From c2be9e6f3ab0220292ddaa260cd2bb80d4abf903 Mon Sep 17 00:00:00 2001 From: Quentin Hibon Date: Fri, 19 Mar 2021 20:43:23 +0100 Subject: [PATCH 001/181] Remove Debian dependency libappindicator1 See [#5031][0]. [0]: https://github.com/signalapp/Signal-Desktop/pull/5031 --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index 83deece918..c734553298 100644 --- a/package.json +++ b/package.json @@ -340,7 +340,6 @@ "deb": { "depends": [ "libnotify4", - "libappindicator1", "libxtst6", "libnss3", "libasound2", From 7c0f5a356e8cae3c1e64e1c5d6e93e57a0fbee56 Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Tue, 2 Mar 2021 13:09:17 -0800 Subject: [PATCH 002/181] Add `curly: 'error'` to eslintrc and fix linting --- .eslintrc.js | 1 + libtextsecure/test/_test.js | 3 ++- libtextsecure/test/fake_web_api.js | 3 ++- .../test/in_memory_signal_protocol_store.js | 15 ++++++++++----- test/metadata/SecretSessionCipher_test.js | 9 ++++++--- 5 files changed, 21 insertions(+), 10 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index d2691c3fbe..e5bdae8c36 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -125,6 +125,7 @@ const rules = { '`with` is disallowed in strict mode because it makes code impossible to predict and optimize.', }, ], + curly: 'error', }; module.exports = { diff --git a/libtextsecure/test/_test.js b/libtextsecure/test/_test.js index b51fa47d9b..6d5a73d42f 100644 --- a/libtextsecure/test/_test.js +++ b/libtextsecure/test/_test.js @@ -54,8 +54,9 @@ window.assertEqualArrayBuffers = (ab1, ab2) => { window.hexToArrayBuffer = str => { const ret = new ArrayBuffer(str.length / 2); const array = new Uint8Array(ret); - for (let i = 0; i < str.length / 2; i += 1) + for (let i = 0; i < str.length / 2; i += 1) { array[i] = parseInt(str.substr(i * 2, 2), 16); + } return ret; }; diff --git a/libtextsecure/test/fake_web_api.js b/libtextsecure/test/fake_web_api.js index 63214c4295..e906c3baa6 100644 --- a/libtextsecure/test/fake_web_api.js +++ b/libtextsecure/test/fake_web_api.js @@ -45,8 +45,9 @@ const fakeAPI = { msg.timestamp === undefined || msg.relay !== undefined || msg.destination !== undefined - ) + ) { throw new Error('Invalid message'); + } messagesSentMap[ `${destination}.${messageArray[i].destinationDeviceId}` diff --git a/libtextsecure/test/in_memory_signal_protocol_store.js b/libtextsecure/test/in_memory_signal_protocol_store.js index 72f48e3deb..f2eb6427a4 100644 --- a/libtextsecure/test/in_memory_signal_protocol_store.js +++ b/libtextsecure/test/in_memory_signal_protocol_store.js @@ -25,21 +25,24 @@ SignalProtocolStore.prototype = { value === undefined || key === null || value === null - ) + ) { throw new Error('Tried to store undefined/null'); + } this.store[key] = value; }, get(key, defaultValue) { - if (key === null || key === undefined) + if (key === null || key === undefined) { throw new Error('Tried to get value for undefined/null key'); + } if (key in this.store) { return this.store[key]; } return defaultValue; }, remove(key) { - if (key === null || key === undefined) + if (key === null || key === undefined) { throw new Error('Tried to remove value for undefined/null key'); + } delete this.store[key]; }, @@ -57,15 +60,17 @@ SignalProtocolStore.prototype = { return Promise.resolve(identityKey === trusted); }, loadIdentityKey(identifier) { - if (identifier === null || identifier === undefined) + if (identifier === null || identifier === undefined) { throw new Error('Tried to get identity key for undefined/null key'); + } return new Promise(resolve => { resolve(this.get(`identityKey${identifier}`)); }); }, saveIdentity(identifier, identityKey) { - if (identifier === null || identifier === undefined) + if (identifier === null || identifier === undefined) { throw new Error('Tried to put identity key for undefined/null key'); + } return new Promise(resolve => { const existing = this.get(`identityKey${identifier}`); this.put(`identityKey${identifier}`, identityKey); diff --git a/test/metadata/SecretSessionCipher_test.js b/test/metadata/SecretSessionCipher_test.js index 03f868d0b7..58b62b8f2b 100644 --- a/test/metadata/SecretSessionCipher_test.js +++ b/test/metadata/SecretSessionCipher_test.js @@ -46,13 +46,15 @@ InMemorySignalProtocolStore.prototype = { value === undefined || key === null || value === null - ) + ) { throw new Error('Tried to store undefined/null'); + } this.store[key] = value; }, get(key, defaultValue) { - if (key === null || key === undefined) + if (key === null || key === undefined) { throw new Error('Tried to get value for undefined/null key'); + } if (key in this.store) { return this.store[key]; } @@ -60,8 +62,9 @@ InMemorySignalProtocolStore.prototype = { return defaultValue; }, remove(key) { - if (key === null || key === undefined) + if (key === null || key === undefined) { throw new Error('Tried to remove value for undefined/null key'); + } delete this.store[key]; }, From 515b713bfd45e59ca4074a5d8b4529484a6f2e2f Mon Sep 17 00:00:00 2001 From: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com> Date: Thu, 11 Feb 2021 13:21:20 -0600 Subject: [PATCH 003/181] Mark discoveredUnregisteredAt as optional --- ts/model-types.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index d8d3394af0..7daba82ad9 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -157,7 +157,7 @@ export type ConversationAttributesType = { addedBy?: string; capabilities?: CapabilitiesType; color?: string; - discoveredUnregisteredAt: number; + discoveredUnregisteredAt?: number; draftAttachments: Array; draftBodyRanges: Array; draftTimestamp: number | null; From 011bdd2ae3d7d0c6579c8c979380e65e6bcae3e1 Mon Sep 17 00:00:00 2001 From: Jordan Rose Date: Fri, 12 Feb 2021 09:59:50 -0800 Subject: [PATCH 004/181] Omit unused resources from built package --- ACKNOWLEDGMENTS.md | 27 --------------------------- package.json | 4 ++-- 2 files changed, 2 insertions(+), 29 deletions(-) diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md index cb1b68758f..f73aa0a87f 100644 --- a/ACKNOWLEDGMENTS.md +++ b/ACKNOWLEDGMENTS.md @@ -1719,33 +1719,6 @@ Signal Desktop makes use of the following open source projects. PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. -## node-gyp - - (The MIT License) - - Copyright (c) 2012 Nathan Rajlich - - Permission is hereby granted, free of charge, to any person - obtaining a copy of this software and associated documentation - files (the "Software"), to deal in the Software without - restriction, including without limitation the rights to use, - copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the - Software is furnished to do so, subject to the following - conditions: - - The above copyright notice and this permission notice shall be - included in all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES - OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND - NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT - HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, - WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING - FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR - OTHER DEALINGS IN THE SOFTWARE. - ## normalize-path The MIT License (MIT) diff --git a/package.json b/package.json index c734553298..0dc480000c 100644 --- a/package.json +++ b/package.json @@ -101,7 +101,6 @@ "mustache": "2.3.0", "node-fetch": "2.6.1", "node-forge": "0.10.0", - "node-gyp": "5.0.3", "normalize-path": "3.0.0", "os-locale": "3.0.1", "p-map": "2.1.0", @@ -242,6 +241,7 @@ "jsdoc": "3.6.2", "mocha": "4.1.0", "mocha-testcheck": "1.0.0-rc.0", + "node-gyp": "5.0.3", "node-sass": "4.14.1", "node-sass-import-once": "1.2.0", "npm-run-all": "4.1.5", @@ -407,7 +407,7 @@ "node_modules/socks/build/client/*.js", "node_modules/smart-buffer/build/*.js", "node_modules/sharp/build/**", - "!node_modules/sharp/{install,src,vendor/include}", + "!node_modules/sharp/{install,src,vendor/include,vendor/*/include}", "!node_modules/@journeyapps/sqlcipher/deps/*", "!node_modules/@journeyapps/sqlcipher/build/*", "!node_modules/@journeyapps/sqlcipher/build-tmp-napi-*", From eb203ba929d5cb8e12f2ec7ff83ea54cb62cef97 Mon Sep 17 00:00:00 2001 From: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com> Date: Fri, 12 Feb 2021 15:58:14 -0600 Subject: [PATCH 005/181] Disable search keyboard shortcuts when main header isn't shown --- ts/background.ts | 37 +--------- ts/components/MainHeader.stories.tsx | 5 +- ts/components/MainHeader.tsx | 68 +++++++++++++++---- ts/state/ducks/conversations.ts | 22 +++--- ts/state/selectors/conversations.ts | 26 ++++++- ts/state/selectors/search.ts | 4 +- ts/state/smart/LeftPane.tsx | 4 +- ts/state/smart/MainHeader.tsx | 3 +- .../state/selectors/conversations_test.ts | 63 +++++++++++++++++ ts/util/lint/exceptions.json | 4 +- 10 files changed, 166 insertions(+), 70 deletions(-) diff --git a/ts/background.ts b/ts/background.ts index 3549b95104..1469ad78d3 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -948,10 +948,9 @@ type WhatIsThis = import('./window.d').WhatIsThis; const commandKey = window.platform === 'darwin' && metaKey; const controlKey = window.platform !== 'darwin' && ctrlKey; const commandOrCtrl = commandKey || controlKey; - const commandAndCtrl = commandKey && ctrlKey; const state = store.getState(); - const selectedId = state.conversations.selectedConversation; + const selectedId = state.conversations.selectedConversationId; const conversation = window.ConversationController.get(selectedId); const isSearching = window.Signal.State.Selectors.search.isSearching( state @@ -1265,40 +1264,6 @@ type WhatIsThis = import('./window.d').WhatIsThis; return; } - // Search - if ( - commandOrCtrl && - !commandAndCtrl && - !shiftKey && - (key === 'f' || key === 'F') - ) { - const { startSearch } = actions.search; - startSearch(); - - event.preventDefault(); - event.stopPropagation(); - return; - } - - // Search in conversation - if ( - conversation && - commandOrCtrl && - !commandAndCtrl && - shiftKey && - (key === 'f' || key === 'F') - ) { - const { searchInConversation } = actions.search; - const name = conversation.isMe() - ? window.i18n('noteToSelf') - : conversation.getTitle(); - searchInConversation(conversation.id, name); - - event.preventDefault(); - event.stopPropagation(); - return; - } - // Focus composer field if ( conversation && diff --git a/ts/components/MainHeader.stories.tsx b/ts/components/MainHeader.stories.tsx index e54303e408..42bae14499 100644 --- a/ts/components/MainHeader.stories.tsx +++ b/ts/components/MainHeader.stories.tsx @@ -33,6 +33,7 @@ const createProps = (overrideProps: Partial = {}): PropsType => ({ 'searchConversationId', overrideProps.searchConversationId ), + selectedConversation: undefined, startSearchCounter: 0, ourConversationId: '', @@ -46,10 +47,12 @@ const createProps = (overrideProps: Partial = {}): PropsType => ({ avatarPath: optionalText('avatarPath', overrideProps.avatarPath), i18n, + updateSearchTerm: action('updateSearchTerm'), searchMessages: action('searchMessages'), searchDiscussions: action('searchDiscussions'), - + startSearch: action('startSearch'), + searchInConversation: action('searchInConversation'), clearConversationSearch: action('clearConversationSearch'), clearSearch: action('clearSearch'), diff --git a/ts/components/MainHeader.tsx b/ts/components/MainHeader.tsx index fdb57856ad..e0c7198551 100644 --- a/ts/components/MainHeader.tsx +++ b/ts/components/MainHeader.tsx @@ -12,12 +12,14 @@ import { Avatar } from './Avatar'; import { AvatarPopup } from './AvatarPopup'; import { LocalizerType } from '../types/Util'; import { ColorType } from '../types/Colors'; +import { ConversationType } from '../state/ducks/conversations'; export type PropsType = { searchTerm: string; searchConversationName?: string; searchConversationId?: string; startSearchCounter: number; + selectedConversation: undefined | ConversationType; // To be used as an ID ourConversationId: string; @@ -36,7 +38,10 @@ export type PropsType = { avatarPath?: string; i18n: LocalizerType; + updateSearchTerm: (searchTerm: string) => void; + startSearch: () => void; + searchInConversation: (id: string, name: string) => void; searchMessages: ( query: string, options: { @@ -53,7 +58,6 @@ export type PropsType = { noteToSelf: string; } ) => void; - clearConversationSearch: () => void; clearSearch: () => void; @@ -107,12 +111,6 @@ export class MainHeader extends React.Component { } }; - public handleOutsideKeyDown = (event: KeyboardEvent): void => { - if (event.key === 'Escape') { - this.hideAvatarPopup(); - } - }; - public showAvatarPopup = (): void => { const popperRoot = document.createElement('div'); document.body.appendChild(popperRoot); @@ -122,14 +120,12 @@ export class MainHeader extends React.Component { popperRoot, }); document.addEventListener('click', this.handleOutsideClick); - document.addEventListener('keydown', this.handleOutsideKeyDown); }; public hideAvatarPopup = (): void => { const { popperRoot } = this.state; document.removeEventListener('click', this.handleOutsideClick); - document.removeEventListener('keydown', this.handleOutsideKeyDown); this.setState({ showingAvatarPopup: false, @@ -141,11 +137,15 @@ export class MainHeader extends React.Component { } }; + public componentDidMount(): void { + document.addEventListener('keydown', this.handleGlobalKeyDown); + } + public componentWillUnmount(): void { const { popperRoot } = this.state; document.removeEventListener('click', this.handleOutsideClick); - document.removeEventListener('keydown', this.handleOutsideKeyDown); + document.removeEventListener('keydown', this.handleGlobalKeyDown); if (popperRoot && document.body.contains(popperRoot)) { document.body.removeChild(popperRoot); @@ -225,7 +225,7 @@ export class MainHeader extends React.Component { this.setFocus(); }; - public handleKeyDown = ( + public handleInputKeyDown = ( event: React.KeyboardEvent ): void => { const { @@ -262,6 +262,50 @@ export class MainHeader extends React.Component { event.stopPropagation(); }; + public handleGlobalKeyDown = (event: KeyboardEvent): void => { + const { showingAvatarPopup } = this.state; + const { + i18n, + selectedConversation, + startSearch, + searchInConversation, + } = this.props; + + const { ctrlKey, metaKey, shiftKey, key } = event; + const commandKey = get(window, 'platform') === 'darwin' && metaKey; + const controlKey = get(window, 'platform') !== 'darwin' && ctrlKey; + const commandOrCtrl = commandKey || controlKey; + const commandAndCtrl = commandKey && ctrlKey; + + if (showingAvatarPopup && key === 'Escape') { + this.hideAvatarPopup(); + } else if ( + commandOrCtrl && + !commandAndCtrl && + !shiftKey && + (key === 'f' || key === 'F') + ) { + startSearch(); + + event.preventDefault(); + event.stopPropagation(); + } else if ( + selectedConversation && + commandOrCtrl && + !commandAndCtrl && + shiftKey && + (key === 'f' || key === 'F') + ) { + const name = selectedConversation.isMe + ? i18n('noteToSelf') + : selectedConversation.title; + searchInConversation(selectedConversation.id, name); + + event.preventDefault(); + event.stopPropagation(); + } + }; + public handleXButton = (): void => { const { searchConversationId, @@ -398,7 +442,7 @@ export class MainHeader extends React.Component { )} placeholder={placeholder} dir="auto" - onKeyDown={this.handleKeyDown} + onKeyDown={this.handleInputKeyDown} value={searchTerm} onChange={this.updateSearch} /> diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 3593df3153..0c73476dbb 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -225,7 +225,7 @@ export type ConversationsStateType = { conversationsByE164: ConversationLookupType; conversationsByUuid: ConversationLookupType; conversationsByGroupId: ConversationLookupType; - selectedConversation?: string; + selectedConversationId?: string; selectedMessage?: string; selectedMessageCounter: number; selectedConversationTitle?: string; @@ -981,7 +981,7 @@ export function reducer( const { id, data } = payload; const { conversationLookup } = state; - let { showArchived, selectedConversation } = state; + let { showArchived, selectedConversationId } = state; const existing = conversationLookup[id]; // In the change case we only modify the lookup if we already had that conversation @@ -989,7 +989,7 @@ export function reducer( return state; } - if (selectedConversation === id) { + if (selectedConversationId === id) { // Archived -> Inbox: we go back to the normal inbox view if (existing.isArchived && !data.isArchived) { showArchived = false; @@ -999,13 +999,13 @@ export function reducer( // behavior - no selected conversation in the left pane, but a conversation show // in the right pane. if (!existing.isArchived && data.isArchived) { - selectedConversation = undefined; + selectedConversationId = undefined; } } return { ...state, - selectedConversation, + selectedConversationId, showArchived, conversationLookup: { ...conversationLookup, @@ -1040,14 +1040,14 @@ export function reducer( } const { messageIds } = existingConversation; - const selectedConversation = - state.selectedConversation !== id - ? state.selectedConversation + const selectedConversationId = + state.selectedConversationId !== id + ? state.selectedConversationId : undefined; return { ...state, - selectedConversation, + selectedConversationId, selectedConversationPanelDepth: 0, messagesLookup: omit(state.messagesLookup, messageIds), messagesByConversation: omit(state.messagesByConversation, [id]), @@ -1065,7 +1065,7 @@ export function reducer( if (action.type === 'MESSAGE_SELECTED') { const { messageId, conversationId } = action.payload; - if (state.selectedConversation !== conversationId) { + if (state.selectedConversationId !== conversationId) { return state; } @@ -1621,7 +1621,7 @@ export function reducer( return { ...state, - selectedConversation: id, + selectedConversationId: id, }; } if (action.type === 'SHOW_INBOX') { diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index 9cda1fbbeb..079f7a9e99 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -22,6 +22,7 @@ import { getCallsByConversation } from './calling'; import { getBubbleProps } from '../../shims/Whisper'; import { PropsDataType as TimelinePropsType } from '../../components/conversation/Timeline'; import { TimelineItemType } from '../../components/conversation/TimelineItem'; +import { assert } from '../../util/assert'; import { getInteractionMode, @@ -83,10 +84,29 @@ export const getConversationsByGroupId = createSelector( } ); -export const getSelectedConversation = createSelector( +export const getSelectedConversationId = createSelector( getConversations, (state: ConversationsStateType): string | undefined => { - return state.selectedConversation; + return state.selectedConversationId; + } +); + +export const getSelectedConversation = createSelector( + getSelectedConversationId, + getConversationLookup, + ( + selectedConversationId: string | undefined, + conversationLookup: ConversationLookupType + ): undefined | ConversationType => { + if (!selectedConversationId) { + return undefined; + } + const conversation = getOwn(conversationLookup, selectedConversationId); + assert( + conversation, + 'getSelectedConversation: could not find selected conversation in lookup; returning undefined' + ); + return conversation; } ); @@ -221,7 +241,7 @@ export const _getLeftPaneLists = ( export const getLeftPaneLists = createSelector( getConversationLookup, getConversationComparator, - getSelectedConversation, + getSelectedConversationId, getPinnedConversationIds, _getLeftPaneLists ); diff --git a/ts/state/selectors/search.ts b/ts/state/selectors/search.ts index 069a126dfe..2bca7627d3 100644 --- a/ts/state/selectors/search.ts +++ b/ts/state/selectors/search.ts @@ -29,7 +29,7 @@ import { GetConversationByIdType, getConversationLookup, getConversationSelector, - getSelectedConversation, + getSelectedConversationId, } from './conversations'; export const getSearch = (state: StateType): SearchStateType => state.search; @@ -78,7 +78,7 @@ export const getSearchResults = createSelector( getRegionCode, getUserAgent, getConversationLookup, - getSelectedConversation, + getSelectedConversationId, getSelectedMessage, ], ( diff --git a/ts/state/smart/LeftPane.tsx b/ts/state/smart/LeftPane.tsx index 4e0c6ea8e7..839e1d8d96 100644 --- a/ts/state/smart/LeftPane.tsx +++ b/ts/state/smart/LeftPane.tsx @@ -11,7 +11,7 @@ import { getSearchResults, isSearching } from '../selectors/search'; import { getIntl } from '../selectors/user'; import { getLeftPaneLists, - getSelectedConversation, + getSelectedConversationId, getShowArchived, } from '../selectors/conversations'; @@ -52,7 +52,7 @@ const mapStateToProps = (state: StateType) => { const lists = showSearch ? undefined : getLeftPaneLists(state); const searchResults = showSearch ? getSearchResults(state) : undefined; - const selectedConversationId = getSelectedConversation(state); + const selectedConversationId = getSelectedConversationId(state); return { ...lists, diff --git a/ts/state/smart/MainHeader.tsx b/ts/state/smart/MainHeader.tsx index 958a1fc7e4..1bc77fa883 100644 --- a/ts/state/smart/MainHeader.tsx +++ b/ts/state/smart/MainHeader.tsx @@ -20,13 +20,14 @@ import { getUserNumber, getUserUuid, } from '../selectors/user'; -import { getMe } from '../selectors/conversations'; +import { getMe, getSelectedConversation } from '../selectors/conversations'; const mapStateToProps = (state: StateType) => { return { searchTerm: getQuery(state), searchConversationId: getSearchConversationId(state), searchConversationName: getSearchConversationName(state), + selectedConversation: getSelectedConversation(state), startSearchCounter: getStartSearchCounter(state), regionCode: getRegionCode(state), ourConversationId: getUserConversationId(state), diff --git a/ts/test-both/state/selectors/conversations_test.ts b/ts/test-both/state/selectors/conversations_test.ts index b7f9077217..6ba2a611b6 100644 --- a/ts/test-both/state/selectors/conversations_test.ts +++ b/ts/test-both/state/selectors/conversations_test.ts @@ -13,6 +13,8 @@ import { _getLeftPaneLists, getConversationSelector, getPlaceholderContact, + getSelectedConversation, + getSelectedConversationId, } from '../../../state/selectors/conversations'; import { noopAction } from '../../../state/ducks/noop'; import { StateType, reducer as rootReducer } from '../../../state/reducer'; @@ -446,4 +448,65 @@ describe('both/state/selectors/conversations', () => { }); }); }); + + describe('#getSelectedConversationId', () => { + it('returns undefined if no conversation is selected', () => { + const state = { + ...getEmptyRootState(), + conversations: { + ...getEmptyState(), + conversationLookup: { + abc123: getDefaultConversation('abc123'), + }, + }, + }; + assert.isUndefined(getSelectedConversationId(state)); + }); + + it('returns the selected conversation ID', () => { + const state = { + ...getEmptyRootState(), + conversations: { + ...getEmptyState(), + conversationLookup: { + abc123: getDefaultConversation('abc123'), + }, + selectedConversationId: 'abc123', + }, + }; + assert.strictEqual(getSelectedConversationId(state), 'abc123'); + }); + }); + + describe('#getSelectedConversation', () => { + it('returns undefined if no conversation is selected', () => { + const state = { + ...getEmptyRootState(), + conversations: { + ...getEmptyState(), + conversationLookup: { + abc123: getDefaultConversation('abc123'), + }, + }, + }; + assert.isUndefined(getSelectedConversation(state)); + }); + + it('returns the selected conversation ID', () => { + const state = { + ...getEmptyRootState(), + conversations: { + ...getEmptyState(), + conversationLookup: { + abc123: getDefaultConversation('abc123'), + }, + selectedConversationId: 'abc123', + }, + }; + assert.deepEqual( + getSelectedConversation(state), + getDefaultConversation('abc123') + ); + }); + }); }); diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index bdcaaec26b..e8ef749bab 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -14631,7 +14631,7 @@ "rule": "React-createRef", "path": "ts/components/MainHeader.js", "line": " this.inputRef = react_1.default.createRef();", - "lineNumber": 146, + "lineNumber": 171, "reasonCategory": "usageTrusted", "updated": "2020-02-14T20:02:37.507Z", "reasonDetail": "Used only to set focus" @@ -14640,7 +14640,7 @@ "rule": "React-createRef", "path": "ts/components/MainHeader.tsx", "line": " this.inputRef = React.createRef();", - "lineNumber": 74, + "lineNumber": 78, "reasonCategory": "usageTrusted", "updated": "2020-02-14T20:02:37.507Z", "reasonDetail": "Used only to set focus" From 6bbcf8677233ba4214ef6228c089ba7240fc2f26 Mon Sep 17 00:00:00 2001 From: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com> Date: Fri, 12 Feb 2021 18:40:39 -0600 Subject: [PATCH 006/181] Shorten searchConversations SQL query --- ts/sql/Server.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index dff4eff820..ed2d3613c7 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -2391,16 +2391,14 @@ async function searchConversations( const rows = await db.all( `SELECT json FROM conversations WHERE ( - e164 LIKE $e164 OR - name LIKE $name OR - profileFullName LIKE $profileFullName + e164 LIKE $query OR + name LIKE $query OR + profileFullName LIKE $query ) ORDER BY active_at DESC LIMIT $limit`, { - $e164: `%${query}%`, - $name: `%${query}%`, - $profileFullName: `%${query}%`, + $query: `%${query}%`, $limit: limit || 100, } ); From fe187226bb8972e0559e377a4b269cef3976d1f2 Mon Sep 17 00:00:00 2001 From: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com> Date: Fri, 12 Feb 2021 18:43:24 -0600 Subject: [PATCH 007/181] Upgrade protobufjs to 6.10.2 --- package.json | 2 +- ts/util/lint/exceptions.json | 54 ++++++++++++++++++------------------ yarn.lock | 29 ++++++++----------- 3 files changed, 40 insertions(+), 45 deletions(-) diff --git a/package.json b/package.json index 0dc480000c..ebb862707b 100644 --- a/package.json +++ b/package.json @@ -109,7 +109,7 @@ "parchment": "1.1.4", "pify": "3.0.0", "popper.js": "1.15.0", - "protobufjs": "6.8.6", + "protobufjs": "6.10.2", "proxy-agent": "3.1.1", "quill": "1.3.7", "quill-delta": "4.0.1", diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index e8ef749bab..63727572f2 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -11947,7 +11947,7 @@ "rule": "eval", "path": "node_modules/protobufjs/dist/light/protobuf.js", "line": " var mod = eval(\"quire\".replace(/^/,\"re\"))(moduleName); // eslint-disable-line no-eval", - "lineNumber": 878, + "lineNumber": 880, "reasonCategory": "usageTrusted", "updated": "2019-03-09T00:08:44.242Z", "reasonDetail": "Eval is used here to produce and evaluate the expression 'require'" @@ -11956,7 +11956,7 @@ "rule": "jQuery-load(", "path": "node_modules/protobufjs/dist/light/protobuf.js", "line": "function load(filename, root, callback) {", - "lineNumber": 2185, + "lineNumber": 2217, "reasonCategory": "falseMatch", "updated": "2019-03-09T00:08:44.242Z" }, @@ -11964,7 +11964,7 @@ "rule": "jQuery-load(", "path": "node_modules/protobufjs/dist/light/protobuf.js", "line": " return root.load(filename, callback);", - "lineNumber": 2191, + "lineNumber": 2223, "reasonCategory": "falseMatch", "updated": "2019-03-09T00:08:44.242Z" }, @@ -11972,7 +11972,7 @@ "rule": "jQuery-load(", "path": "node_modules/protobufjs/dist/light/protobuf.js", "line": "// function load(filename:string, callback:LoadCallback):undefined", - "lineNumber": 2204, + "lineNumber": 2236, "reasonCategory": "exampleCode", "updated": "2019-03-09T00:08:44.242Z" }, @@ -11980,7 +11980,7 @@ "rule": "jQuery-load(", "path": "node_modules/protobufjs/dist/light/protobuf.js", "line": "// function load(filename:string, [root:Root]):Promise", - "lineNumber": 2216, + "lineNumber": 2248, "reasonCategory": "exampleCode", "updated": "2019-03-09T00:08:44.242Z" }, @@ -11988,7 +11988,7 @@ "rule": "jQuery-load(", "path": "node_modules/protobufjs/dist/light/protobuf.js", "line": "Root.prototype.load = function load(filename, options, callback) {", - "lineNumber": 4096, + "lineNumber": 4195, "reasonCategory": "falseMatch", "updated": "2019-03-09T00:08:44.242Z" }, @@ -11996,7 +11996,7 @@ "rule": "jQuery-load(", "path": "node_modules/protobufjs/dist/light/protobuf.js", "line": "// function load(filename:string, options:IParseOptions, callback:LoadCallback):undefined", - "lineNumber": 4223, + "lineNumber": 4324, "reasonCategory": "exampleCode", "updated": "2019-03-09T00:08:44.242Z" }, @@ -12004,7 +12004,7 @@ "rule": "jQuery-load(", "path": "node_modules/protobufjs/dist/light/protobuf.js", "line": "// function load(filename:string, callback:LoadCallback):undefined", - "lineNumber": 4233, + "lineNumber": 4334, "reasonCategory": "exampleCode", "updated": "2019-03-09T00:08:44.242Z" }, @@ -12012,7 +12012,7 @@ "rule": "jQuery-load(", "path": "node_modules/protobufjs/dist/light/protobuf.js", "line": "// function load(filename:string, [options:IParseOptions]):Promise", - "lineNumber": 4243, + "lineNumber": 4344, "reasonCategory": "exampleCode", "updated": "2019-03-09T00:08:44.242Z" }, @@ -12020,7 +12020,7 @@ "rule": "jQuery-load(", "path": "node_modules/protobufjs/dist/light/protobuf.js", "line": " return this.load(filename, options, SYNC);", - "lineNumber": 4256, + "lineNumber": 4357, "reasonCategory": "falseMatch", "updated": "2019-03-09T00:08:44.242Z" }, @@ -12043,7 +12043,7 @@ "rule": "eval", "path": "node_modules/protobufjs/dist/minimal/protobuf.js", "line": " var mod = eval(\"quire\".replace(/^/,\"re\"))(moduleName); // eslint-disable-line no-eval", - "lineNumber": 660, + "lineNumber": 662, "reasonCategory": "usageTrusted", "updated": "2019-03-09T00:08:44.242Z", "reasonDetail": "Eval is used here to produce and evaluate the expression 'require'" @@ -12060,7 +12060,7 @@ "rule": "eval", "path": "node_modules/protobufjs/dist/protobuf.js", "line": " var mod = eval(\"quire\".replace(/^/,\"re\"))(moduleName); // eslint-disable-line no-eval", - "lineNumber": 878, + "lineNumber": 880, "reasonCategory": "usageTrusted", "updated": "2019-03-09T00:08:44.242Z", "reasonDetail": "Eval is used here to produce and evaluate the expression 'require'" @@ -12069,7 +12069,7 @@ "rule": "jQuery-load(", "path": "node_modules/protobufjs/dist/protobuf.js", "line": "function load(filename, root, callback) {", - "lineNumber": 2586, + "lineNumber": 2618, "reasonCategory": "falseMatch", "updated": "2019-03-09T00:08:44.242Z" }, @@ -12077,7 +12077,7 @@ "rule": "jQuery-load(", "path": "node_modules/protobufjs/dist/protobuf.js", "line": " return root.load(filename, callback);", - "lineNumber": 2592, + "lineNumber": 2624, "reasonCategory": "falseMatch", "updated": "2019-03-09T00:08:44.242Z" }, @@ -12085,7 +12085,7 @@ "rule": "jQuery-load(", "path": "node_modules/protobufjs/dist/protobuf.js", "line": "// function load(filename:string, callback:LoadCallback):undefined", - "lineNumber": 2605, + "lineNumber": 2637, "reasonCategory": "exampleCode", "updated": "2019-03-09T00:08:44.242Z" }, @@ -12093,7 +12093,7 @@ "rule": "jQuery-load(", "path": "node_modules/protobufjs/dist/protobuf.js", "line": "// function load(filename:string, [root:Root]):Promise", - "lineNumber": 2617, + "lineNumber": 2649, "reasonCategory": "exampleCode", "updated": "2019-03-09T00:08:44.242Z" }, @@ -12101,7 +12101,7 @@ "rule": "jQuery-load(", "path": "node_modules/protobufjs/dist/protobuf.js", "line": "Root.prototype.load = function load(filename, options, callback) {", - "lineNumber": 5270, + "lineNumber": 5399, "reasonCategory": "falseMatch", "updated": "2019-03-09T00:08:44.242Z" }, @@ -12109,7 +12109,7 @@ "rule": "jQuery-load(", "path": "node_modules/protobufjs/dist/protobuf.js", "line": "// function load(filename:string, options:IParseOptions, callback:LoadCallback):undefined", - "lineNumber": 5397, + "lineNumber": 5528, "reasonCategory": "exampleCode", "updated": "2019-03-09T00:08:44.242Z" }, @@ -12117,7 +12117,7 @@ "rule": "jQuery-load(", "path": "node_modules/protobufjs/dist/protobuf.js", "line": "// function load(filename:string, callback:LoadCallback):undefined", - "lineNumber": 5407, + "lineNumber": 5538, "reasonCategory": "exampleCode", "updated": "2019-03-09T00:08:44.242Z" }, @@ -12125,7 +12125,7 @@ "rule": "jQuery-load(", "path": "node_modules/protobufjs/dist/protobuf.js", "line": "// function load(filename:string, [options:IParseOptions]):Promise", - "lineNumber": 5417, + "lineNumber": 5548, "reasonCategory": "exampleCode", "updated": "2019-03-09T00:08:44.242Z" }, @@ -12133,7 +12133,7 @@ "rule": "jQuery-load(", "path": "node_modules/protobufjs/dist/protobuf.js", "line": " return this.load(filename, options, SYNC);", - "lineNumber": 5430, + "lineNumber": 5561, "reasonCategory": "falseMatch", "updated": "2019-03-09T00:08:44.242Z" }, @@ -12195,7 +12195,7 @@ "rule": "jQuery-load(", "path": "node_modules/protobufjs/src/root.js", "line": "Root.prototype.load = function load(filename, options, callback) {", - "lineNumber": 75, + "lineNumber": 85, "reasonCategory": "falseMatch", "updated": "2018-09-15T00:38:04.183Z" }, @@ -12203,7 +12203,7 @@ "rule": "jQuery-load(", "path": "node_modules/protobufjs/src/root.js", "line": "// function load(filename:string, options:IParseOptions, callback:LoadCallback):undefined", - "lineNumber": 202, + "lineNumber": 214, "reasonCategory": "falseMatch", "updated": "2018-09-15T00:38:04.183Z" }, @@ -12211,7 +12211,7 @@ "rule": "jQuery-load(", "path": "node_modules/protobufjs/src/root.js", "line": "// function load(filename:string, callback:LoadCallback):undefined", - "lineNumber": 212, + "lineNumber": 224, "reasonCategory": "falseMatch", "updated": "2018-09-15T00:38:04.183Z" }, @@ -12219,7 +12219,7 @@ "rule": "jQuery-load(", "path": "node_modules/protobufjs/src/root.js", "line": "// function load(filename:string, [options:IParseOptions]):Promise", - "lineNumber": 222, + "lineNumber": 234, "reasonCategory": "falseMatch", "updated": "2018-09-15T00:38:04.183Z" }, @@ -12227,7 +12227,7 @@ "rule": "jQuery-load(", "path": "node_modules/protobufjs/src/root.js", "line": " return this.load(filename, options, SYNC);", - "lineNumber": 235, + "lineNumber": 247, "reasonCategory": "falseMatch", "updated": "2018-09-15T00:38:04.183Z" }, @@ -15321,4 +15321,4 @@ "updated": "2021-01-08T15:46:32.143Z", "reasonDetail": "Doesn't manipulate the DOM. This is just a function." } -] +] \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 0c33106a38..f21c0b6304 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2313,16 +2313,11 @@ resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.106.tgz#6093e9a02aa567ddecfe9afadca89e53e5dce4dd" integrity sha512-tOSvCVrvSqFZ4A/qrqqm6p37GZoawsZtoR0SJhlF7EonNZUgrn8FfT+RNQ11h+NUpMt6QVe36033f3qEKBwfWA== -"@types/long@4.0.1": +"@types/long@4.0.1", "@types/long@^4.0.1": version "4.0.1" resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.1.tgz#459c65fa1867dafe6a8f322c4c51695663cc55e9" integrity sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w== -"@types/long@^3.0.32": - version "3.0.32" - resolved "https://registry.yarnpkg.com/@types/long/-/long-3.0.32.tgz#f4e5af31e9e9b196d8e5fca8a5e2e20aa3d60b69" - integrity sha512-ZXyOOm83p7X8p3s0IYM3VeueNmHpkk/yMlP8CLeOnEcu6hIwPH7YjZBvhQkR0ZFS2DqZAxKtJ/M5fcuv3OU5BA== - "@types/memoizee@0.4.2": version "0.4.2" resolved "https://registry.yarnpkg.com/@types/memoizee/-/memoizee-0.4.2.tgz#a500158999a8144a9b46cf9a9fb49b15f1853573" @@ -2375,16 +2370,16 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-12.12.27.tgz#d7506f73160ad30fcebbcf5b8b7d2d976e649e42" integrity sha512-odQFl/+B9idbdS0e8IxDl2ia/LP8KZLXhV3BUeI98TrZp0uoIzQPhGd+5EtzHmT0SMOIaPd7jfz6pOHLWTtl7A== +"@types/node@^13.7.0": + version "13.13.41" + resolved "https://registry.yarnpkg.com/@types/node/-/node-13.13.41.tgz#045a4981318d31a581650ce70f340a32c3461198" + integrity sha512-qLT9IvHiXJfdrje9VmsLzun7cQ65obsBTmtU3EOnCSLFOoSHx1hpiRHoBnpdbyFqnzqdUUIv81JcEJQCB8un9g== + "@types/node@^6.14.4": version "6.14.10" resolved "https://registry.yarnpkg.com/@types/node/-/node-6.14.10.tgz#d9ce598127eb0cc02821476862d11389cb01f6a4" integrity sha512-pF4HjZGSog75kGq7B1InK/wt/N08BuPATo+7HRfv7gZUzccebwv/fmWVGs/j6LvSiLWpCuGGhql51M/wcQsNzA== -"@types/node@^8.9.4": - version "8.10.40" - resolved "https://registry.yarnpkg.com/@types/node/-/node-8.10.40.tgz#4314888d5cd537945d73e9ce165c04cc550144a4" - integrity sha512-RRSjdwz63kS4u7edIwJUn8NqKLLQ6LyqF/X4+4jp38MBT3Vwetewi2N4dgJEshLbDwNgOJXNYoOwzVZUSSLhkQ== - "@types/normalize-path@3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@types/normalize-path/-/normalize-path-3.0.0.tgz#bb5c46cab77b93350b4cf8d7ff1153f47189ae31" @@ -12945,10 +12940,10 @@ proto-list@~1.2.1: resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" integrity sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk= -protobufjs@6.8.6: - version "6.8.6" - resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-6.8.6.tgz#ce3cf4fff9625b62966c455fc4c15e4331a11ca2" - integrity sha512-eH2OTP9s55vojr3b7NBaF9i4WhWPkv/nq55nznWNp/FomKrLViprUcqnBjHph2tFQ+7KciGPTPsVWGz0SOhL0Q== +protobufjs@6.10.2: + version "6.10.2" + resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-6.10.2.tgz#b9cb6bd8ec8f87514592ba3fdfd28e93f33a469b" + integrity sha512-27yj+04uF6ya9l+qfpH187aqEzfCF4+Uit0I9ZBQVqK09hk/SQzKa2MUqUpXaVa7LOFRg1TSSr3lVxGOk6c0SQ== dependencies: "@protobufjs/aspromise" "^1.1.2" "@protobufjs/base64" "^1.1.2" @@ -12960,8 +12955,8 @@ protobufjs@6.8.6: "@protobufjs/path" "^1.1.2" "@protobufjs/pool" "^1.1.0" "@protobufjs/utf8" "^1.1.0" - "@types/long" "^3.0.32" - "@types/node" "^8.9.4" + "@types/long" "^4.0.1" + "@types/node" "^13.7.0" long "^4.0.0" protocols@^1.1.0, protocols@^1.4.0: From 98e7e65d25fc7f49dc124dc4538b7d65337ff916 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Thu, 18 Feb 2021 08:40:26 -0800 Subject: [PATCH 008/181] Automatic session reset --- _locales/en/messages.json | 16 ++ images/chat-session-refresh.svg | 12 ++ images/icons/v2/refresh-16.svg | 3 + libtextsecure/test/fake_web_api.js | 2 +- .../test/in_memory_signal_protocol_store.js | 11 ++ libtextsecure/test/index.html | 5 +- libtextsecure/test/message_receiver_test.js | 186 ++++++++++-------- preload.js | 1 + stylesheets/_modules.scss | 82 ++++++++ test/index.html | 8 - ts/background.ts | 117 +++-------- .../ChatSessionRefreshedDialog.stories.tsx | 25 +++ .../ChatSessionRefreshedDialog.tsx | 57 ++++++ ...atSessionRefreshedNotification.stories.tsx | 24 +++ .../ChatSessionRefreshedNotification.tsx | 63 ++++++ .../conversation/Timeline.stories.tsx | 2 + .../conversation/TimelineItem.stories.tsx | 1 + ts/components/conversation/TimelineItem.tsx | 19 +- ts/models/conversations.ts | 29 +++ ts/models/messages.ts | 20 ++ ts/textsecure/MessageReceiver.ts | 142 +++++++++++-- ts/textsecure/SendMessage.ts | 74 ++++--- ts/util/index.ts | 2 + ts/util/mapToSupportLocale.ts | 115 +++++++++++ ts/views/conversation_view.ts | 10 + ts/window.d.ts | 2 + 26 files changed, 803 insertions(+), 225 deletions(-) create mode 100644 images/chat-session-refresh.svg create mode 100644 images/icons/v2/refresh-16.svg create mode 100644 ts/components/conversation/ChatSessionRefreshedDialog.stories.tsx create mode 100644 ts/components/conversation/ChatSessionRefreshedDialog.tsx create mode 100644 ts/components/conversation/ChatSessionRefreshedNotification.stories.tsx create mode 100644 ts/components/conversation/ChatSessionRefreshedNotification.tsx create mode 100644 ts/util/mapToSupportLocale.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 3fe43aaffc..fdd9ac3adb 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1077,6 +1077,22 @@ "message": "Secure session reset", "description": "This is a past tense, informational message. In other words, your secure session has been reset." }, + "ChatRefresh--notification": { + "message": "Chat session refreshed", + "description": "Shown in timeline when a error happened, and the session was automatically reset." + }, + "ChatRefresh--learnMore": { + "message": "Learn More", + "description": "Shown in timeline when session is automatically reset, to provide access to a popup info dialog" + }, + "ChatRefresh--summary": { + "message": "Signal uses end-to-end encryption and it may need to refresh your chat session sometimes. This doesn’t affect your chat’s security but you may have missed a message from this contact and you can ask them to resend it.", + "description": "Shown on explainer dialog available from chat session refreshed timeline events" + }, + "ChatRefresh--contactSupport": { + "message": "Contact Support", + "description": "Shown on explainer dialog available from chat session refreshed timeline events" + }, "quoteThumbnailAlt": { "message": "Thumbnail of image from quoted message", "description": "Used in alt tag of thumbnail images inside of an embedded message quote" diff --git a/images/chat-session-refresh.svg b/images/chat-session-refresh.svg new file mode 100644 index 0000000000..be9410200c --- /dev/null +++ b/images/chat-session-refresh.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/images/icons/v2/refresh-16.svg b/images/icons/v2/refresh-16.svg new file mode 100644 index 0000000000..9d417e1a61 --- /dev/null +++ b/images/icons/v2/refresh-16.svg @@ -0,0 +1,3 @@ + + + diff --git a/libtextsecure/test/fake_web_api.js b/libtextsecure/test/fake_web_api.js index e906c3baa6..3c9864ca88 100644 --- a/libtextsecure/test/fake_web_api.js +++ b/libtextsecure/test/fake_web_api.js @@ -14,7 +14,7 @@ const fakeAPI = { getAvatar: fakeCall, getDevices: fakeCall, // getKeysForIdentifier : fakeCall, - getMessageSocket: fakeCall, + getMessageSocket: () => new window.MockSocket('ws://localhost:8081/'), getMyKeys: fakeCall, getProfile: fakeCall, getProvisioningSocket: fakeCall, diff --git a/libtextsecure/test/in_memory_signal_protocol_store.js b/libtextsecure/test/in_memory_signal_protocol_store.js index f2eb6427a4..801f726f1e 100644 --- a/libtextsecure/test/in_memory_signal_protocol_store.js +++ b/libtextsecure/test/in_memory_signal_protocol_store.js @@ -166,4 +166,15 @@ SignalProtocolStore.prototype = { resolve(deviceIds); }); }, + + getUnprocessedCount: () => Promise.resolve(0), + getAllUnprocessed: () => Promise.resolve([]), + getUnprocessedById: () => Promise.resolve(null), + addUnprocessed: () => Promise.resolve(), + addMultipleUnprocessed: () => Promise.resolve(), + updateUnprocessedAttempts: () => Promise.resolve(), + updateUnprocessedWithData: () => Promise.resolve(), + updateUnprocessedsWithData: () => Promise.resolve(), + removeUnprocessed: () => Promise.resolve(), + removeAllUnprocessed: () => Promise.resolve(), }; diff --git a/libtextsecure/test/index.html b/libtextsecure/test/index.html index 0f6b58901f..5fe7f3dcf1 100644 --- a/libtextsecure/test/index.html +++ b/libtextsecure/test/index.html @@ -24,6 +24,7 @@ + @@ -31,10 +32,7 @@ - - - @@ -44,6 +42,7 @@ + diff --git a/libtextsecure/test/message_receiver_test.js b/libtextsecure/test/message_receiver_test.js index 73b606deb7..0ded6a0c28 100644 --- a/libtextsecure/test/message_receiver_test.js +++ b/libtextsecure/test/message_receiver_test.js @@ -1,10 +1,9 @@ // Copyright 2015-2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -/* global libsignal, textsecure, SignalProtocolStore */ +/* global libsignal, textsecure */ describe('MessageReceiver', () => { - textsecure.storage.impl = new SignalProtocolStore(); const { WebSocket } = window; const number = '+19999999999'; const uuid = 'AAAAAAAA-BBBB-4CCC-9DDD-EEEEEEEEEEEE'; @@ -12,6 +11,7 @@ describe('MessageReceiver', () => { const signalingKey = libsignal.crypto.getRandomBytes(32 + 20); before(() => { + localStorage.clear(); window.WebSocket = MockSocket; textsecure.storage.user.setNumberAndDeviceId(number, deviceId, 'name'); textsecure.storage.user.setUuidAndDeviceId(uuid, deviceId); @@ -19,94 +19,126 @@ describe('MessageReceiver', () => { textsecure.storage.put('signaling_key', signalingKey); }); after(() => { + localStorage.clear(); window.WebSocket = WebSocket; }); describe('connecting', () => { - const attrs = { - type: textsecure.protobuf.Envelope.Type.CIPHERTEXT, - source: number, - sourceUuid: uuid, - sourceDevice: deviceId, - timestamp: Date.now(), - }; - const websocketmessage = new textsecure.protobuf.WebSocketMessage({ - type: textsecure.protobuf.WebSocketMessage.Type.REQUEST, - request: { verb: 'PUT', path: '/messages' }, + let attrs; + let websocketmessage; + + before(() => { + attrs = { + type: textsecure.protobuf.Envelope.Type.CIPHERTEXT, + source: number, + sourceUuid: uuid, + sourceDevice: deviceId, + timestamp: Date.now(), + content: libsignal.crypto.getRandomBytes(200), + }; + const body = new textsecure.protobuf.Envelope(attrs).toArrayBuffer(); + + websocketmessage = new textsecure.protobuf.WebSocketMessage({ + type: textsecure.protobuf.WebSocketMessage.Type.REQUEST, + request: { verb: 'PUT', path: '/api/v1/message', body }, + }); }); - before(done => { - const signal = new textsecure.protobuf.Envelope(attrs).toArrayBuffer(); - - const aesKey = signalingKey.slice(0, 32); - const macKey = signalingKey.slice(32, 32 + 20); - - window.crypto.subtle - .importKey('raw', aesKey, { name: 'AES-CBC' }, false, ['encrypt']) - .then(key => { - const iv = libsignal.crypto.getRandomBytes(16); - window.crypto.subtle - .encrypt({ name: 'AES-CBC', iv: new Uint8Array(iv) }, key, signal) - .then(ciphertext => { - window.crypto.subtle - .importKey( - 'raw', - macKey, - { name: 'HMAC', hash: { name: 'SHA-256' } }, - false, - ['sign'] - ) - .then(innerKey => { - window.crypto.subtle - .sign({ name: 'HMAC', hash: 'SHA-256' }, innerKey, signal) - .then(mac => { - const version = new Uint8Array([1]); - const message = dcodeIO.ByteBuffer.concat([ - version, - iv, - ciphertext, - mac, - ]); - websocketmessage.request.body = message.toArrayBuffer(); - done(); - }); - }); - }); - }); - }); - - it('connects', done => { - const mockServer = new MockServer( - `ws://localhost:8080/v1/websocket/?login=${encodeURIComponent( - uuid - )}.1&password=password` - ); + it('generates light-session-reset event when it cannot decrypt', done => { + const mockServer = new MockServer('ws://localhost:8081/'); mockServer.on('connection', server => { - server.send(new Blob([websocketmessage.toArrayBuffer()])); + setTimeout(() => { + server.send(new Blob([websocketmessage.toArrayBuffer()])); + }, 1); }); - window.addEventListener('textsecure:message', ev => { - const signal = ev.proto; - const keys = Object.keys(attrs); - - for (let i = 0, max = keys.length; i < max; i += 1) { - const key = keys[i]; - assert.strictEqual(attrs[key], signal[key]); - } - assert.strictEqual(signal.message.body, 'hello'); - mockServer.close(); - - done(); - }); - - window.messageReceiver = new textsecure.MessageReceiver( + const messageReceiver = new textsecure.MessageReceiver( + 'oldUsername', 'username', 'password', - 'signalingKey' - // 'ws://localhost:8080', - // window, + 'signalingKey', + { + serverTrustRoot: 'AAAAAAAA', + } ); + + messageReceiver.addEventListener('light-session-reset', done()); + }); + }); + + describe('methods', () => { + let messageReceiver; + let mockServer; + + beforeEach(() => { + // Necessary to populate the server property inside of MockSocket. Without it, we + // crash when doing any number of things to a MockSocket instance. + mockServer = new MockServer('ws://localhost:8081'); + + messageReceiver = new textsecure.MessageReceiver( + 'oldUsername', + 'username', + 'password', + 'signalingKey', + { + serverTrustRoot: 'AAAAAAAA', + } + ); + }); + afterEach(() => { + mockServer.close(); + }); + + describe('#isOverHourIntoPast', () => { + it('returns false for now', () => { + assert.isFalse(messageReceiver.isOverHourIntoPast(Date.now())); + }); + it('returns false for 5 minutes ago', () => { + const fiveMinutesAgo = Date.now() - 5 * 60 * 1000; + assert.isFalse(messageReceiver.isOverHourIntoPast(fiveMinutesAgo)); + }); + it('returns true for 65 minutes ago', () => { + const sixtyFiveMinutesAgo = Date.now() - 65 * 60 * 1000; + assert.isTrue(messageReceiver.isOverHourIntoPast(sixtyFiveMinutesAgo)); + }); + }); + + describe('#cleanupSessionResets', () => { + it('leaves empty object alone', () => { + window.storage.put('sessionResets', {}); + messageReceiver.cleanupSessionResets(); + const actual = window.storage.get('sessionResets'); + + const expected = {}; + assert.deepEqual(actual, expected); + }); + it('filters out any timestamp older than one hour', () => { + const startValue = { + one: Date.now() - 1, + two: Date.now(), + three: Date.now() - 65 * 60 * 1000, + }; + window.storage.put('sessionResets', startValue); + messageReceiver.cleanupSessionResets(); + const actual = window.storage.get('sessionResets'); + + const expected = window._.pick(startValue, ['one', 'two']); + assert.deepEqual(actual, expected); + }); + it('filters out falsey items', () => { + const startValue = { + one: 0, + two: false, + three: Date.now(), + }; + window.storage.put('sessionResets', startValue); + messageReceiver.cleanupSessionResets(); + const actual = window.storage.get('sessionResets'); + + const expected = window._.pick(startValue, ['three']); + assert.deepEqual(actual, expected); + }); }); }); }); diff --git a/preload.js b/preload.js index 581f47e125..dadfe1d4f8 100644 --- a/preload.js +++ b/preload.js @@ -45,6 +45,7 @@ try { window.platform = process.platform; window.getTitle = () => title; + window.getLocale = () => config.locale; window.getEnvironment = getEnvironment; window.getAppInstance = () => config.appInstance; window.getVersion = () => config.version; diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index b0b0e733b9..97dc97c293 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -11453,6 +11453,88 @@ $contact-modal-padding: 18px; } } +// Module: Chat Session Refreshed Notification + +.module-chat-session-refreshed-notification { + @include font-body-2; + display: flex; + flex-direction: column; + align-items: center; +} + +.module-chat-session-refreshed-notification__first-line { + margin-bottom: 12px; + display: flex; + flex-direction: row; + align-items: center; + + margin-left: auto; + margin-right: auto; +} +.module-chat-session-refreshed-notification__icon { + height: 16px; + width: 16px; + display: inline-block; + margin-right: 8px; + + @include light-theme { + @include color-svg('../images/icons/v2/refresh-16.svg', $color-gray-60); + } + @include dark-theme { + @include color-svg('../images/icons/v2/refresh-16.svg', $color-gray-25); + } +} +.module-chat-session-refreshed-notification__button { + @include button-reset; + @include button-light-blue-text; + @include button-small; + + @include font-body-2; + padding: 5px 12px; +} + +// Module: Chat Session Refreshed Dialog + +.module-chat-session-refreshed-dialog { + width: 360px; + padding: 16px; + padding-top: 28px; + border-radius: 8px; + margin-left: auto; + margin-right: auto; + + @include light-theme { + background-color: $color-white; + } + @include dark-theme { + background-color: $color-gray-95; + } +} +.module-chat-session-refreshed-dialog__image { + text-align: center; +} +.module-chat-session-refreshed-dialog__title { + @include font-body-1-bold; + margin-top: 10px; + margin-bottom: 3px; +} +.module-chat-session-refreshed-dialog__buttons { + text-align: right; + margin-top: 20px; +} +.module-chat-session-refreshed-dialog__button { + @include font-body-1-bold; + @include button-reset; + @include button-primary; + + border-radius: 4px; + padding: 7px 14px; + margin-left: 12px; +} +.module-chat-session-refreshed-dialog__button--secondary { + @include button-secondary; +} + /* Third-party module: react-contextmenu*/ .react-contextmenu { diff --git a/test/index.html b/test/index.html index a95268288a..540786f111 100644 --- a/test/index.html +++ b/test/index.html @@ -337,15 +337,12 @@ - - - @@ -353,20 +350,15 @@ - - - - - diff --git a/ts/background.ts b/ts/background.ts index 1469ad78d3..5fd76106ce 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -1937,6 +1937,7 @@ type WhatIsThis = import('./window.d').WhatIsThis; addQueuedEventListener('read', onReadReceipt); addQueuedEventListener('verified', onVerified); addQueuedEventListener('error', onError); + addQueuedEventListener('light-session-reset', onLightSessionReset); addQueuedEventListener('empty', onEmpty); addQueuedEventListener('reconnect', onReconnect); addQueuedEventListener('configuration', onConfiguration); @@ -3121,7 +3122,8 @@ type WhatIsThis = import('./window.d').WhatIsThis; error.name === 'HTTPError' && (error.code === 401 || error.code === 403) ) { - return unlinkAndDisconnect(); + unlinkAndDisconnect(); + return; } if ( @@ -3136,101 +3138,40 @@ type WhatIsThis = import('./window.d').WhatIsThis; window.Whisper.events.trigger('reconnectTimer'); } - return Promise.resolve(); + return; } - if (ev.proto) { - if (error && error.name === 'MessageCounterError') { - if (ev.confirm) { - ev.confirm(); - } - // Ignore this message. It is likely a duplicate delivery - // because the server lost our ack the first time. - return Promise.resolve(); - } - const envelope = ev.proto; - const id = window.ConversationController.ensureContactIds({ - e164: envelope.source, - uuid: envelope.sourceUuid, - }); - if (!id) { - throw new Error('onError: ensureContactIds returned falsey id!'); - } - const message = initIncomingMessage(envelope, { - type: Message.PRIVATE, - id, - }); + window.log.warn('background onError: Doing nothing with incoming error'); + } - const conversationId = message.get('conversationId'); - const conversation = window.ConversationController.get(conversationId); + type LightSessionResetEventType = { + senderUuid: string; + }; - if (!conversation) { - window.log.warn( - 'onError: No conversation id, cannot save error bubble' - ); - ev.confirm(); - return Promise.resolve(); - } + function onLightSessionReset(event: LightSessionResetEventType) { + const conversationId = window.ConversationController.ensureContactIds({ + uuid: event.senderUuid, + }); - // This matches the queueing behavior used in Message.handleDataMessage - conversation.queueJob(async () => { - const existingMessage = await window.Signal.Data.getMessageBySender( - message.attributes, - { - Message: window.Whisper.Message, - } - ); - if (existingMessage) { - ev.confirm(); - window.log.warn( - `Got duplicate error for message ${message.idForLogging()}` - ); - return; - } + if (!conversationId) { + window.log.warn( + 'onLightSessionReset: No conversation id, cannot add message to timeline' + ); + return; + } + const conversation = window.ConversationController.get(conversationId); - const model = new window.Whisper.Message({ - ...message.attributes, - id: window.getGuid(), - }); - await model.saveErrors(error || new Error('Error was null'), { - skipSave: true, - }); - - window.MessageController.register(model.id, model); - await window.Signal.Data.saveMessage(model.attributes, { - Message: window.Whisper.Message, - forceSave: true, - }); - - conversation.set({ - active_at: Date.now(), - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - unreadCount: conversation.get('unreadCount')! + 1, - }); - - const conversationTimestamp = conversation.get('timestamp'); - const messageTimestamp = model.get('timestamp'); - if ( - !conversationTimestamp || - messageTimestamp > conversationTimestamp - ) { - conversation.set({ timestamp: model.get('sent_at') }); - } - - conversation.trigger('newmessage', model); - conversation.notify(model); - - window.Whisper.events.trigger('incrementProgress'); - - if (ev.confirm) { - ev.confirm(); - } - - window.Signal.Data.updateConversation(conversation.attributes); - }); + if (!conversation) { + window.log.warn( + 'onLightSessionReset: No conversation, cannot add message to timeline' + ); + return; } - throw error; + const receivedAt = Date.now(); + conversation.queueJob(async () => { + conversation.addChatSessionRefreshed(receivedAt); + }); } async function onViewSync(ev: WhatIsThis) { diff --git a/ts/components/conversation/ChatSessionRefreshedDialog.stories.tsx b/ts/components/conversation/ChatSessionRefreshedDialog.stories.tsx new file mode 100644 index 0000000000..b08bad13b8 --- /dev/null +++ b/ts/components/conversation/ChatSessionRefreshedDialog.stories.tsx @@ -0,0 +1,25 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; + +import { setup as setupI18n } from '../../../js/modules/i18n'; +import enMessages from '../../../_locales/en/messages.json'; +import { ChatSessionRefreshedDialog } from './ChatSessionRefreshedDialog'; + +const i18n = setupI18n('en', enMessages); + +storiesOf('Components/Conversation/ChatSessionRefreshedDialog', module).add( + 'Default', + () => { + return ( + + ); + } +); diff --git a/ts/components/conversation/ChatSessionRefreshedDialog.tsx b/ts/components/conversation/ChatSessionRefreshedDialog.tsx new file mode 100644 index 0000000000..cb73ab4cdf --- /dev/null +++ b/ts/components/conversation/ChatSessionRefreshedDialog.tsx @@ -0,0 +1,57 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; +import classNames from 'classnames'; + +import { LocalizerType } from '../../types/Util'; + +export type PropsType = { + i18n: LocalizerType; + contactSupport: () => unknown; + onClose: () => unknown; +}; + +export function ChatSessionRefreshedDialog( + props: PropsType +): React.ReactElement { + const { i18n, contactSupport, onClose } = props; + + return ( +
+
+ +
+
+ {i18n('ChatRefresh--notification')} +
+
+ {i18n('ChatRefresh--summary')} +
+
+ + +
+
+ ); +} diff --git a/ts/components/conversation/ChatSessionRefreshedNotification.stories.tsx b/ts/components/conversation/ChatSessionRefreshedNotification.stories.tsx new file mode 100644 index 0000000000..80c14cb5eb --- /dev/null +++ b/ts/components/conversation/ChatSessionRefreshedNotification.stories.tsx @@ -0,0 +1,24 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; + +import { setup as setupI18n } from '../../../js/modules/i18n'; +import enMessages from '../../../_locales/en/messages.json'; +import { ChatSessionRefreshedNotification } from './ChatSessionRefreshedNotification'; + +const i18n = setupI18n('en', enMessages); + +storiesOf( + 'Components/Conversation/ChatSessionRefreshedNotification', + module +).add('Default', () => { + return ( + + ); +}); diff --git a/ts/components/conversation/ChatSessionRefreshedNotification.tsx b/ts/components/conversation/ChatSessionRefreshedNotification.tsx new file mode 100644 index 0000000000..cf5e316744 --- /dev/null +++ b/ts/components/conversation/ChatSessionRefreshedNotification.tsx @@ -0,0 +1,63 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { useCallback, useState, ReactElement } from 'react'; + +import { LocalizerType } from '../../types/Util'; + +import { ModalHost } from '../ModalHost'; +import { ChatSessionRefreshedDialog } from './ChatSessionRefreshedDialog'; + +type PropsHousekeepingType = { + i18n: LocalizerType; +}; + +export type PropsActionsType = { + contactSupport: () => unknown; +}; + +export type PropsType = PropsHousekeepingType & PropsActionsType; + +export function ChatSessionRefreshedNotification( + props: PropsType +): ReactElement { + const { contactSupport, i18n } = props; + const [isDialogOpen, setIsDialogOpen] = useState(false); + + const openDialog = useCallback(() => { + setIsDialogOpen(true); + }, [setIsDialogOpen]); + const closeDialog = useCallback(() => { + setIsDialogOpen(false); + }, [setIsDialogOpen]); + + const wrappedContactSupport = useCallback(() => { + setIsDialogOpen(false); + contactSupport(); + }, [contactSupport, setIsDialogOpen]); + + return ( +
+
+ + {i18n('ChatRefresh--notification')} +
+ + {isDialogOpen ? ( + + + + ) : null} +
+ ); +} diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index b62f4707f5..f0eb3fa827 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -252,6 +252,8 @@ const actions = () => ({ messageSizeChanged: action('messageSizeChanged'), startCallingLobby: action('startCallingLobby'), returnToActiveCall: action('returnToActiveCall'), + + contactSupport: action('contactSupport'), }); const renderItem = (id: string) => ( diff --git a/ts/components/conversation/TimelineItem.stories.tsx b/ts/components/conversation/TimelineItem.stories.tsx index 03b2fe5aa7..d9800da507 100644 --- a/ts/components/conversation/TimelineItem.stories.tsx +++ b/ts/components/conversation/TimelineItem.stories.tsx @@ -41,6 +41,7 @@ const getDefaultProps = () => ({ selectMessage: action('selectMessage'), reactToMessage: action('reactToMessage'), clearSelectedMessage: action('clearSelectedMessage'), + contactSupport: action('contactSupport'), replyToMessage: action('replyToMessage'), retrySend: action('retrySend'), deleteMessage: action('deleteMessage'), diff --git a/ts/components/conversation/TimelineItem.tsx b/ts/components/conversation/TimelineItem.tsx index 409914b0a6..ec6959d3e4 100644 --- a/ts/components/conversation/TimelineItem.tsx +++ b/ts/components/conversation/TimelineItem.tsx @@ -10,11 +10,14 @@ import { PropsActions as MessageActionsType, PropsData as MessageProps, } from './Message'; - import { CallingNotification, PropsActionsType as CallingNotificationActionsType, } from './CallingNotification'; +import { + ChatSessionRefreshedNotification, + PropsActionsType as PropsChatSessionRefreshedActionsType, +} from './ChatSessionRefreshedNotification'; import { CallingNotificationType } from '../../util/callingNotification'; import { InlineNotificationWrapper } from './InlineNotificationWrapper'; import { @@ -58,6 +61,10 @@ type CallHistoryType = { type: 'callHistory'; data: CallingNotificationType; }; +type ChatSessionRefreshedType = { + type: 'chatSessionRefreshed'; + data: null; +}; type LinkNotificationType = { type: 'linkNotification'; data: null; @@ -105,6 +112,7 @@ type ProfileChangeNotificationType = { export type TimelineItemType = | CallHistoryType + | ChatSessionRefreshedType | GroupNotificationType | GroupV1MigrationType | GroupV2ChangeType @@ -131,6 +139,7 @@ type PropsLocalType = { type PropsActionsType = MessageActionsType & CallingNotificationActionsType & + PropsChatSessionRefreshedActionsType & UnsupportedMessageActionsType & SafetyNumberActionsType; @@ -184,6 +193,14 @@ export class TimelineItem extends React.PureComponent { {...item.data} /> ); + } else if (item.type === 'chatSessionRefreshed') { + notification = ( + + ); } else if (item.type === 'linkNotification') { notification = (
diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index d24da65d72..129ec242c5 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -2277,6 +2277,35 @@ export class ConversationModel extends window.Backbone.Model< return this.setVerified(); } + async addChatSessionRefreshed(receivedAt: number): Promise { + window.log.info( + `addChatSessionRefreshed: adding for ${this.idForLogging()}` + ); + + const message = ({ + conversationId: this.id, + type: 'chat-session-refreshed', + sent_at: receivedAt, + received_at: receivedAt, + unread: 1, + // TODO: DESKTOP-722 + // this type does not fully implement the interface it is expected to + } as unknown) as typeof window.Whisper.MessageAttributesType; + + const id = await window.Signal.Data.saveMessage(message, { + Message: window.Whisper.Message, + }); + const model = window.MessageController.register( + id, + new window.Whisper.Message({ + ...message, + id, + }) + ); + + this.trigger('newmessage', model); + } + async addKeyChange(keyChangedId: string): Promise { window.log.info( 'adding key change advisory for', diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 9c54d33fdb..8f340a1f41 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -201,6 +201,7 @@ export class MessageModel extends window.Backbone.Model { isNormalBubble(): boolean { return ( !this.isCallHistory() && + !this.isChatSessionRefreshed() && !this.isEndSession() && !this.isExpirationTimerUpdate() && !this.isGroupUpdate() && @@ -282,6 +283,12 @@ export class MessageModel extends window.Backbone.Model { data: this.getPropsForProfileChange(), }; } + if (this.isChatSessionRefreshed()) { + return { + type: 'chatSessionRefreshed', + data: null, + }; + } return { type: 'message', @@ -461,6 +468,10 @@ export class MessageModel extends window.Backbone.Model { return this.get('type') === 'call-history'; } + isChatSessionRefreshed(): boolean { + return this.get('type') === 'chat-session-refreshed'; + } + isProfileChange(): boolean { return this.get('type') === 'profile-change'; } @@ -1178,6 +1189,13 @@ export class MessageModel extends window.Backbone.Model { } getNotificationData(): { emoji?: string; text: string } { + if (this.isChatSessionRefreshed()) { + return { + emoji: '🔁', + text: window.i18n('ChatRefresh--notification'), + }; + } + if (this.isUnsupportedMessage()) { return { text: window.i18n('message--getDescription--unsupported-message'), @@ -1695,6 +1713,7 @@ export class MessageModel extends window.Backbone.Model { // Rendered sync messages const isCallHistory = this.isCallHistory(); + const isChatSessionRefreshed = this.isChatSessionRefreshed(); const isGroupUpdate = this.isGroupUpdate(); const isGroupV2Change = this.isGroupV2Change(); const isEndSession = this.isEndSession(); @@ -1723,6 +1742,7 @@ export class MessageModel extends window.Backbone.Model { isSticker || // Rendered sync messages isCallHistory || + isChatSessionRefreshed || isGroupUpdate || isGroupV2Change || isEndSession || diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index 52e9a78929..1eeea3a05e 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -15,6 +15,7 @@ import { v4 as getGuid } from 'uuid'; import { SessionCipherClass, SignalProtocolAddressClass } from '../libsignal.d'; import { BatcherType, createBatcher } from '../util/batcher'; +import { assert } from '../util/assert'; import EventTarget from './EventTarget'; import { WebAPIType } from './WebAPI'; @@ -48,6 +49,8 @@ const GROUPV1_ID_LENGTH = 16; const GROUPV2_ID_LENGTH = 32; const RETRY_TIMEOUT = 2 * 60 * 1000; +type SessionResetsType = Record; + declare global { // We want to extend `Event`, so we need an interface. // eslint-disable-next-line no-restricted-syntax @@ -208,6 +211,8 @@ class MessageReceiverInner extends EventTarget { maxSize: 30, processBatch: this.cacheRemoveBatch.bind(this), }); + + this.cleanupSessionResets(); } static stringToArrayBuffer = (string: string): ArrayBuffer => @@ -237,6 +242,7 @@ class MessageReceiverInner extends EventTarget { } this.isEmptied = false; + this.hasConnected = true; if (this.socket && this.socket.readyState !== WebSocket.CLOSED) { @@ -1089,34 +1095,120 @@ class MessageReceiverInner extends EventTarget { return plaintext; }) .catch(async error => { - let errorToThrow = error; + this.removeFromCache(envelope); - if (error && error.message === 'Unknown identity key') { - // create an error that the UI will pick up and ask the - // user if they want to re-negotiate - const buffer = window.dcodeIO.ByteBuffer.wrap(ciphertext); - errorToThrow = new IncomingIdentityKeyError( - address.toString(), - buffer.toArrayBuffer(), - error.identityKey + const uuid = envelope.sourceUuid; + const deviceId = envelope.sourceDevice; + + // We don't do a light session reset if it's just a duplicated message + if (error && error.name === 'MessageCounterError') { + throw error; + } + + if (uuid && deviceId) { + await this.lightSessionReset(uuid, deviceId); + } else { + const envelopeId = this.getEnvelopeId(envelope); + window.log.error( + `MessageReceiver.decrypt: Envelope ${envelopeId} missing uuid or deviceId` ); } - if (envelope.timestamp && envelope.timestamp.toNumber) { - // eslint-disable-next-line no-param-reassign - envelope.timestamp = envelope.timestamp.toNumber(); - } - - const ev = new Event('error'); - ev.error = errorToThrow; - ev.proto = envelope; - ev.confirm = this.removeFromCache.bind(this, envelope); - - const returnError = async () => Promise.reject(errorToThrow); - return this.dispatchAndWait(ev).then(returnError, returnError); + throw error; }); } + isOverHourIntoPast(timestamp: number): boolean { + const HOUR = 1000 * 60 * 60; + const now = Date.now(); + const oneHourIntoPast = now - HOUR; + + return isNumber(timestamp) && timestamp <= oneHourIntoPast; + } + + // We don't lose anything if we delete keys over an hour into the past, because we only + // change our behavior if the timestamps stored are less than an hour ago. + cleanupSessionResets(): void { + const sessionResets = window.storage.get( + 'sessionResets', + {} + ) as SessionResetsType; + + const keys = Object.keys(sessionResets); + keys.forEach(key => { + const timestamp = sessionResets[key]; + if (!timestamp || this.isOverHourIntoPast(timestamp)) { + delete sessionResets[key]; + } + }); + + window.storage.put('sessionResets', sessionResets); + } + + async lightSessionReset(uuid: string, deviceId: number) { + const id = `${uuid}.${deviceId}`; + + try { + const sessionResets = window.storage.get( + 'sessionResets', + {} + ) as SessionResetsType; + const lastReset = sessionResets[id]; + + if (lastReset && !this.isOverHourIntoPast(lastReset)) { + window.log.warn( + `lightSessionReset: Skipping session reset for ${id}, last reset at ${lastReset}` + ); + return; + } + sessionResets[id] = Date.now(); + window.storage.put('sessionResets', sessionResets); + + // First, fetch this conversation + const conversationId = window.ConversationController.ensureContactIds({ + uuid, + }); + assert(conversationId, 'lightSessionReset: missing conversationId'); + + const conversation = window.ConversationController.get(conversationId); + assert(conversation, 'lightSessionReset: missing conversation'); + + window.log.warn(`lightSessionReset: Resetting session for ${id}`); + + // Archive open session with this device + const address = new window.libsignal.SignalProtocolAddress( + uuid, + deviceId + ); + const sessionCipher = new window.libsignal.SessionCipher( + window.textsecure.storage.protocol, + address + ); + + await sessionCipher.closeOpenSessionForDevice(); + + // Send a null message with newly-created session + const sendOptions = conversation.getSendOptions(); + await window.textsecure.messaging.sendNullMessage({ uuid }, sendOptions); + + // Emit event for app to put item into conversation timeline + const event = new Event('light-session-reset'); + event.senderUuid = uuid; + await this.dispatchAndWait(event); + } catch (error) { + // If we failed to do the session reset, then we'll allow another attempt + const sessionResets = window.storage.get( + 'sessionResets', + {} + ) as SessionResetsType; + delete sessionResets[id]; + window.storage.put('sessionResets', sessionResets); + + const errorString = error && error.stack ? error.stack : error; + window.log.error('lightSessionReset: Enountered error', errorString); + } + } + async decryptPreKeyWhisperMessage( ciphertext: ArrayBuffer, sessionCipher: SessionCipherClass, @@ -2266,6 +2358,10 @@ export default class MessageReceiver { this.stopProcessing = inner.stopProcessing.bind(inner); this.unregisterBatchers = inner.unregisterBatchers.bind(inner); + // For tests + this.isOverHourIntoPast = inner.isOverHourIntoPast.bind(inner); + this.cleanupSessionResets = inner.cleanupSessionResets.bind(inner); + inner.connect(); } @@ -2287,6 +2383,10 @@ export default class MessageReceiver { unregisterBatchers: () => void; + isOverHourIntoPast: (timestamp: number) => boolean; + + cleanupSessionResets: () => void; + static stringToArrayBuffer = MessageReceiverInner.stringToArrayBuffer; static arrayBufferToString = MessageReceiverInner.arrayBufferToString; diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index cb276ff45d..9b5c7e48da 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -742,12 +742,7 @@ export default class MessageSender { createSyncMessage(): SyncMessageClass { const syncMessage = new window.textsecure.protobuf.SyncMessage(); - // Generate a random int from 1 and 512 - const buffer = window.libsignal.crypto.getRandomBytes(1); - const paddingLength = (new Uint8Array(buffer)[0] & 0x1ff) + 1; - - // Generate a random padding buffer of the chosen size - syncMessage.padding = window.libsignal.crypto.getRandomBytes(paddingLength); + syncMessage.padding = this.getRandomPadding(); return syncMessage; } @@ -1374,6 +1369,47 @@ export default class MessageSender { ); } + getRandomPadding(): ArrayBuffer { + // Generate a random int from 1 and 512 + const buffer = window.libsignal.crypto.getRandomBytes(2); + const paddingLength = (new Uint16Array(buffer)[0] & 0x1ff) + 1; + + // Generate a random padding buffer of the chosen size + return window.libsignal.crypto.getRandomBytes(paddingLength); + } + + async sendNullMessage( + { + uuid, + e164, + padding, + }: { uuid?: string; e164?: string; padding?: ArrayBuffer }, + options?: SendOptionsType + ): Promise { + const nullMessage = new window.textsecure.protobuf.NullMessage(); + + const identifier = uuid || e164; + if (!identifier) { + throw new Error('sendNullMessage: Got neither uuid nor e164!'); + } + + nullMessage.padding = padding || this.getRandomPadding(); + + const contentMessage = new window.textsecure.protobuf.Content(); + contentMessage.nullMessage = nullMessage; + + // We want the NullMessage to look like a normal outgoing message; not silent + const silent = false; + const timestamp = Date.now(); + return this.sendIndividualProto( + identifier, + contentMessage, + timestamp, + silent, + options + ); + } + async syncVerification( destinationE164: string, destinationUuid: string, @@ -1390,26 +1426,12 @@ export default class MessageSender { return Promise.resolve(); } + // Get padding which we can share between null message and verified sync + const padding = this.getRandomPadding(); + // First send a null message to mask the sync message. - const nullMessage = new window.textsecure.protobuf.NullMessage(); - - // Generate a random int from 1 and 512 - const buffer = window.libsignal.crypto.getRandomBytes(1); - const paddingLength = (new Uint8Array(buffer)[0] & 0x1ff) + 1; - - // Generate a random padding buffer of the chosen size - nullMessage.padding = window.libsignal.crypto.getRandomBytes(paddingLength); - - const contentMessage = new window.textsecure.protobuf.Content(); - contentMessage.nullMessage = nullMessage; - - // We want the NullMessage to look like a normal outgoing message; not silent - const silent = false; - const promise = this.sendIndividualProto( - destinationUuid || destinationE164, - contentMessage, - now, - silent, + const promise = this.sendNullMessage( + { uuid: destinationUuid, e164: destinationE164, padding }, options ); @@ -1423,7 +1445,7 @@ export default class MessageSender { verified.destinationUuid = destinationUuid; } verified.identityKey = identityKey; - verified.nullMessage = nullMessage.padding; + verified.nullMessage = padding; const syncMessage = this.createSyncMessage(); syncMessage.verified = verified; diff --git a/ts/util/index.ts b/ts/util/index.ts index 380327b749..b8c53db379 100644 --- a/ts/util/index.ts +++ b/ts/util/index.ts @@ -24,6 +24,7 @@ import { parseRemoteClientExpiration } from './parseRemoteClientExpiration'; import { sleep } from './sleep'; import { longRunningTaskWrapper } from './longRunningTaskWrapper'; import { toWebSafeBase64, fromWebSafeBase64 } from './webSafeBase64'; +import { mapToSupportLocale } from './mapToSupportLocale'; import * as zkgroup from './zkgroup'; export { @@ -44,6 +45,7 @@ export { isFileDangerous, longRunningTaskWrapper, makeLookup, + mapToSupportLocale, missingCaseError, parseRemoteClientExpiration, Registration, diff --git a/ts/util/mapToSupportLocale.ts b/ts/util/mapToSupportLocale.ts new file mode 100644 index 0000000000..5a00a7d267 --- /dev/null +++ b/ts/util/mapToSupportLocale.ts @@ -0,0 +1,115 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export type SupportLocaleType = + | 'ar' + | 'de' + | 'en-us' + | 'es' + | 'fr' + | 'it' + | 'ja' + | 'pl' + | 'pt-br' + | 'ru' + | 'sq' + | 'zh-tw'; + +export type ElectronLocaleType = + | 'af' + | 'ar' + | 'bg' + | 'bn' + | 'ca' + | 'cs' + | 'cy' + | 'da' + | 'de' + | 'el' + | 'en' + | 'eo' + | 'es' + | 'es_419' + | 'et' + | 'eu' + | 'fa' + | 'fi' + | 'fr' + | 'he' + | 'hi' + | 'hr' + | 'hu' + | 'id' + | 'it' + | 'ja' + | 'km' + | 'kn' + | 'ko' + | 'lt' + | 'mk' + | 'mr' + | 'ms' + | 'nb' + | 'nl' + | 'nn' + | 'no' + | 'pl' + | 'pt_BR' + | 'pt_PT' + | 'ro' + | 'ru' + | 'sk' + | 'sl' + | 'sq' + | 'sr' + | 'sv' + | 'sw' + | 'ta' + | 'te' + | 'th' + | 'tr' + | 'uk' + | 'ur' + | 'vi' + | 'zh_CN' + | 'zh_TW'; + +export function mapToSupportLocale( + ourLocale: ElectronLocaleType +): SupportLocaleType { + if (ourLocale === 'ar') { + return ourLocale; + } + if (ourLocale === 'de') { + return ourLocale; + } + if (ourLocale === 'es') { + return ourLocale; + } + if (ourLocale === 'fr') { + return ourLocale; + } + if (ourLocale === 'it') { + return ourLocale; + } + if (ourLocale === 'ja') { + return ourLocale; + } + if (ourLocale === 'pl') { + return ourLocale; + } + if (ourLocale === 'pt_BR') { + return 'pt-br'; + } + if (ourLocale === 'ru') { + return ourLocale; + } + if (ourLocale === 'sq') { + return ourLocale; + } + if (ourLocale === 'zh_TW') { + return 'zh-tw'; + } + + return 'en-us'; +} diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index 1b2877a8ce..046b5eeac9 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -774,6 +774,15 @@ Whisper.ConversationView = Whisper.View.extend({ const showExpiredOutgoingTapToViewToast = () => { this.showToast(Whisper.TapToViewExpiredOutgoingToast); }; + const contactSupport = () => { + const baseUrl = + 'https://support.signal.org/hc/LOCALE/requests/new?desktop&chat_refreshed'; + const locale = window.getLocale(); + const supportLocale = window.Signal.Util.mapToSupportLocale(locale); + const url = baseUrl.replace('LOCALE', supportLocale); + + this.navigateTo(url); + }; const scrollToQuotedMessage = async (options: any) => { const { authorId, sentAt } = options; @@ -928,6 +937,7 @@ Whisper.ConversationView = Whisper.View.extend({ JSX: window.Signal.State.Roots.createTimeline(window.reduxStore, { id, + contactSupport, deleteMessage, deleteMessageForEveryone, displayTapToViewMessage, diff --git a/ts/window.d.ts b/ts/window.d.ts index 36993f7728..414fdbea51 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -92,6 +92,7 @@ import { ProgressModal } from './components/ProgressModal'; import { Quote } from './components/conversation/Quote'; import { StagedLinkPreview } from './components/conversation/StagedLinkPreview'; import { MIMEType } from './types/MIME'; +import { ElectronLocaleType } from './util/mapToSupportLocale'; export { Long } from 'long'; @@ -148,6 +149,7 @@ declare global { getInboxCollection: () => ConversationModelCollectionType; getIncomingCallNotification: () => Promise; getInteractionMode: () => 'mouse' | 'keyboard'; + getLocale: () => ElectronLocaleType; getMediaCameraPermissions: () => Promise; getMediaPermissions: () => Promise; getNodeVersion: () => string; From 79ddd48c28f31a6d8fbdfcc9216cd76caca56c0f Mon Sep 17 00:00:00 2001 From: Jim Gustafson Date: Thu, 18 Feb 2021 10:38:09 -0800 Subject: [PATCH 009/181] Update to RingRTC v2.9.3 --- package.json | 2 +- yarn.lock | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index ebb862707b..6c6fa61f80 100644 --- a/package.json +++ b/package.json @@ -134,7 +134,7 @@ "redux-ts-utils": "3.2.2", "reselect": "4.0.0", "rimraf": "2.6.2", - "ringrtc": "https://github.com/signalapp/signal-ringrtc-node.git#552582f97102be4baf27a81fdaab1fba46fc6595", + "ringrtc": "https://github.com/signalapp/signal-ringrtc-node.git#ecc8dbdfe9b99030de5f0687db8afcc798771a8e", "sanitize-filename": "1.6.3", "sanitize.css": "11.0.0", "semver": "5.4.1", diff --git a/yarn.lock b/yarn.lock index f21c0b6304..ecfb1419d6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14368,9 +14368,9 @@ rimraf@~2.4.0: dependencies: glob "^6.0.1" -"ringrtc@https://github.com/signalapp/signal-ringrtc-node.git#552582f97102be4baf27a81fdaab1fba46fc6595": - version "2.9.2" - resolved "https://github.com/signalapp/signal-ringrtc-node.git#552582f97102be4baf27a81fdaab1fba46fc6595" +"ringrtc@https://github.com/signalapp/signal-ringrtc-node.git#ecc8dbdfe9b99030de5f0687db8afcc798771a8e": + version "2.9.3" + resolved "https://github.com/signalapp/signal-ringrtc-node.git#ecc8dbdfe9b99030de5f0687db8afcc798771a8e" ripemd160@^2.0.0, ripemd160@^2.0.1: version "2.0.1" From 733c86ee8e00522cdb26b79d700635883fed24ab Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Fri, 19 Feb 2021 10:40:41 -0800 Subject: [PATCH 010/181] GroupV2: Log from/to revision when applying full group state --- ts/groups.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ts/groups.ts b/ts/groups.ts index c637d67972..d5c87caf4d 100644 --- a/ts/groups.ts +++ b/ts/groups.ts @@ -1,4 +1,4 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { @@ -2905,6 +2905,11 @@ async function getCurrentGroupState({ logId ); + const oldVersion = group.version; + const newVersion = decryptedGroupState.version; + window.log.info( + `getCurrentGroupState/${logId}: Applying full group state, from version ${oldVersion} to ${newVersion}.` + ); const newAttributes = await applyGroupState({ group, groupState: decryptedGroupState, From 1ee47735d9febd1d798db0f980f8f7fb90f1a524 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Fri, 19 Feb 2021 12:46:48 -0800 Subject: [PATCH 011/181] Add 'chat session refreshed' to timeline for every error --- ts/textsecure/MessageReceiver.ts | 82 ++++++++++++++++++-------------- 1 file changed, 46 insertions(+), 36 deletions(-) diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index 1eeea3a05e..4cb5593155 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -1106,7 +1106,7 @@ class MessageReceiverInner extends EventTarget { } if (uuid && deviceId) { - await this.lightSessionReset(uuid, deviceId); + await this.maybeLightSessionReset(uuid, deviceId); } else { const envelopeId = this.getEnvelopeId(envelope); window.log.error( @@ -1145,7 +1145,7 @@ class MessageReceiverInner extends EventTarget { window.storage.put('sessionResets', sessionResets); } - async lightSessionReset(uuid: string, deviceId: number) { + async maybeLightSessionReset(uuid: string, deviceId: number): Promise { const id = `${uuid}.${deviceId}`; try { @@ -1155,48 +1155,27 @@ class MessageReceiverInner extends EventTarget { ) as SessionResetsType; const lastReset = sessionResets[id]; + // We emit this event every time we encounter an error, not just when we reset the + // session. This is because a message might have been lost with every decryption + // failure. + const event = new Event('light-session-reset'); + event.senderUuid = uuid; + this.dispatchAndWait(event); + if (lastReset && !this.isOverHourIntoPast(lastReset)) { window.log.warn( - `lightSessionReset: Skipping session reset for ${id}, last reset at ${lastReset}` + `maybeLightSessionReset/${id}: Skipping session reset, last reset at ${lastReset}` ); return; } + sessionResets[id] = Date.now(); window.storage.put('sessionResets', sessionResets); - // First, fetch this conversation - const conversationId = window.ConversationController.ensureContactIds({ - uuid, - }); - assert(conversationId, 'lightSessionReset: missing conversationId'); - - const conversation = window.ConversationController.get(conversationId); - assert(conversation, 'lightSessionReset: missing conversation'); - - window.log.warn(`lightSessionReset: Resetting session for ${id}`); - - // Archive open session with this device - const address = new window.libsignal.SignalProtocolAddress( - uuid, - deviceId - ); - const sessionCipher = new window.libsignal.SessionCipher( - window.textsecure.storage.protocol, - address - ); - - await sessionCipher.closeOpenSessionForDevice(); - - // Send a null message with newly-created session - const sendOptions = conversation.getSendOptions(); - await window.textsecure.messaging.sendNullMessage({ uuid }, sendOptions); - - // Emit event for app to put item into conversation timeline - const event = new Event('light-session-reset'); - event.senderUuid = uuid; - await this.dispatchAndWait(event); + await this.lightSessionReset(uuid, deviceId); } catch (error) { - // If we failed to do the session reset, then we'll allow another attempt + // If we failed to do the session reset, then we'll allow another attempt sooner + // than one hour from now. const sessionResets = window.storage.get( 'sessionResets', {} @@ -1205,10 +1184,41 @@ class MessageReceiverInner extends EventTarget { window.storage.put('sessionResets', sessionResets); const errorString = error && error.stack ? error.stack : error; - window.log.error('lightSessionReset: Enountered error', errorString); + window.log.error( + `maybeLightSessionReset/${id}: Enountered error`, + errorString + ); } } + async lightSessionReset(uuid: string, deviceId: number): Promise { + const id = `${uuid}.${deviceId}`; + + // First, fetch this conversation + const conversationId = window.ConversationController.ensureContactIds({ + uuid, + }); + assert(conversationId, `lightSessionReset/${id}: missing conversationId`); + + const conversation = window.ConversationController.get(conversationId); + assert(conversation, `lightSessionReset/${id}: missing conversation`); + + window.log.warn(`lightSessionReset/${id}: Resetting session`); + + // Archive open session with this device + const address = new window.libsignal.SignalProtocolAddress(uuid, deviceId); + const sessionCipher = new window.libsignal.SessionCipher( + window.textsecure.storage.protocol, + address + ); + + await sessionCipher.closeOpenSessionForDevice(); + + // Send a null message with newly-created session + const sendOptions = conversation.getSendOptions(); + await window.textsecure.messaging.sendNullMessage({ uuid }, sendOptions); + } + async decryptPreKeyWhisperMessage( ciphertext: ArrayBuffer, sessionCipher: SessionCipherClass, From c4551ca7cac19d1a69ed13d741afa1f4c1d29e89 Mon Sep 17 00:00:00 2001 From: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com> Date: Tue, 23 Feb 2021 10:00:18 -0600 Subject: [PATCH 012/181] Revoke object URLs in two error cases --- js/views/identicon_svg_view.js | 9 +++++++-- ts/views/conversation_view.ts | 11 ++++++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/js/views/identicon_svg_view.js b/js/views/identicon_svg_view.js index 60b51999dd..9933ca8acb 100644 --- a/js/views/identicon_svg_view.js +++ b/js/views/identicon_svg_view.js @@ -1,4 +1,4 @@ -// Copyright 2015-2020 Signal Messenger, LLC +// Copyright 2015-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only /* global Whisper, loadImage */ @@ -21,7 +21,7 @@ const svg = new Blob([html], { type: 'image/svg+xml;charset=utf-8' }); return URL.createObjectURL(svg); }, - getDataUrl() { + getDataUrl() /* : Promise */ { const svgurl = this.getSVGUrl(); return new Promise(resolve => { const img = document.createElement('img'); @@ -36,6 +36,11 @@ URL.revokeObjectURL(svgurl); resolve(canvas.toDataURL('image/png')); }; + img.onerror = () => { + URL.revokeObjectURL(svgurl); + // If this fails for some reason, we'd rather continue on than reject. + resolve(undefined); + }; img.src = svgurl; }); diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index 046b5eeac9..b7466568cc 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -1805,7 +1805,6 @@ Whisper.ConversationView = Whisper.View.extend({ return new Promise((resolve, reject) => { const url = URL.createObjectURL(file); const img = document.createElement('img'); - img.onerror = reject; img.onload = () => { URL.revokeObjectURL(url); @@ -1863,6 +1862,16 @@ Whisper.ConversationView = Whisper.View.extend({ file: blob, }); }; + img.onerror = ( + _event: unknown, + _source: unknown, + _lineno: unknown, + _colno: unknown, + error: Error = new Error('Failed to load image for auto-scaling') + ) => { + URL.revokeObjectURL(url); + reject(error); + }; img.src = url; }); }, From 8ee3bd968767e5802b378dd03e22d2192e33f95b Mon Sep 17 00:00:00 2001 From: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com> Date: Tue, 23 Feb 2021 13:28:48 -0600 Subject: [PATCH 013/181] Create +

+

+ +

+ +

+ +

+

+ +

+ +

+ +

+

+ +

+ +)); diff --git a/ts/components/Button.tsx b/ts/components/Button.tsx new file mode 100644 index 0000000000..596902d645 --- /dev/null +++ b/ts/components/Button.tsx @@ -0,0 +1,55 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { MouseEventHandler, ReactNode } from 'react'; +import classNames from 'classnames'; + +import { assert } from '../util/assert'; + +export enum ButtonVariant { + Primary, + Secondary, + Destructive, +} + +type PropsType = { + children: ReactNode; + className?: string; + disabled?: boolean; + onClick: MouseEventHandler; + variant?: ButtonVariant; +}; + +const VARIANT_CLASS_NAMES = new Map([ + [ButtonVariant.Primary, 'module-Button--primary'], + [ButtonVariant.Secondary, 'module-Button--secondary'], + [ButtonVariant.Destructive, 'module-Button--destructive'], +]); + +export const Button = React.forwardRef( + ( + { + children, + className, + disabled = false, + onClick, + variant = ButtonVariant.Primary, + }, + ref + ) => { + const variantClassName = VARIANT_CLASS_NAMES.get(variant); + assert(variantClassName, ' + ); + } +); diff --git a/ts/components/GroupV2JoinDialog.tsx b/ts/components/GroupV2JoinDialog.tsx index d89aa7ff48..8199e0c181 100644 --- a/ts/components/GroupV2JoinDialog.tsx +++ b/ts/components/GroupV2JoinDialog.tsx @@ -6,6 +6,7 @@ import classNames from 'classnames'; import { LocalizerType } from '../types/Util'; import { Avatar } from './Avatar'; import { Spinner } from './Spinner'; +import { Button, ButtonVariant } from './Button'; import { PreJoinConversationType } from '../state/ducks/conversations'; @@ -90,30 +91,30 @@ export const GroupV2JoinDialog = React.memo((props: PropsType) => {
{promptString}
- - +
); From 84dc166b63c49850dfe0797c836e4445a3d56515 Mon Sep 17 00:00:00 2001 From: Jack Lloyd Date: Tue, 23 Feb 2021 14:36:07 -0500 Subject: [PATCH 014/181] Bump to using 0.3.0 release of the libsignal-client library Co-authored-by: Scott Nonnenberg --- libtextsecure/libsignal-protocol.js | 8 -------- package.json | 2 +- preload.js | 12 ++++++++++++ yarn.lock | 6 +++--- 4 files changed, 16 insertions(+), 12 deletions(-) diff --git a/libtextsecure/libsignal-protocol.js b/libtextsecure/libsignal-protocol.js index 28831add46..b7e8fc14fb 100644 --- a/libtextsecure/libsignal-protocol.js +++ b/libtextsecure/libsignal-protocol.js @@ -23918,8 +23918,6 @@ var Internal = Internal || {}; }, HKDF: function(input, salt, info) { - // Specific implementation of RFC 5869 that only returns the first 3 32-byte chunks - // TODO: We dont always need the third chunk, we might skip it return Internal.crypto.sign(salt, input).then(function(PRK) { var infoBuffer = new ArrayBuffer(info.byteLength + 1 + 32); var infoArray = new Uint8Array(infoBuffer); @@ -23980,12 +23978,6 @@ var Internal = Internal || {}; }); }; - libsignal.HKDF = { - deriveSecrets: function(input, salt, info) { - return Internal.HKDF(input, salt, info); - } - }; - libsignal.crypto = { encrypt: function(key, data, iv) { return Internal.crypto.encrypt(key, data, iv); diff --git a/package.json b/package.json index 6c6fa61f80..9f87c11ed4 100644 --- a/package.json +++ b/package.json @@ -148,7 +148,7 @@ "uuid": "3.3.2", "websocket": "1.0.28", "zkgroup": "https://github.com/signalapp/signal-zkgroup-node.git#2d7db946cc88492b65cc66e9aa9de0c9e664fd8d", - "libsignal-client": "https://github.com/signalapp/libsignal-client-node.git#23edaad30ddec4d52a5634b30090563df1351337" + "libsignal-client": "https://github.com/signalapp/libsignal-client-node.git#f10fbd04eb6efb396eb12c25429761e8785dc9d0" }, "devDependencies": { "@babel/core": "7.7.7", diff --git a/preload.js b/preload.js index dadfe1d4f8..fa1b311118 100644 --- a/preload.js +++ b/preload.js @@ -627,6 +627,18 @@ try { window.libsignal.externalCurve = externalCurve; window.libsignal.externalCurveAsync = externalCurveAsync; + window.libsignal.HKDF = {}; + window.libsignal.HKDF.deriveSecrets = (input, salt, info) => { + const hkdf = client.HKDF.new(3); + const output = hkdf.deriveSecrets( + 3 * 32, + Buffer.from(input), + Buffer.from(info), + Buffer.from(salt) + ); + return [output.slice(0, 32), output.slice(32, 64), output.slice(64, 96)]; + }; + // Pulling these in separately since they access filesystem, electron window.Signal.Backup = require('./js/modules/backup'); window.Signal.Debug = require('./js/modules/debug'); diff --git a/yarn.lock b/yarn.lock index ecfb1419d6..0bf7904af1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10268,9 +10268,9 @@ levn@~0.3.0: prelude-ls "~1.1.2" type-check "~0.3.2" -"libsignal-client@https://github.com/signalapp/libsignal-client-node.git#23edaad30ddec4d52a5634b30090563df1351337": - version "0.2.0" - resolved "https://github.com/signalapp/libsignal-client-node.git#23edaad30ddec4d52a5634b30090563df1351337" +"libsignal-client@https://github.com/signalapp/libsignal-client-node.git#f10fbd04eb6efb396eb12c25429761e8785dc9d0": + version "0.3.0" + resolved "https://github.com/signalapp/libsignal-client-node.git#f10fbd04eb6efb396eb12c25429761e8785dc9d0" dependencies: bindings "^1.5.0" From 06fb4fd0bcbc4e4d10bbdd00c94ecadaf5bf46a3 Mon Sep 17 00:00:00 2001 From: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com> Date: Tue, 23 Feb 2021 14:34:28 -0600 Subject: [PATCH 015/181] Add "new conversation" composer for direct messages --- _locales/en/messages.json | 16 + images/icons/v2/compose-outline-24.svg | 1 + js/views/inbox_view.js | 2 +- stylesheets/_modules.scss | 1124 ++++++++--------- ts/background.ts | 189 +-- ts/components/ConversationList.stories.tsx | 471 +++++++ ts/components/ConversationList.tsx | 243 ++++ .../ConversationListItem.stories.tsx | 304 ----- ts/components/ConversationListItem.tsx | 283 ----- ts/components/LeftPane.stories.tsx | 384 ++++-- ts/components/LeftPane.tsx | 886 +++++-------- ts/components/MainHeader.stories.tsx | 1 + ts/components/MainHeader.tsx | 15 + ts/components/MessageSearchResult.tsx | 182 --- ts/components/SearchResults.stories.tsx | 423 ------- ts/components/SearchResults.tsx | 611 --------- ts/components/ShortcutGuide.tsx | 7 +- .../StartNewConversation.stories.tsx | 38 - ts/components/StartNewConversation.tsx | 44 - ts/components/conversation/About.tsx | 10 +- .../BaseConversationListItem.tsx | 143 +++ .../conversationList/ContactListItem.tsx | 86 ++ .../conversationList/ConversationListItem.tsx | 206 +++ .../MessageBodyHighlight.stories.tsx | 6 +- .../MessageBodyHighlight.tsx | 21 +- .../MessageSearchResult.stories.tsx | 9 +- .../conversationList/MessageSearchResult.tsx | 140 ++ .../conversationList/StartNewConversation.tsx | 48 + .../leftPane/LeftPaneArchiveHelper.tsx | 113 ++ .../leftPane/LeftPaneComposeHelper.tsx | 171 +++ ts/components/leftPane/LeftPaneHelper.tsx | 67 + ts/components/leftPane/LeftPaneInboxHelper.ts | 192 +++ .../leftPane/LeftPaneSearchHelper.tsx | 240 ++++ .../leftPane/getConversationInDirection.ts | 63 + ts/groups/joinViaLink.ts | 24 +- ts/models/conversations.ts | 14 +- ts/sql/Server.ts | 4 +- ts/state/ducks/conversations.ts | 131 +- ts/state/ducks/search.ts | 68 +- ts/state/selectors/conversations.ts | 109 +- ts/state/selectors/search.ts | 170 +-- ts/state/smart/CompositionArea.tsx | 11 +- ts/state/smart/ConversationHeader.tsx | 14 +- ts/state/smart/LeftPane.tsx | 67 +- ts/state/smart/MessageSearchResult.tsx | 9 +- .../state/selectors/conversations_test.ts | 221 +++- ts/test-both/state/selectors/search_test.ts | 95 +- ts/test-both/util/deconstructLookup_test.ts | 19 + .../util/isConversationUnread_test.ts | 46 + .../util/isConversationUnregistered_test.ts | 50 + .../state/ducks/conversations_test.ts | 275 +++- ts/test-node/components/LeftPane_test.tsx | 208 --- .../leftPane/LeftPaneArchiveHelper_test.ts | 162 +++ .../leftPane/LeftPaneComposeHelper_test.ts | 144 +++ .../leftPane/LeftPaneInboxHelper_test.ts | 635 ++++++++++ .../leftPane/LeftPaneSearchHelper_test.ts | 331 +++++ .../getConversationInDirection_test.ts | 199 +++ ts/util/deconstructLookup.ts | 21 + ts/util/isConversationUnread.ts | 13 + ts/util/isConversationUnregistered.ts | 13 + ts/util/lint/exceptions.json | 55 +- 61 files changed, 5960 insertions(+), 3887 deletions(-) create mode 100644 images/icons/v2/compose-outline-24.svg create mode 100644 ts/components/ConversationList.stories.tsx create mode 100644 ts/components/ConversationList.tsx delete mode 100644 ts/components/ConversationListItem.stories.tsx delete mode 100644 ts/components/ConversationListItem.tsx delete mode 100644 ts/components/MessageSearchResult.tsx delete mode 100644 ts/components/SearchResults.stories.tsx delete mode 100644 ts/components/SearchResults.tsx delete mode 100644 ts/components/StartNewConversation.stories.tsx delete mode 100644 ts/components/StartNewConversation.tsx create mode 100644 ts/components/conversationList/BaseConversationListItem.tsx create mode 100644 ts/components/conversationList/ContactListItem.tsx create mode 100644 ts/components/conversationList/ConversationListItem.tsx rename ts/components/{ => conversationList}/MessageBodyHighlight.stories.tsx (90%) rename ts/components/{ => conversationList}/MessageBodyHighlight.tsx (76%) rename ts/components/{ => conversationList}/MessageSearchResult.stories.tsx (93%) create mode 100644 ts/components/conversationList/MessageSearchResult.tsx create mode 100644 ts/components/conversationList/StartNewConversation.tsx create mode 100644 ts/components/leftPane/LeftPaneArchiveHelper.tsx create mode 100644 ts/components/leftPane/LeftPaneComposeHelper.tsx create mode 100644 ts/components/leftPane/LeftPaneHelper.tsx create mode 100644 ts/components/leftPane/LeftPaneInboxHelper.ts create mode 100644 ts/components/leftPane/LeftPaneSearchHelper.tsx create mode 100644 ts/components/leftPane/getConversationInDirection.ts create mode 100644 ts/test-both/util/deconstructLookup_test.ts create mode 100644 ts/test-both/util/isConversationUnread_test.ts create mode 100644 ts/test-both/util/isConversationUnregistered_test.ts delete mode 100644 ts/test-node/components/LeftPane_test.tsx create mode 100644 ts/test-node/components/leftPane/LeftPaneArchiveHelper_test.ts create mode 100644 ts/test-node/components/leftPane/LeftPaneComposeHelper_test.ts create mode 100644 ts/test-node/components/leftPane/LeftPaneInboxHelper_test.ts create mode 100644 ts/test-node/components/leftPane/LeftPaneSearchHelper_test.ts create mode 100644 ts/test-node/components/leftPane/getConversationInDirection_test.ts create mode 100644 ts/util/deconstructLookup.ts create mode 100644 ts/util/isConversationUnread.ts create mode 100644 ts/util/isConversationUnregistered.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index fdd9ac3adb..16b1373fe5 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1893,6 +1893,18 @@ "message": "Start new conversation…", "description": "Label underneath number a user enters that is not an existing contact" }, + "newConversation": { + "message": "New conversation", + "description": "Label for header when starting a new conversation" + }, + "newConversationContactSearchPlaceholder": { + "message": "Search by name or phone number", + "description": "Placeholder to use when searching for contacts in the composer" + }, + "newConversationNoContacts": { + "message": "No contacts found", + "description": "Label shown when there are no contacts to compose to" + }, "notSupportedSMS": { "message": "SMS/MMS messages are not supported.", "description": "Label underneath number informing user that SMS is not supported on desktop" @@ -2326,6 +2338,10 @@ "message": "Open conversation menu", "description": "Shown in the shortcuts guide" }, + "Keyboard--new-conversation": { + "message": "Start new conversation", + "description": "Shown in the shortcuts guide" + }, "Keyboard--archive-conversation": { "message": "Archive conversation", "description": "Shown in the shortcuts guide" diff --git a/images/icons/v2/compose-outline-24.svg b/images/icons/v2/compose-outline-24.svg new file mode 100644 index 0000000000..9cf1803e44 --- /dev/null +++ b/images/icons/v2/compose-outline-24.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/js/views/inbox_view.js b/js/views/inbox_view.js index 535a241909..7d23b4bb96 100644 --- a/js/views/inbox_view.js +++ b/js/views/inbox_view.js @@ -212,7 +212,7 @@ const { openConversationExternal } = window.reduxActions.conversations; if (openConversationExternal) { - openConversationExternal(id, messageId); + openConversationExternal(conversation.id, messageId); } this.conversation_stack.open(conversation, messageId); diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index c19668e359..f5a3525a00 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -4228,300 +4228,6 @@ button.module-conversation-details__action-button { } } -// Module: Conversation List Item - -.module-conversation-list-item { - @include button-reset; - - width: 100%; - - display: flex; - flex-direction: row; - padding-right: 16px; - padding-left: 16px; - align-items: center; - - &:hover, - &:focus { - @include light-theme { - background-color: $color-gray-05; - } - @include dark-theme { - background-color: $color-gray-75; - } - } -} - -.module-conversation-list-item__muted { - display: inline-block; - height: 14px; - margin-right: 4px; - vertical-align: middle; - width: 14px; - - @include light-theme { - @include color-svg( - '../images/icons/v2/sound-off-outline-24.svg', - $color-gray-60 - ); - } - @include dark-theme { - @include color-svg( - '../images/icons/v2/sound-off-outline-24.svg', - $color-gray-25 - ); - } -} - -.module-conversation-list-item--has-unread { - padding-left: 12px; - - @include light-theme { - border-left: 4px solid $ultramarine-ui-light; - } - - @include dark-theme { - border-left: 4px solid $ultramarine-ui-dark; - } -} - -.module-conversation-list-item--is-selected { - @include light-theme { - background-color: $color-gray-15; - } - @include dark-theme { - background-color: $color-gray-65; - } -} - -.module-conversation-list-item__avatar-container { - position: relative; - margin-top: 8px; - margin-bottom: 8px; -} - -.module-conversation-list-item__unread-count { - text-align: center; - - padding-left: 3px; - padding-right: 3px; - - position: absolute; - right: -6px; - top: 0px; - - @include font-caption-bold; - - height: 20px; - min-width: 20px; - line-height: 20px; - border-radius: 10px; - - color: $color-white; - - @include light-theme { - background-color: $ultramarine-ui-light; - box-shadow: 0px 0px 0px 1px $color-gray-02; - } - @include dark-theme { - background-color: $ultramarine-ui-dark; - box-shadow: 0px 0px 0px 1px $color-gray-90; - } -} - -.module-conversation-list-item__content { - flex-grow: 1; - margin-left: 12px; - // parent - 52px (for avatar) - 12p (margin to avatar) - max-width: calc(100% - 64px); - - display: flex; - flex-direction: column; - align-items: stretch; -} - -.module-conversation-list-item__header { - display: flex; - flex-direction: row; - align-items: center; -} - -.module-conversation-list-item__header__name { - flex-grow: 1; - flex-shrink: 1; - - @include font-body-1-bold; - - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - - @include light-theme { - color: $color-gray-90; - } - @include dark-theme { - color: $color-gray-05; - } -} - -.module-conversation-list-item__header__timestamp { - flex-shrink: 0; - margin-left: 6px; - - @include font-caption; - - overflow-x: hidden; - white-space: nowrap; - text-overflow: ellipsis; - - @include light-theme { - color: $color-gray-60; - } - @include dark-theme { - color: $color-gray-25; - } -} - -.module-conversation-list-item__header__timestamp--with-unread { - @include font-caption-bold; -} - -.module-conversation-list-item__header__date--has-unread { - @include font-caption-bold; - - @include light-theme { - color: $color-gray-90; - } - @include dark-theme { - color: $color-gray-05; - } -} - -.module-conversation-list-item__message { - display: flex; - flex-direction: row; - align-items: center; -} - -.module-conversation-list-item__message-request { - @include font-body-2-bold; - - @include light-theme { - color: $color-gray-60; - } - @include dark-theme { - color: $color-gray-25; - } -} - -.module-conversation-list-item__message__text { - flex-grow: 1; - flex-shrink: 1; - - @include font-body-2; - - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - text-align: left; - - @include light-theme { - color: $color-gray-60; - } - @include dark-theme { - color: $color-gray-25; - } -} - -.module-conversation-list-item__message__text--has-unread { - @include font-body-2-bold; - - @include light-theme { - color: $color-gray-90; - } - @include dark-theme { - color: $color-gray-05; - } -} - -.module-conversation-list-item__message { - &__draft-prefix, - &__deleted-for-everyone { - font-style: italic; - margin-right: 3px; - } -} - -.module-conversation-list-item__message__status-icon { - flex-shrink: 0; - - margin-top: 2px; - width: 12px; - height: 12px; - display: inline-block; - margin-left: 6px; -} - -.module-conversation-list-item__message__status-icon--sending { - animation: module-conversation-list-item__message__status-icon--spinning 4s - linear infinite; - @include light-theme { - @include color-svg('../images/sending.svg', $color-gray-60); - } - @include dark-theme { - @include color-svg('../images/sending.svg', $color-gray-45); - } -} - -@keyframes module-conversation-list-item__message__status-icon--spinning { - 100% { - -webkit-transform: rotate(360deg); - transform: rotate(360deg); - } -} - -.module-conversation-list-item__message__status-icon--sent { - @include light-theme { - @include color-svg('../images/check-circle-outline.svg', $color-gray-25); - } - @include dark-theme { - @include color-svg('../images/check-circle-outline.svg', $color-gray-45); - } -} -.module-conversation-list-item__message__status-icon--delivered { - @include light-theme { - @include color-svg('../images/double-check.svg', $color-gray-25); - } - @include dark-theme { - @include color-svg('../images/double-check.svg', $color-gray-45); - } - width: 18px; -} -.module-conversation-list-item__message__status-icon--read { - @include light-theme { - @include color-svg('../images/read.svg', $color-gray-25); - } - @include dark-theme { - @include color-svg('../images/read.svg', $color-gray-45); - } - width: 18px; -} -.module-conversation-list-item__message__status-icon--error, -.module-conversation-list-item__message__status-icon--partial-sent { - @include light-theme { - @include color-svg( - '../images/icons/v2/error-outline-12.svg', - $color-accent-red - ); - } - @include dark-theme { - @include color-svg( - '../images/icons/v2/error-solid-12.svg', - $color-accent-red - ); - } -} - // Module: Avatar .module-avatar { @@ -5000,6 +4706,24 @@ button.module-conversation-details__action-button { } } } + + &__compose-icon { + $icon: '../images/icons/v2/compose-outline-24.svg'; + + width: 24px; + height: 24px; + + @include light-theme { + @include color-svg($icon, $color-gray-90); + } + @include dark-theme { + @include color-svg($icon, $color-gray-02); + } + + &:focus { + @include color-svg($icon, $ultramarine-ui-light); + } + } } // Module: Image @@ -6066,176 +5790,6 @@ button.module-image__border-overlay:focus { } } -// Module: Search Results - -.module-search-results { - outline: none; - overflow: hidden; - flex-grow: 1; -} - -.module-search-results__conversations-header, -.module-search-results__contacts-header, -.module-search-results__messages-header { - @include font-body-1-bold; - - height: 52px; - margin-left: 16px; - padding-bottom: 8px; - padding-top: 8px; - - @include dark-theme { - color: $color-gray-05; - } -} - -.module-search-results__sms-not-supported { - font-size: 14px; - padding-top: 12px; - text-align: center; - - @include light-theme { - color: $color-gray-60; - } - @include dark-theme { - color: $color-gray-25; - } -} - -.module-search-results__no-results { - margin-top: 27px; - padding-left: 1em; - padding-right: 1em; - width: 100%; - text-align: center; - outline: none; -} - -.module-search-results__spinner-container { - width: 100%; - padding: 10px; - - text-align: center; -} - -// Module: Message Search Result - -.module-message-search-result { - @include button-reset; - - width: 100%; - - padding: 8px; - padding-left: 16px; - padding-right: 16px; - min-height: 64px; - max-width: $left-pane-width; - - display: flex; - flex-direction: row; - align-items: flex-start; - - &:hover, - &:focus { - @include light-theme { - background-color: $color-gray-05; - } - @include dark-theme { - background-color: $color-gray-75; - } - } -} - -.module-message-search-result--is-selected { - @include light-theme { - background-color: $color-gray-15; - } - @include dark-theme { - background-color: $color-gray-65; - } -} - -.module-message-search-result__text { - flex-grow: 1; - margin-left: 12px; - // parent - 48px (for avatar) - 16px (our right margin) - max-width: calc(100% - 64px); - - display: inline-flex; - flex-direction: column; - align-items: stretch; -} - -.module-message-search-result__header { - display: flex; - flex-direction: row; - align-items: center; -} - -.module-message-search-result__header__from { - @include font-body-1; - - flex-grow: 1; - flex-shrink: 1; - - overflow-x: hidden; - white-space: nowrap; - text-overflow: ellipsis; - - @include light-theme { - color: $color-gray-90; - } - @include dark-theme { - color: $color-gray-05; - } -} - -.module-message-search-result__header__timestamp { - flex-shrink: 0; - margin-left: 6px; - - @include font-caption; - - overflow-x: hidden; - white-space: nowrap; - text-overflow: ellipsis; - - text-transform: uppercase; - - @include light-theme { - color: $color-gray-60; - } - @include dark-theme { - color: $color-gray-25; - } -} - -.module-message-search-result__body { - @include font-body-2; - - margin-top: 1px; - flex-grow: 1; - flex-shrink: 1; - - @include light-theme { - color: $color-gray-60; - } - @include dark-theme { - color: $color-gray-15; - } - - overflow: hidden; - display: -webkit-box; - -webkit-line-clamp: 3; - -webkit-box-orient: vertical; - - // Note: -webkit-line-clamp doesn't work for RTL text, and it forces you to use - // ... as the truncation indicator. That's not a solution that works well for - // all languages. More resources: - // - http://hackingui.com/front-end/a-pure-css-solution-for-multiline-text-truncation/ - // - https://medium.com/mofed/css-line-clamp-the-good-the-bad-and-the-straight-up-broken-865413f16e5 -} - // Module: Reaction Viewer .module-reaction-viewer { @@ -7576,6 +7130,389 @@ button.module-image__border-overlay:focus { } } +// Module: conversation list + +.module-conversation-list { + scroll-behavior: smooth; + + &__item { + &--archive-button { + @include button-reset; + + @include font-body-1-bold; + + height: 64px; + line-height: 64px; + text-align: center; + width: 100%; + + @include light-theme { + color: $color-gray-60; + + &:hover, + &:focus { + background-color: $color-gray-05; + } + } + @include dark-theme { + color: $color-gray-25; + &:hover, + &:focus { + background-color: $color-gray-75; + } + } + + &__archived-count { + @include font-body-2-bold; + + padding: 6px; + padding-top: 1px; + padding-bottom: 1px; + border-radius: 10px; + + @include light-theme { + color: $color-gray-60; + background-color: $color-gray-05; + } + @include dark-theme { + color: $color-gray-25; + background-color: $color-gray-75; + } + } + } + + &--contact-or-conversation { + @include button-reset; + + width: 100%; + + display: flex; + flex-direction: row; + padding-right: 16px; + padding-left: 16px; + align-items: center; + + &:hover, + &:focus { + @include light-theme { + background-color: $color-gray-05; + } + @include dark-theme { + background-color: $color-gray-75; + } + } + + &--has-unread { + padding-left: 12px; + + @include light-theme { + border-left: 4px solid $ultramarine-ui-light; + } + + @include dark-theme { + border-left: 4px solid $ultramarine-ui-dark; + } + } + + &--is-selected { + @include light-theme { + background-color: $color-gray-15; + } + @include dark-theme { + background-color: $color-gray-65; + } + } + + &__avatar-container { + position: relative; + margin-top: 8px; + margin-bottom: 8px; + } + + &__unread-count { + text-align: center; + + padding-left: 3px; + padding-right: 3px; + + position: absolute; + right: -6px; + top: 0px; + + @include font-caption-bold; + + height: 20px; + min-width: 20px; + line-height: 20px; + border-radius: 10px; + + color: $color-white; + + @include light-theme { + background-color: $ultramarine-ui-light; + box-shadow: 0px 0px 0px 1px $color-gray-02; + } + @include dark-theme { + background-color: $ultramarine-ui-dark; + box-shadow: 0px 0px 0px 1px $color-gray-90; + } + } + + &__content { + flex-grow: 1; + margin-left: 12px; + // parent - 52px (for avatar) - 12p (margin to avatar) + max-width: calc(100% - 64px); + + display: flex; + flex-direction: column; + align-items: stretch; + + &__header { + display: flex; + flex-direction: row; + align-items: center; + + &__name { + flex-grow: 1; + flex-shrink: 1; + + @include font-body-1-bold; + + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + + @include light-theme { + color: $color-gray-90; + } + @include dark-theme { + color: $color-gray-05; + } + } + + &__date { + display: inline-block; + flex-shrink: 0; + + &--has-unread { + @include font-caption-bold; + + @include light-theme { + color: $color-gray-90; + } + @include dark-theme { + color: $color-gray-05; + } + } + + &__timestamp { + flex-shrink: 0; + margin-left: 6px; + + @include font-caption; + + overflow-x: hidden; + white-space: nowrap; + text-overflow: ellipsis; + + @include light-theme { + color: $color-gray-60; + } + @include dark-theme { + color: $color-gray-25; + } + + &--with-unread { + @include font-caption-bold; + } + } + } + } + + &__message { + display: flex; + flex-direction: row; + align-items: center; + + &__text { + flex-grow: 1; + flex-shrink: 1; + + @include font-body-2; + + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + text-align: left; + + @include light-theme { + color: $color-gray-60; + } + @include dark-theme { + color: $color-gray-25; + } + + &--has-unread { + @include font-body-2-bold; + + @include light-theme { + color: $color-gray-90; + } + @include dark-theme { + color: $color-gray-05; + } + } + + &__muted { + display: inline-block; + height: 14px; + margin-right: 4px; + vertical-align: middle; + width: 14px; + + @include light-theme { + @include color-svg( + '../images/icons/v2/sound-off-outline-24.svg', + $color-gray-60 + ); + } + @include dark-theme { + @include color-svg( + '../images/icons/v2/sound-off-outline-24.svg', + $color-gray-25 + ); + } + } + + &__message-request { + @include font-body-2-bold; + + @include light-theme { + color: $color-gray-60; + } + @include dark-theme { + color: $color-gray-25; + } + } + + &__draft-prefix, + &__deleted-for-everyone { + font-style: italic; + margin-right: 3px; + } + + &__status-icon { + flex-shrink: 0; + + margin-top: 2px; + width: 12px; + height: 12px; + display: inline-block; + margin-left: 6px; + + @mixin normal-status-icon($icon) { + @include light-theme { + @include color-svg($icon, $color-gray-25); + } + @include dark-theme { + @include color-svg($icon, $color-gray-45); + } + } + + &--sending { + animation: module-conversation-list__item--contact-or-conversation__contact__message__text__status-icon--spinning + 4s linear infinite; + @include light-theme { + @include color-svg('../images/sending.svg', $color-gray-60); + } + @include dark-theme { + @include color-svg('../images/sending.svg', $color-gray-45); + } + } + + &--sent { + @include normal-status-icon( + '../images/check-circle-outline.svg' + ); + } + + &--delivered { + @include normal-status-icon('../images/double-check.svg'); + width: 18px; + } + + &--read { + @include normal-status-icon('../images/read.svg'); + width: 18px; + } + + &--error, + &--partial-sent { + @include light-theme { + @include color-svg( + '../images/icons/v2/error-outline-12.svg', + $color-accent-red + ); + } + @include dark-theme { + @include color-svg( + '../images/icons/v2/error-solid-12.svg', + $color-accent-red + ); + } + } + } + + &__message-search-result-contents { + display: -webkit-box; + white-space: initial; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + + // Note: -webkit-line-clamp doesn't work for RTL text, and it forces you to use + // ... as the truncation indicator. That's not a solution that works well for + // all languages. More resources: + // - http://hackingui.com/front-end/a-pure-css-solution-for-multiline-text-truncation/ + // - https://medium.com/mofed/css-line-clamp-the-good-the-bad-and-the-straight-up-broken-865413f16e5 + } + + &__start-new-conversation { + @include font-body-1-italic; + } + } + } + } + } + + &--header { + @include font-body-1-bold; + + display: inline-flex; + align-items: center; + padding-left: 16px; + + @include dark-theme { + color: $color-gray-05; + } + } + + &--spinner { + width: 100%; + padding: 10px; + + text-align: center; + } + } +} + +@keyframes module-conversation-list__item--contact-or-conversation__contact__message__text__status-icon--spinning { + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} + // Module: Left Pane .module-left-pane { @@ -7589,81 +7526,69 @@ button.module-image__border-overlay:focus { .module-left-pane__header { flex-grow: 0; flex-shrink: 0; -} -.module-left-pane__archive-header { - height: calc(#{$header-height} + var(--title-bar-drag-area-height)); - width: 100%; + &__contents { + height: calc(#{$header-height} + var(--title-bar-drag-area-height)); + width: 100%; - display: inline-flex; - flex-direction: row; - align-items: center; - padding-top: var(--title-bar-drag-area-height); -} + display: inline-flex; + flex-direction: row; + align-items: center; + padding-top: var(--title-bar-drag-area-height); -.module-left-pane__header-row { - @include font-body-1-bold; + &__back-button { + @include button-reset; - display: inline-flex; - align-items: center; - padding-left: 16px; + margin-left: 7px; + margin-right: 5px; - @include dark-theme { - color: $color-gray-05; - } -} + width: 24px; + height: 24px; -.module-left-pane__to-inbox-button { - @include button-reset; + @include light-theme { + @include color-svg( + '../images/icons/v2/chevron-left-24.svg', + $color-gray-60 + ); + } + @include keyboard-mode { + &:focus { + @include color-svg( + '../images/icons/v2/chevron-left-24.svg', + $ultramarine-ui-light + ); + } + } - margin-left: 7px; - margin-right: 5px; - - width: 24px; - height: 24px; - - @include light-theme { - @include color-svg( - '../images/icons/v2/chevron-left-24.svg', - $color-gray-60 - ); - } - @include keyboard-mode { - &:focus { - @include color-svg( - '../images/icons/v2/chevron-left-24.svg', - $ultramarine-ui-light - ); + @include dark-theme { + @include color-svg( + '../images/icons/v2/chevron-left-24.svg', + $color-gray-25 + ); + } + @include dark-keyboard-mode { + &:hover { + @include color-svg( + '../images/icons/v2/chevron-left-24.svg', + $ultramarine-ui-dark + ); + } + } } - } - @include dark-theme { - @include color-svg( - '../images/icons/v2/chevron-left-24.svg', - $color-gray-25 - ); - } - @include dark-keyboard-mode { - &:hover { - @include color-svg( - '../images/icons/v2/chevron-left-24.svg', - $ultramarine-ui-dark - ); + &__text { + @include font-body-1-bold; + + @include light-theme { + color: $color-gray-90; + } + @include dark-theme { + color: $color-gray-05; + } } } } -.module-left-pane__archive-header-text { - @include font-body-1-bold; - - @include light-theme { - color: $color-gray-90; - } - @include dark-theme { - color: $color-gray-05; - } -} - .module-left-pane__archive-helper-text { @include font-body-2; @@ -7682,6 +7607,54 @@ button.module-image__border-overlay:focus { } } +.module-left-pane__no-search-results, +.module-left-pane__compose-no-contacts { + flex-grow: 1; + margin-top: 27px; + padding-left: 1em; + padding-right: 1em; + width: 100%; + text-align: center; + outline: none; +} + +.module-left-pane__compose-search-form { + display: flex; + padding: 8px 16px; + margin-bottom: 8px; + + &__input { + flex-grow: 1; + + padding: 5px 12px; + + border-radius: 17px; + border: none; + + @include font-body-1; + + @include light-theme { + background-color: $color-gray-05; + color: $color-gray-90; + border: solid 1px $color-gray-02; + } + @include dark-theme { + color: $color-gray-05; + background-color: $color-gray-95; + border: solid 1px $color-gray-80; + } + + &:placeholder { + color: $color-gray-45; + } + + &:focus { + border: solid 1px $ultramarine-ui-light; + outline: none; + } + } +} + .module-left-pane__list--measure { flex-grow: 1; flex-shrink: 1; @@ -7694,108 +7667,9 @@ button.module-image__border-overlay:focus { .module-left-pane__list { position: absolute; -} - -.module-left-pane__virtual-list { outline: none; } -.module-left-pane__archived-button { - @include button-reset; - - @include font-body-1-bold; - - height: 64px; - line-height: 64px; - text-align: center; - width: 100%; - - @include light-theme { - color: $color-gray-60; - - &:hover, - &:focus { - background-color: $color-gray-05; - } - } - @include dark-theme { - color: $color-gray-25; - &:hover, - &:focus { - background-color: $color-gray-75; - } - } -} - -.module-left-pane__archived-button__archived-count { - @include font-body-2-bold; - - padding: 6px; - padding-top: 1px; - padding-bottom: 1px; - border-radius: 10px; - - @include light-theme { - color: $color-gray-60; - background-color: $color-gray-05; - } - @include dark-theme { - color: $color-gray-25; - background-color: $color-gray-75; - } -} - -// Module: Start New Conversation - -.module-start-new-conversation { - @include button-reset; - - width: 100%; - - display: flex; - flex-direction: row; - align-items: center; - - padding-top: 8px; - padding-bottom: 8px; - padding-left: 16px; - - &:hover, - &:focus { - @include light-theme { - background-color: $color-gray-05; - } - @include dark-theme { - background-color: $color-gray-75; - } - } -} - -.module-start-new-conversation__content { - margin-left: 12px; -} - -.module-start-new-conversation__number { - font-weight: bold; - - @include dark-theme { - color: $color-gray-05; - } -} - -.module-start-new-conversation__text { - margin-top: 3px; - - @include font-body-1-italic; - - @include light-theme { - color: $color-gray-60; - } - @include dark-theme { - color: $color-gray-45; - } -} - // Module: Timeline Loading Row .module-timeline-loading-row { diff --git a/ts/background.ts b/ts/background.ts index 5fd76106ce..2c6b22ccaf 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -730,7 +730,7 @@ type WhatIsThis = import('./window.d').WhatIsThis; ), messagesByConversation: {}, messagesLookup: {}, - selectedConversation: undefined, + selectedConversationId: undefined, selectedMessage: undefined, selectedMessageCounter: 0, selectedConversationPanelDepth: 0, @@ -866,85 +866,9 @@ type WhatIsThis = import('./window.d').WhatIsThis; } }; - function getConversationsToSearch() { - const state = store.getState(); - const { - archivedConversations, - conversations: unpinnedConversations, - pinnedConversations, - } = window.Signal.State.Selectors.conversations.getLeftPaneLists(state); - - return state.conversations.showArchived - ? archivedConversations - : [...pinnedConversations, ...unpinnedConversations]; - } - - function getConversationByIndex(index: WhatIsThis) { - const conversationsToSearch = getConversationsToSearch(); - - const target = conversationsToSearch[index]; - - if (target) { - return target.id; - } - - return null; - } - - function findConversation( - conversationId: WhatIsThis, - direction: WhatIsThis, - unreadOnly: WhatIsThis - ) { - const conversationsToSearch = getConversationsToSearch(); - - const increment = direction === 'up' ? -1 : 1; - let startIndex: WhatIsThis; - - if (conversationId) { - const index = conversationsToSearch.findIndex( - (item: WhatIsThis) => item.id === conversationId - ); - if (index >= 0) { - startIndex = index + increment; - } - } else { - startIndex = direction === 'up' ? conversationsToSearch.length - 1 : 0; - } - - for ( - let i = startIndex, max = conversationsToSearch.length; - i >= 0 && i < max; - i += increment - ) { - const target = conversationsToSearch[i]; - if (!unreadOnly) { - return target.id; - } - if ((target.unreadCount || 0) > 0) { - return target.id; - } - } - - return null; - } - - const NUMBERS: Record = { - '1': 1, - '2': 2, - '3': 3, - '4': 4, - '5': 5, - '6': 6, - '7': 7, - '8': 8, - '9': 9, - }; - document.addEventListener('keydown', event => { - const { altKey, ctrlKey, key, metaKey, shiftKey } = event; + const { ctrlKey, key, metaKey, shiftKey } = event; - const optionOrAlt = altKey; const commandKey = window.platform === 'darwin' && metaKey; const controlKey = window.platform !== 'darwin' && ctrlKey; const commandOrCtrl = commandKey || controlKey; @@ -952,9 +876,6 @@ type WhatIsThis = import('./window.d').WhatIsThis; const state = store.getState(); const selectedId = state.conversations.selectedConversationId; const conversation = window.ConversationController.get(selectedId); - const isSearching = window.Signal.State.Selectors.search.isSearching( - state - ); // NAVIGATION @@ -976,8 +897,14 @@ type WhatIsThis = import('./window.d').WhatIsThis; const targets: Array = [ document.querySelector('.module-main-header .module-avatar-button'), - document.querySelector('.module-left-pane__to-inbox-button'), + document.querySelector( + '.module-left-pane__header__contents__back-button' + ), document.querySelector('.module-main-header__search__input'), + document.querySelector('.module-main-header__compose-icon'), + document.querySelector( + '.module-left-pane__compose-search-form__input' + ), document.querySelector('.module-left-pane__list'), document.querySelector('.module-search-results'), document.querySelector('.module-composition-area .ql-editor'), @@ -1128,94 +1055,6 @@ type WhatIsThis = import('./window.d').WhatIsThis; return; } - // Change currently selected conversation by index - if (!isSearching && commandOrCtrl && NUMBERS[key]) { - const targetId = getConversationByIndex( - (NUMBERS[key] as WhatIsThis) - 1 - ); - - if (targetId) { - window.Whisper.events.trigger('showConversation', targetId); - event.preventDefault(); - event.stopPropagation(); - return; - } - } - - // Change currently selected conversation - // up/previous - if ( - (!isSearching && optionOrAlt && !shiftKey && key === 'ArrowUp') || - (!isSearching && commandOrCtrl && shiftKey && key === '[') || - (!isSearching && ctrlKey && shiftKey && key === 'Tab') - ) { - const unreadOnly = false; - const targetId = findConversation( - conversation ? conversation.id : null, - 'up', - unreadOnly - ); - - if (targetId) { - window.Whisper.events.trigger('showConversation', targetId); - event.preventDefault(); - event.stopPropagation(); - return; - } - } - // down/next - if ( - (!isSearching && optionOrAlt && !shiftKey && key === 'ArrowDown') || - (!isSearching && commandOrCtrl && shiftKey && key === ']') || - (!isSearching && ctrlKey && key === 'Tab') - ) { - const unreadOnly = false; - const targetId = findConversation( - conversation ? conversation.id : null, - 'down', - unreadOnly - ); - - if (targetId) { - window.Whisper.events.trigger('showConversation', targetId); - event.preventDefault(); - event.stopPropagation(); - return; - } - } - // previous unread - if (!isSearching && optionOrAlt && shiftKey && key === 'ArrowUp') { - const unreadOnly = true; - const targetId = findConversation( - conversation ? conversation.id : null, - 'up', - unreadOnly - ); - - if (targetId) { - window.Whisper.events.trigger('showConversation', targetId); - event.preventDefault(); - event.stopPropagation(); - return; - } - } - // next unread - if (!isSearching && optionOrAlt && shiftKey && key === 'ArrowDown') { - const unreadOnly = true; - const targetId = findConversation( - conversation ? conversation.id : null, - 'down', - unreadOnly - ); - - if (targetId) { - window.Whisper.events.trigger('showConversation', targetId); - event.preventDefault(); - event.stopPropagation(); - return; - } - } - // Preferences - handled by Electron-managed keyboard shortcuts // Open the top-right menu for current conversation @@ -1323,8 +1162,7 @@ type WhatIsThis = import('./window.d').WhatIsThis; ); // It's very likely that the act of archiving a conversation will set focus to - // 'none,' or the top-level body element. This resets it to the left pane, - // whether in the normal conversation list or search results. + // 'none,' or the top-level body element. This resets it to the left pane. if (document.activeElement === document.body) { const leftPaneEl: HTMLElement | null = document.querySelector( '.module-left-pane__list' @@ -1332,13 +1170,6 @@ type WhatIsThis = import('./window.d').WhatIsThis; if (leftPaneEl) { leftPaneEl.focus(); } - - const searchResultsEl: HTMLElement | null = document.querySelector( - '.module-search-results' - ); - if (searchResultsEl) { - searchResultsEl.focus(); - } } event.preventDefault(); diff --git a/ts/components/ConversationList.stories.tsx b/ts/components/ConversationList.stories.tsx new file mode 100644 index 0000000000..41de5daed1 --- /dev/null +++ b/ts/components/ConversationList.stories.tsx @@ -0,0 +1,471 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import { omit } from 'lodash'; + +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import { boolean, date, select, text } from '@storybook/addon-knobs'; + +import { ConversationList, PropsType, RowType, Row } from './ConversationList'; +import { MessageSearchResult } from './conversationList/MessageSearchResult'; +import { + PropsData as ConversationListItemPropsType, + MessageStatuses, +} from './conversationList/ConversationListItem'; +import { setup as setupI18n } from '../../js/modules/i18n'; +import enMessages from '../../_locales/en/messages.json'; + +const i18n = setupI18n('en', enMessages); + +const story = storiesOf('Components/ConversationList', module); + +const defaultConversations: Array = [ + { + id: 'fred-convo', + isSelected: false, + lastUpdated: Date.now(), + markedUnread: false, + title: 'Fred Willard', + type: 'direct', + }, + { + id: 'marc-convo', + isSelected: true, + lastUpdated: Date.now(), + markedUnread: false, + unreadCount: 12, + title: 'Marc Barraca', + type: 'direct', + }, +]; + +const createProps = (rows: ReadonlyArray): PropsType => ({ + dimensions: { + width: 300, + height: 350, + }, + rowCount: rows.length, + getRow: (index: number) => rows[index], + shouldRecomputeRowHeights: false, + i18n, + onSelectConversation: action('onSelectConversation'), + onClickArchiveButton: action('onClickArchiveButton'), + renderMessageSearchResult: (id: string, style: React.CSSProperties) => ( + + ), + startNewConversationFromPhoneNumber: action( + 'startNewConversationFromPhoneNumber' + ), +}); + +story.add('Archive button', () => ( + +)); + +story.add('Contact: note to self', () => ( + +)); + +story.add('Contact: direct', () => ( + +)); + +story.add('Contact: direct with short about', () => ( + +)); + +story.add('Contact: direct with long about', () => ( + +)); + +story.add('Contact: group', () => ( + +)); + +{ + const createConversation = ( + overrideProps: Partial = {} + ): ConversationListItemPropsType => ({ + ...overrideProps, + acceptedMessageRequest: boolean( + 'acceptedMessageRequest', + overrideProps.acceptedMessageRequest !== undefined + ? overrideProps.acceptedMessageRequest + : true + ), + isMe: boolean('isMe', overrideProps.isMe || false), + avatarPath: text('avatarPath', overrideProps.avatarPath || ''), + id: overrideProps.id || '', + isSelected: boolean('isSelected', overrideProps.isSelected || false), + title: text('title', overrideProps.title || 'Some Person'), + name: overrideProps.name || 'Some Person', + type: overrideProps.type || 'direct', + markedUnread: boolean('markedUnread', overrideProps.markedUnread || false), + lastMessage: overrideProps.lastMessage || { + text: text('lastMessage.text', 'Hi there!'), + status: select( + 'status', + MessageStatuses.reduce((m, s) => ({ ...m, [s]: s }), {}), + 'read' + ), + }, + lastUpdated: date( + 'lastUpdated', + new Date(overrideProps.lastUpdated || Date.now() - 5 * 60 * 1000) + ), + }); + + const renderConversation = ( + overrideProps: Partial = {} + ) => ( + + ); + + story.add('Conversation: name', () => renderConversation()); + + story.add('Conversation: name and avatar', () => + renderConversation({ + avatarPath: '/fixtures/kitten-1-64-64.jpg', + }) + ); + + story.add('Conversation: with yourself', () => + renderConversation({ + lastMessage: { + text: 'Just a second', + status: 'read', + }, + name: 'Myself', + title: 'Myself', + isMe: true, + }) + ); + + story.add('Conversations: Message Statuses', () => ( + ({ + type: RowType.Conversation, + conversation: createConversation({ + lastMessage: { text: status, status }, + }), + })) + )} + /> + )); + + story.add('Conversation: Typing Status', () => + renderConversation({ + typingContact: { + name: 'Someone Here', + }, + }) + ); + + story.add('Conversation: With draft', () => + renderConversation({ + shouldShowDraft: true, + draftPreview: "I'm in the middle of typing this...", + }) + ); + + story.add('Conversation: Deleted for everyone', () => + renderConversation({ + lastMessage: { + status: 'sent', + text: 'You should not see this!', + deletedForEveryone: true, + }, + }) + ); + + story.add('Conversation: Message Request', () => + renderConversation({ + acceptedMessageRequest: false, + lastMessage: { + text: 'A Message', + status: 'delivered', + }, + }) + ); + + story.add('Conversations: unread count', () => ( + ({ + type: RowType.Conversation, + conversation: createConversation({ + lastMessage: { text: 'Hey there!', status: 'delivered' }, + unreadCount, + }), + })) + )} + /> + )); + + story.add('Conversation: marked unread', () => + renderConversation({ markedUnread: true }) + ); + + story.add('Conversation: Selected', () => + renderConversation({ + lastMessage: { + text: 'Hey there!', + status: 'read', + }, + isSelected: true, + }) + ); + + story.add('Conversation: Emoji in Message', () => + renderConversation({ + lastMessage: { + text: '🔥', + status: 'read', + }, + }) + ); + + story.add('Conversation: Link in Message', () => + renderConversation({ + lastMessage: { + text: 'Download at http://signal.org', + status: 'read', + }, + }) + ); + + story.add('Conversation: long name', () => { + const name = + 'Long contact name. Esquire. The third. And stuff. And more! And more!'; + + return renderConversation({ + name, + title: name, + }); + }); + + story.add('Conversation: Long Message', () => { + const messages = [ + "Long line. This is a really really really long line. Really really long. Because that's just how it is", + `Many lines. This is a many-line message. +Line 2 is really exciting but it shouldn't be seen. +Line three is even better. +Line 4, well.`, + ]; + + return ( + ({ + type: RowType.Conversation, + conversation: createConversation({ + lastMessage: { + text: messageText, + status: 'read', + }, + }), + })) + )} + /> + ); + }); + + story.add('Conversations: Various Times', () => { + const times: Array<[number, string]> = [ + [Date.now() - 5 * 60 * 60 * 1000, 'Five hours ago'], + [Date.now() - 24 * 60 * 60 * 1000, 'One day ago'], + [Date.now() - 7 * 24 * 60 * 60 * 1000, 'One week ago'], + [Date.now() - 365 * 24 * 60 * 60 * 1000, 'One year ago'], + ]; + + return ( + ({ + type: RowType.Conversation, + conversation: createConversation({ + lastUpdated, + lastMessage: { + text: messageText, + status: 'read', + }, + }), + })) + )} + /> + ); + }); + + story.add('Conversation: Missing Date', () => { + const row = { + type: RowType.Conversation as const, + conversation: omit(createConversation(), 'lastUpdated'), + }; + + return ; + }); + + story.add('Conversation: Missing Message', () => { + const row = { + type: RowType.Conversation as const, + conversation: omit(createConversation(), 'lastMessage'), + }; + + return ; + }); + + story.add('Conversation: Missing Text', () => + renderConversation({ + lastMessage: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + text: undefined as any, + status: 'sent', + }, + }) + ); + + story.add('Conversation: Muted Conversation', () => + renderConversation({ + muteExpiresAt: Date.now() + 1000 * 60 * 60, + }) + ); + + story.add('Conversation: At Mention', () => + renderConversation({ + title: 'The Rebellion', + type: 'group', + lastMessage: { + text: '@Leia Organa I know', + status: 'read', + }, + }) + ); +} + +story.add('Headers', () => ( + +)); + +story.add('Start new conversation', () => ( + +)); + +story.add('Kitchen sink', () => ( + +)); diff --git a/ts/components/ConversationList.tsx b/ts/components/ConversationList.tsx new file mode 100644 index 0000000000..147257ef8f --- /dev/null +++ b/ts/components/ConversationList.tsx @@ -0,0 +1,243 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { useRef, useEffect, useCallback, CSSProperties } from 'react'; +import { List, ListRowRenderer } from 'react-virtualized'; + +import { missingCaseError } from '../util/missingCaseError'; +import { assert } from '../util/assert'; +import { LocalizerType } from '../types/Util'; + +import { + ConversationListItem, + PropsData as ConversationListItemPropsType, +} from './conversationList/ConversationListItem'; +import { + ContactListItem, + PropsDataType as ContactListItemPropsType, +} from './conversationList/ContactListItem'; +import { Spinner as SpinnerComponent } from './Spinner'; +import { StartNewConversation as StartNewConversationComponent } from './conversationList/StartNewConversation'; + +export enum RowType { + ArchiveButton, + Contact, + Conversation, + Header, + MessageSearchResult, + Spinner, + StartNewConversation, +} + +type ArchiveButtonRowType = { + type: RowType.ArchiveButton; + archivedConversationsCount: number; +}; + +type ContactRowType = { + type: RowType.Contact; + contact: ContactListItemPropsType; +}; + +type ConversationRowType = { + type: RowType.Conversation; + conversation: ConversationListItemPropsType; +}; + +type MessageRowType = { + type: RowType.MessageSearchResult; + messageId: string; +}; + +type HeaderRowType = { + type: RowType.Header; + i18nKey: string; +}; + +type SpinnerRowType = { type: RowType.Spinner }; + +type StartNewConversationRowType = { + type: RowType.StartNewConversation; + phoneNumber: string; +}; + +export type Row = + | ArchiveButtonRowType + | ContactRowType + | ConversationRowType + | MessageRowType + | HeaderRowType + | SpinnerRowType + | StartNewConversationRowType; + +export type PropsType = { + dimensions?: { + width: number; + height: number; + }; + rowCount: number; + // If `getRow` is called with an invalid index, it should return `undefined`. However, + // this should only happen if there is a bug somewhere. For example, an inaccurate + // `rowCount`. + getRow: (index: number) => undefined | Row; + scrollToRowIndex?: number; + shouldRecomputeRowHeights: boolean; + + i18n: LocalizerType; + + onSelectConversation: (conversationId: string, messageId?: string) => void; + onClickArchiveButton: () => void; + renderMessageSearchResult: (id: string, style: CSSProperties) => JSX.Element; + startNewConversationFromPhoneNumber: (e164: string) => void; +}; + +export const ConversationList: React.FC = ({ + dimensions, + getRow, + i18n, + onClickArchiveButton, + onSelectConversation, + renderMessageSearchResult, + rowCount, + scrollToRowIndex, + shouldRecomputeRowHeights, + startNewConversationFromPhoneNumber, +}) => { + const listRef = useRef(null); + + useEffect(() => { + const list = listRef.current; + if (shouldRecomputeRowHeights && list) { + list.recomputeRowHeights(); + } + }, [shouldRecomputeRowHeights]); + + const calculateRowHeight = useCallback( + ({ index }: { index: number }): number => { + const row = getRow(index); + if (!row) { + assert(false, `Expected a row at index ${index}`); + return 68; + } + return row.type === RowType.Header ? 40 : 68; + }, + [getRow] + ); + + const renderRow: ListRowRenderer = useCallback( + ({ key, index, style }) => { + const row = getRow(index); + if (!row) { + assert(false, `Expected a row at index ${index}`); + return
; + } + + switch (row.type) { + case RowType.ArchiveButton: + return ( + + ); + case RowType.Contact: + return ( + + ); + case RowType.Conversation: + return ( + + ); + case RowType.Header: + return ( +
+ {i18n(row.i18nKey)} +
+ ); + case RowType.Spinner: + return ( +
+ +
+ ); + case RowType.MessageSearchResult: + return ( + + {renderMessageSearchResult(row.messageId, style)} + + ); + case RowType.StartNewConversation: + return ( + { + startNewConversationFromPhoneNumber(row.phoneNumber); + }} + style={style} + /> + ); + default: + throw missingCaseError(row); + } + }, + [ + getRow, + i18n, + onClickArchiveButton, + onSelectConversation, + renderMessageSearchResult, + startNewConversationFromPhoneNumber, + ] + ); + + // Though `width` and `height` are required properties, we want to be careful in case + // the caller sends bogus data. Notably, react-measure's types seem to be inaccurate. + const { width = 0, height = 0 } = dimensions || {}; + if (!width || !height) { + return null; + } + + return ( + + ); +}; diff --git a/ts/components/ConversationListItem.stories.tsx b/ts/components/ConversationListItem.stories.tsx deleted file mode 100644 index cf98aa045a..0000000000 --- a/ts/components/ConversationListItem.stories.tsx +++ /dev/null @@ -1,304 +0,0 @@ -// Copyright 2020 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import * as React from 'react'; - -import { storiesOf } from '@storybook/react'; -import { action } from '@storybook/addon-actions'; -import { boolean, date, select, text } from '@storybook/addon-knobs'; - -import { - ConversationListItem, - MessageStatuses, - Props, -} from './ConversationListItem'; -import { setup as setupI18n } from '../../js/modules/i18n'; -import enMessages from '../../_locales/en/messages.json'; - -const i18n = setupI18n('en', enMessages); - -const story = storiesOf('Components/ConversationListItem', module); - -story.addDecorator(storyFn => ( -
{storyFn()}
-)); - -const createProps = (overrideProps: Partial = {}): Props => ({ - ...overrideProps, - i18n, - acceptedMessageRequest: boolean( - 'acceptedMessageRequest', - overrideProps.acceptedMessageRequest !== undefined - ? overrideProps.acceptedMessageRequest - : true - ), - isMe: boolean('isMe', overrideProps.isMe || false), - avatarPath: text('avatarPath', overrideProps.avatarPath || ''), - id: overrideProps.id || '', - isSelected: boolean('isSelected', overrideProps.isSelected || false), - title: text('title', overrideProps.title || 'Some Person'), - name: overrideProps.name || 'Some Person', - type: overrideProps.type || 'direct', - onClick: action('onClick'), - markedUnread: boolean('markedUnread', overrideProps.markedUnread || false), - lastMessage: overrideProps.lastMessage || { - text: text('lastMessage.text', 'Hi there!'), - status: select( - 'status', - MessageStatuses.reduce((m, s) => ({ ...m, [s]: s }), {}), - 'read' - ), - }, - lastUpdated: date( - 'lastUpdated', - new Date(overrideProps.lastUpdated || Date.now() - 5 * 60 * 1000) - ), -}); - -story.add('Name', () => { - const props = createProps(); - - return ; -}); - -story.add('Name and Avatar', () => { - const props = createProps({ - avatarPath: '/fixtures/kitten-1-64-64.jpg', - }); - - return ; -}); - -story.add('Conversation with Yourself', () => { - const props = createProps({ - lastMessage: { - text: 'Just a second', - status: 'read', - }, - name: 'Myself', - title: 'Myself', - isMe: true, - }); - - return ; -}); - -story.add('Message Statuses', () => { - return MessageStatuses.map(status => { - const props = createProps({ - lastMessage: { - text: status, - status, - }, - }); - - return ; - }); -}); - -story.add('Typing Status', () => { - const props = createProps({ - typingContact: { - name: 'Someone Here', - }, - }); - - return ; -}); - -story.add('With draft', () => { - const props = createProps({ - shouldShowDraft: true, - draftPreview: "I'm in the middle of typing this...", - }); - - return ; -}); - -story.add('Deleted for everyone', () => { - const props = createProps({ - lastMessage: { - status: 'sent', - text: 'You should not see this!', - deletedForEveryone: true, - }, - }); - - return ; -}); - -story.add('Message Request', () => { - const props = createProps({ - acceptedMessageRequest: false, - lastMessage: { - text: 'A Message', - status: 'delivered', - }, - }); - - return ; -}); - -story.add('Unread', () => { - const counts = [4, 10, 250]; - const defaultProps = createProps({ - lastMessage: { - text: 'Hey there!', - status: 'delivered', - }, - }); - - const items = counts.map(unreadCount => { - const props = { - ...defaultProps, - unreadCount, - }; - - return ; - }); - - const markedUnreadProps = { - ...defaultProps, - markedUnread: true, - }; - - const markedUnreadItem = [ - , - ]; - - return [...items, ...markedUnreadItem]; -}); - -story.add('Selected', () => { - const props = createProps({ - lastMessage: { - text: 'Hey there!', - status: 'read', - }, - isSelected: true, - }); - - return ; -}); - -story.add('Emoji in Message', () => { - const props = createProps({ - lastMessage: { - text: '🔥', - status: 'read', - }, - }); - - return ; -}); - -story.add('Link in Message', () => { - const props = createProps({ - lastMessage: { - text: 'Download at http://signal.org', - status: 'read', - }, - }); - - return ; -}); - -story.add('Long Name', () => { - const name = - 'Long contact name. Esquire. The third. And stuff. And more! And more!'; - - const props = createProps({ - name, - title: name, - }); - - return ; -}); - -story.add('Long Message', () => { - const messages = [ - "Long line. This is a really really really long line. Really really long. Because that's just how it is", - `Many lines. This is a many-line message. -Line 2 is really exciting but it shouldn't be seen. -Line three is even better. -Line 4, well.`, - ]; - - return messages.map(message => { - const props = createProps({ - lastMessage: { - text: message, - status: 'read', - }, - }); - - return ; - }); -}); - -story.add('Various Times', () => { - const times: Array<[number, string]> = [ - [Date.now() - 5 * 60 * 60 * 1000, 'Five hours ago'], - [Date.now() - 24 * 60 * 60 * 1000, 'One day ago'], - [Date.now() - 7 * 24 * 60 * 60 * 1000, 'One week ago'], - [Date.now() - 365 * 24 * 60 * 60 * 1000, 'One year ago'], - ]; - - return times.map(([lastUpdated, messageText]) => { - const props = createProps({ - lastUpdated, - lastMessage: { - text: messageText, - status: 'read', - }, - }); - - return ; - }); -}); - -story.add('Missing Date', () => { - const props = createProps(); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return ; -}); - -story.add('Missing Message', () => { - const props = createProps(); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return ; -}); - -story.add('Missing Text', () => { - const props = createProps(); - - return ( - - ); -}); - -story.add('Muted Conversation', () => { - const props = createProps(); - const muteExpiresAt = Date.now() + 1000 * 60 * 60; - - return ; -}); - -story.add('At Mention', () => { - const props = createProps({ - title: 'The Rebellion', - type: 'group', - lastMessage: { - text: '@Leia Organa I know', - status: 'read', - }, - }); - - return ; -}); diff --git a/ts/components/ConversationListItem.tsx b/ts/components/ConversationListItem.tsx deleted file mode 100644 index 99368e7ecf..0000000000 --- a/ts/components/ConversationListItem.tsx +++ /dev/null @@ -1,283 +0,0 @@ -// Copyright 2018-2020 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import React, { CSSProperties } from 'react'; -import classNames from 'classnames'; -import { isNumber } from 'lodash'; - -import { Avatar } from './Avatar'; -import { MessageBody } from './conversation/MessageBody'; -import { Timestamp } from './conversation/Timestamp'; -import { ContactName } from './conversation/ContactName'; -import { TypingAnimation } from './conversation/TypingAnimation'; -import { cleanId } from './_util'; - -import { LocalizerType } from '../types/Util'; -import { ColorType } from '../types/Colors'; - -export const MessageStatuses = [ - 'sending', - 'sent', - 'delivered', - 'read', - 'error', - 'partial-sent', -] as const; - -export type MessageStatusType = typeof MessageStatuses[number]; - -export type PropsData = { - id: string; - phoneNumber?: string; - color?: ColorType; - profileName?: string; - title: string; - name?: string; - type: 'group' | 'direct'; - avatarPath?: string; - isMe?: boolean; - muteExpiresAt?: number; - - lastUpdated?: number; - unreadCount?: number; - markedUnread?: boolean; - isSelected?: boolean; - - acceptedMessageRequest?: boolean; - draftPreview?: string; - shouldShowDraft?: boolean; - - typingContact?: unknown; - lastMessage?: { - status: MessageStatusType; - text: string; - deletedForEveryone?: boolean; - }; - isPinned?: boolean; -}; - -type PropsHousekeeping = { - i18n: LocalizerType; - style?: CSSProperties; - onClick?: (id: string) => void; -}; - -export type Props = PropsData & PropsHousekeeping; - -export class ConversationListItem extends React.PureComponent { - public renderAvatar(): JSX.Element { - const { - avatarPath, - color, - type, - i18n, - isMe, - name, - phoneNumber, - profileName, - title, - } = this.props; - - return ( -
- - {this.renderUnread()} -
- ); - } - - isUnread(): boolean { - const { markedUnread, unreadCount } = this.props; - - return Boolean((isNumber(unreadCount) && unreadCount > 0) || markedUnread); - } - - public renderUnread(): JSX.Element | null { - const { unreadCount } = this.props; - - if (this.isUnread()) { - return ( -
- {unreadCount || ''} -
- ); - } - - return null; - } - - public renderHeader(): JSX.Element { - const { - i18n, - isMe, - lastUpdated, - name, - phoneNumber, - profileName, - title, - } = this.props; - - return ( -
-
- {isMe ? ( - i18n('noteToSelf') - ) : ( - - )} -
-
- -
-
- ); - } - - public renderMessage(): JSX.Element | null { - const { - draftPreview, - i18n, - acceptedMessageRequest, - lastMessage, - muteExpiresAt, - shouldShowDraft, - typingContact, - } = this.props; - if (!lastMessage && !typingContact) { - return null; - } - - const messageBody = lastMessage ? lastMessage.text : ''; - const showingDraft = shouldShowDraft && draftPreview; - const deletedForEveryone = Boolean( - lastMessage && lastMessage.deletedForEveryone - ); - - /* eslint-disable no-nested-ternary */ - return ( -
-
- {muteExpiresAt && Date.now() < muteExpiresAt && ( - - )} - {!acceptedMessageRequest ? ( - - {i18n('ConversationListItem--message-request')} - - ) : typingContact ? ( - - ) : ( - <> - {showingDraft ? ( - <> - - {i18n('ConversationListItem--draft-prefix')} - - - - ) : deletedForEveryone ? ( - - {i18n('message--deletedForEveryone')} - - ) : ( - - )} - - )} -
- {!showingDraft && lastMessage && lastMessage.status ? ( -
- ) : null} -
- ); - } - /* eslint-enable no-nested-ternary */ - - public render(): JSX.Element { - const { id, isSelected, onClick, style } = this.props; - - return ( - - ); - } -} diff --git a/ts/components/LeftPane.stories.tsx b/ts/components/LeftPane.stories.tsx index 791a0b452d..6c5a60996f 100644 --- a/ts/components/LeftPane.stories.tsx +++ b/ts/components/LeftPane.stories.tsx @@ -1,14 +1,14 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; import { action } from '@storybook/addon-actions'; -import { boolean, text } from '@storybook/addon-knobs'; import { storiesOf } from '@storybook/react'; -import { LeftPane, PropsType } from './LeftPane'; -import { PropsData } from './ConversationListItem'; +import { LeftPane, LeftPaneMode, PropsType } from './LeftPane'; +import { PropsData as ConversationListItemPropsType } from './conversationList/ConversationListItem'; +import { MessageSearchResult } from './conversationList/MessageSearchResult'; import { setup as setupI18n } from '../../js/modules/i18n'; import enMessages from '../../_locales/en/messages.json'; @@ -16,7 +16,7 @@ const i18n = setupI18n('en', enMessages); const story = storiesOf('Components/LeftPane', module); -const defaultConversations: Array = [ +const defaultConversations: Array = [ { id: 'fred-convo', isSelected: false, @@ -35,7 +35,7 @@ const defaultConversations: Array = [ }, ]; -const defaultArchivedConversations: Array = [ +const defaultArchivedConversations: Array = [ { id: 'michelle-archive-convo', isSelected: false, @@ -46,7 +46,7 @@ const defaultArchivedConversations: Array = [ }, ]; -const pinnedConversations: Array = [ +const pinnedConversations: Array = [ { id: 'philly-convo', isPinned: true, @@ -67,107 +67,311 @@ const pinnedConversations: Array = [ }, ]; +const defaultModeSpecificProps = { + mode: LeftPaneMode.Inbox as const, + pinnedConversations, + conversations: defaultConversations, + archivedConversations: defaultArchivedConversations, +}; + +const emptySearchResultsGroup = { isLoading: false, results: [] }; + const createProps = (overrideProps: Partial = {}): PropsType => ({ - archivedConversations: - overrideProps.archivedConversations || defaultArchivedConversations, - conversations: overrideProps.conversations || defaultConversations, i18n, + modeSpecificProps: defaultModeSpecificProps, openConversationInternal: action('openConversationInternal'), - pinnedConversations: overrideProps.pinnedConversations || [], + regionCode: 'US', renderExpiredBuildDialog: () =>
, renderMainHeader: () =>
, - renderMessageSearchResult: () =>
, + renderMessageSearchResult: (id: string, style: React.CSSProperties) => ( + + ), renderNetworkStatus: () =>
, renderRelinkDialog: () =>
, renderUpdateDialog: () =>
, - searchResults: overrideProps.searchResults, - selectedConversationId: text( - 'selectedConversationId', - overrideProps.selectedConversationId || null - ), - showArchived: boolean('showArchived', overrideProps.showArchived || false), + selectedConversationId: undefined, + selectedMessageId: undefined, + setComposeSearchTerm: action('setComposeSearchTerm'), showArchivedConversations: action('showArchivedConversations'), showInbox: action('showInbox'), - startNewConversation: action('startNewConversation'), + startComposing: action('startComposing'), + startNewConversationFromPhoneNumber: action( + 'startNewConversationFromPhoneNumber' + ), + + ...overrideProps, }); -story.add('Conversation States (Active, Selected, Archived)', () => { - const props = createProps(); +// Inbox stories - return ; -}); +story.add('Inbox: no conversations', () => ( + +)); -story.add('Pinned and Non-pinned Conversations', () => { - const props = createProps({ - pinnedConversations, - }); +story.add('Inbox: only pinned conversations', () => ( + +)); - return ; -}); +story.add('Inbox: only non-pinned conversations', () => ( + +)); -story.add('Only Pinned Conversations', () => { - const props = createProps({ - archivedConversations: [], - conversations: [], - pinnedConversations, - }); +story.add('Inbox: only archived conversations', () => ( + +)); - return ; -}); +story.add('Inbox: pinned and archived conversations', () => ( + +)); -story.add('Archived Conversations Shown', () => { - const props = createProps({ - showArchived: true, - }); - return ; -}); +story.add('Inbox: non-pinned and archived conversations', () => ( + +)); -story.add('Search Results', () => { - const props = createProps({ - searchResults: { - discussionsLoading: false, - items: [ - { - type: 'conversations-header', - data: undefined, +story.add('Inbox: pinned and non-pinned conversations', () => ( + +)); + +story.add('Inbox: pinned, non-pinned, and archived conversations', () => ( + +)); + +// Search stories + +story.add('Search: no results when searching everywhere', () => ( + +)); + +story.add('Search: no results when searching in a conversation', () => ( + +)); + +story.add('Search: all results loading', () => ( + +)); + +story.add('Search: some results loading', () => ( + +)); - return ; -}); +story.add('Search: has conversations and contacts, but not messages', () => ( + +)); + +story.add('Search: all results', () => ( + +)); + +// Archived stories + +story.add('Archive: no archived conversations', () => ( + +)); + +story.add('Archive: archived conversations', () => ( + +)); + +// Compose stories + +story.add('Compose: no contacts', () => ( + +)); + +story.add('Compose: some contacts, no search term', () => ( + +)); + +story.add('Compose: some contacts with a search term', () => ( + +)); diff --git a/ts/components/LeftPane.tsx b/ts/components/LeftPane.tsx index f7b3d4d008..5c3a319f3f 100644 --- a/ts/components/LeftPane.tsx +++ b/ts/components/LeftPane.tsx @@ -1,649 +1,327 @@ -// Copyright 2019-2020 Signal Messenger, LLC +// Copyright 2019-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import Measure, { BoundingRect, MeasuredComponentProps } from 'react-measure'; -import React, { CSSProperties } from 'react'; -import { List } from 'react-virtualized'; -import { debounce, get } from 'lodash'; +import React, { useRef, useEffect, useMemo, CSSProperties } from 'react'; +import Measure, { MeasuredComponentProps } from 'react-measure'; +import { isNumber } from 'lodash'; import { - ConversationListItem, - PropsData as ConversationListItemPropsType, -} from './ConversationListItem'; + LeftPaneHelper, + FindDirection, + ToFindType, +} from './leftPane/LeftPaneHelper'; import { - PropsDataType as SearchResultsProps, - SearchResults, -} from './SearchResults'; + LeftPaneInboxHelper, + LeftPaneInboxPropsType, +} from './leftPane/LeftPaneInboxHelper'; +import { + LeftPaneSearchHelper, + LeftPaneSearchPropsType, +} from './leftPane/LeftPaneSearchHelper'; +import { + LeftPaneArchiveHelper, + LeftPaneArchivePropsType, +} from './leftPane/LeftPaneArchiveHelper'; +import { + LeftPaneComposeHelper, + LeftPaneComposePropsType, +} from './leftPane/LeftPaneComposeHelper'; + +import * as OS from '../OS'; import { LocalizerType } from '../types/Util'; -import { cleanId } from './_util'; +import { missingCaseError } from '../util/missingCaseError'; + +import { ConversationList } from './ConversationList'; + +export enum LeftPaneMode { + Inbox, + Search, + Archive, + Compose, +} export type PropsType = { - conversations?: Array; - archivedConversations?: Array; - pinnedConversations?: Array; - selectedConversationId?: string; - searchResults?: SearchResultsProps; - showArchived?: boolean; - + // These help prevent invalid states. For example, we don't need the list of pinned + // conversations if we're trying to start a new conversation. Ideally these would be + // at the top level, but this is not supported by react-redux + TypeScript. + modeSpecificProps: + | ({ + mode: LeftPaneMode.Inbox; + } & LeftPaneInboxPropsType) + | ({ + mode: LeftPaneMode.Search; + } & LeftPaneSearchPropsType) + | ({ + mode: LeftPaneMode.Archive; + } & LeftPaneArchivePropsType) + | ({ + mode: LeftPaneMode.Compose; + } & LeftPaneComposePropsType); i18n: LocalizerType; + selectedConversationId: undefined | string; + selectedMessageId: undefined | string; + regionCode: string; // Action Creators - startNewConversation: ( - query: string, - options: { regionCode: string } - ) => void; - openConversationInternal: (id: string, messageId?: string) => void; + startNewConversationFromPhoneNumber: (e164: string) => void; + openConversationInternal: (_: { + conversationId: string; + messageId?: string; + switchToAssociatedView?: boolean; + }) => void; showArchivedConversations: () => void; showInbox: () => void; + startComposing: () => void; + setComposeSearchTerm: (composeSearchTerm: string) => void; // Render Props renderExpiredBuildDialog: () => JSX.Element; renderMainHeader: () => JSX.Element; - renderMessageSearchResult: (id: string) => JSX.Element; + renderMessageSearchResult: (id: string, style: CSSProperties) => JSX.Element; renderNetworkStatus: () => JSX.Element; renderRelinkDialog: () => JSX.Element; renderUpdateDialog: () => JSX.Element; }; -// from https://github.com/bvaughn/react-virtualized/blob/fb3484ed5dcc41bffae8eab029126c0fb8f7abc0/source/List/types.js#L5 -type RowRendererParamsType = { - index: number; - isScrolling: boolean; - isVisible: boolean; - key: string; - parent: Record; - style: CSSProperties; -}; +export const LeftPane: React.FC = ({ + i18n, + modeSpecificProps, + openConversationInternal, + renderExpiredBuildDialog, + renderMainHeader, + renderMessageSearchResult, + renderNetworkStatus, + renderRelinkDialog, + renderUpdateDialog, + selectedConversationId, + selectedMessageId, + setComposeSearchTerm, + showArchivedConversations, + showInbox, + startComposing, + startNewConversationFromPhoneNumber, +}) => { + const previousModeSpecificPropsRef = useRef(modeSpecificProps); + const previousModeSpecificProps = previousModeSpecificPropsRef.current; + previousModeSpecificPropsRef.current = modeSpecificProps; -export enum RowType { - ArchiveButton, - ArchivedConversation, - Conversation, - Header, - PinnedConversation, - Undefined, -} - -export enum HeaderType { - Pinned, - Chats, -} - -type ArchiveButtonRow = { - type: RowType.ArchiveButton; -}; - -type ConversationRow = { - index: number; - type: - | RowType.ArchivedConversation - | RowType.Conversation - | RowType.PinnedConversation; -}; - -type HeaderRow = { - headerType: HeaderType; - type: RowType.Header; -}; - -type UndefinedRow = { - type: RowType.Undefined; -}; - -type Row = ArchiveButtonRow | ConversationRow | HeaderRow | UndefinedRow; - -export class LeftPane extends React.Component { - public listRef = React.createRef(); - - public containerRef = React.createRef(); - - public setFocusToFirstNeeded = false; - - public setFocusToLastNeeded = false; - - public calculateRowHeight = ({ index }: { index: number }): number => { - const { type } = this.getRowFromIndex(index); - return type === RowType.Header ? 40 : 68; - }; - - public getRowFromIndex = (index: number): Row => { - const { - archivedConversations, - conversations, - pinnedConversations, - showArchived, - } = this.props; - - if (!conversations || !pinnedConversations || !archivedConversations) { - return { - type: RowType.Undefined, - }; + // The left pane can be in various modes: the inbox, the archive, the composer, etc. + // Ideally, this would render subcomponents such as `` or + // `` (and if there's a way to do that cleanly, we should refactor + // this). + // + // But doing that presents two problems: + // + // 1. Different components render the same logical inputs (the main header's search), + // but React doesn't know that they're the same, so you can lose focus as you change + // modes. + // 2. These components render virtualized lists, which are somewhat slow to initialize. + // Switching between modes can cause noticable hiccups. + // + // To get around those problems, we use "helpers" which all correspond to the same + // interface. + // + // Unfortunately, there's a little bit of repetition here because TypeScript isn't quite + // smart enough. + let helper: LeftPaneHelper; + let shouldRecomputeRowHeights: boolean; + switch (modeSpecificProps.mode) { + case LeftPaneMode.Inbox: { + const inboxHelper = new LeftPaneInboxHelper(modeSpecificProps); + shouldRecomputeRowHeights = + previousModeSpecificProps.mode === modeSpecificProps.mode + ? inboxHelper.shouldRecomputeRowHeights(previousModeSpecificProps) + : true; + helper = inboxHelper; + break; } - - if (showArchived) { - return { - index, - type: RowType.ArchivedConversation, - }; + case LeftPaneMode.Search: { + const searchHelper = new LeftPaneSearchHelper(modeSpecificProps); + shouldRecomputeRowHeights = + previousModeSpecificProps.mode === modeSpecificProps.mode + ? searchHelper.shouldRecomputeRowHeights(previousModeSpecificProps) + : true; + helper = searchHelper; + break; } - - let conversationIndex = index; - - if (pinnedConversations.length) { - if (conversations.length) { - if (index === 0) { - return { - headerType: HeaderType.Pinned, - type: RowType.Header, - }; - } - - if (index <= pinnedConversations.length) { - return { - index: index - 1, - type: RowType.PinnedConversation, - }; - } - - if (index === pinnedConversations.length + 1) { - return { - headerType: HeaderType.Chats, - type: RowType.Header, - }; - } - - conversationIndex -= pinnedConversations.length + 2; - } else if (index < pinnedConversations.length) { - return { - index, - type: RowType.PinnedConversation, - }; - } else { - conversationIndex = 0; - } + case LeftPaneMode.Archive: { + const archiveHelper = new LeftPaneArchiveHelper(modeSpecificProps); + shouldRecomputeRowHeights = + previousModeSpecificProps.mode === modeSpecificProps.mode + ? archiveHelper.shouldRecomputeRowHeights(previousModeSpecificProps) + : true; + helper = archiveHelper; + break; } - - if (conversationIndex === conversations.length) { - return { - type: RowType.ArchiveButton, - }; + case LeftPaneMode.Compose: { + const composeHelper = new LeftPaneComposeHelper(modeSpecificProps); + shouldRecomputeRowHeights = + previousModeSpecificProps.mode === modeSpecificProps.mode + ? composeHelper.shouldRecomputeRowHeights(previousModeSpecificProps) + : true; + helper = composeHelper; + break; } - - return { - index: conversationIndex, - type: RowType.Conversation, - }; - }; - - public renderConversationRow( - conversation: ConversationListItemPropsType, - key: string, - style: CSSProperties - ): JSX.Element { - const { i18n, openConversationInternal } = this.props; - - return ( -
- -
- ); + default: + throw missingCaseError(modeSpecificProps); } - public renderHeaderRow = ( - index: number, - key: string, - style: CSSProperties - ): JSX.Element => { - const { i18n } = this.props; + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + const { ctrlKey, shiftKey, altKey, metaKey, key } = event; + const commandOrCtrl = OS.isMacOS() ? metaKey : ctrlKey; - switch (index) { - case HeaderType.Pinned: { - return ( -
- {i18n('LeftPane--pinned')} -
+ if ( + commandOrCtrl && + !shiftKey && + !altKey && + (key === 'n' || key === 'N') + ) { + startComposing(); + + event.preventDefault(); + event.stopPropagation(); + return; + } + + let conversationToOpen: + | undefined + | { + conversationId: string; + messageId?: string; + }; + + const numericIndex = keyboardKeyToNumericIndex(event.key); + if (commandOrCtrl && isNumber(numericIndex)) { + conversationToOpen = helper.getConversationAndMessageAtIndex( + numericIndex ); - } - case HeaderType.Chats: { - return ( -
- {i18n('LeftPane--chats')} -
- ); - } - default: { - window.log.warn('LeftPane: invalid HeaderRowIndex received'); - return <>; - } - } - }; - - public renderRow = ({ - index, - key, - style, - }: RowRendererParamsType): JSX.Element => { - const { - archivedConversations, - conversations, - pinnedConversations, - } = this.props; - - if (!conversations || !pinnedConversations || !archivedConversations) { - throw new Error( - 'renderRow: Tried to render without conversations or pinnedConversations or archivedConversations' - ); - } - - const row = this.getRowFromIndex(index); - - switch (row.type) { - case RowType.ArchiveButton: { - return this.renderArchivedButton(key, style); - } - case RowType.ArchivedConversation: { - return this.renderConversationRow( - archivedConversations[row.index], - key, - style - ); - } - case RowType.Conversation: { - return this.renderConversationRow(conversations[row.index], key, style); - } - case RowType.Header: { - return this.renderHeaderRow(row.headerType, key, style); - } - case RowType.PinnedConversation: { - return this.renderConversationRow( - pinnedConversations[row.index], - key, - style - ); - } - default: - window.log.warn('LeftPane: unknown RowType received'); - return <>; - } - }; - - public renderArchivedButton = ( - key: string, - style: CSSProperties - ): JSX.Element => { - const { - archivedConversations, - i18n, - showArchivedConversations, - } = this.props; - - if (!archivedConversations || !archivedConversations.length) { - throw new Error( - 'renderArchivedButton: Tried to render without archivedConversations' - ); - } - - return ( - - ); - }; - - public handleKeyDown = (event: React.KeyboardEvent): void => { - const commandKey = get(window, 'platform') === 'darwin' && event.metaKey; - const controlKey = get(window, 'platform') !== 'darwin' && event.ctrlKey; - const commandOrCtrl = commandKey || controlKey; - - if (commandOrCtrl && !event.shiftKey && event.key === 'ArrowUp') { - this.scrollToRow(0); - this.setFocusToFirstNeeded = true; - - event.preventDefault(); - event.stopPropagation(); - - return; - } - - if (commandOrCtrl && !event.shiftKey && event.key === 'ArrowDown') { - const length = this.getLength(); - this.scrollToRow(length - 1); - this.setFocusToLastNeeded = true; - - event.preventDefault(); - event.stopPropagation(); - } - }; - - public handleFocus = (): void => { - const { selectedConversationId } = this.props; - const { current: container } = this.containerRef; - - if (!container) { - return; - } - - if (document.activeElement === container) { - const scrollingContainer = this.getScrollContainer(); - if (selectedConversationId && scrollingContainer) { - const escapedId = cleanId(selectedConversationId).replace( - /["\\]/g, - '\\$&' - ); - const target: HTMLElement | null = scrollingContainer.querySelector( - `.module-conversation-list-item[data-id="${escapedId}"]` - ); - - if (target && target.focus) { - target.focus(); - - return; + } else { + let toFind: undefined | ToFindType; + if ( + (altKey && !shiftKey && key === 'ArrowUp') || + (commandOrCtrl && shiftKey && key === '[') || + (ctrlKey && shiftKey && key === 'Tab') + ) { + toFind = { direction: FindDirection.Up, unreadOnly: false }; + } else if ( + (altKey && !shiftKey && key === 'ArrowDown') || + (commandOrCtrl && shiftKey && key === ']') || + (ctrlKey && key === 'Tab') + ) { + toFind = { direction: FindDirection.Down, unreadOnly: false }; + } else if (altKey && shiftKey && key === 'ArrowUp') { + toFind = { direction: FindDirection.Up, unreadOnly: true }; + } else if (altKey && shiftKey && key === 'ArrowDown') { + toFind = { direction: FindDirection.Down, unreadOnly: true }; + } + if (toFind) { + conversationToOpen = helper.getConversationAndMessageInDirection( + toFind, + selectedConversationId, + selectedMessageId + ); } } - this.setFocusToFirst(); - } - }; - - public scrollToRow = (row: number): void => { - if (!this.listRef || !this.listRef.current) { - return; - } - - this.listRef.current.scrollToRow(row); - }; - - public recomputeRowHeights = (): void => { - if (!this.listRef || !this.listRef.current) { - return; - } - - this.listRef.current.recomputeRowHeights(); - }; - - public getScrollContainer = (): HTMLDivElement | null => { - if (!this.listRef || !this.listRef.current) { - return null; - } - - const list = this.listRef.current; - - // TODO: DESKTOP-689 - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const grid: any = list.Grid; - if (!grid || !grid._scrollingContainer) { - return null; - } - - return grid._scrollingContainer as HTMLDivElement; - }; - - public setFocusToFirst = (): void => { - const scrollContainer = this.getScrollContainer(); - if (!scrollContainer) { - return; - } - - const item: HTMLElement | null = scrollContainer.querySelector( - '.module-conversation-list-item' - ); - if (item && item.focus) { - item.focus(); - } - }; - - public onScroll = debounce( - (): void => { - if (this.setFocusToFirstNeeded) { - this.setFocusToFirstNeeded = false; - this.setFocusToFirst(); + if (conversationToOpen) { + const { conversationId, messageId } = conversationToOpen; + openConversationInternal({ conversationId, messageId }); + event.preventDefault(); + event.stopPropagation(); } - if (this.setFocusToLastNeeded) { - this.setFocusToLastNeeded = false; + }; - const scrollContainer = this.getScrollContainer(); - if (!scrollContainer) { - return; - } + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('keydown', onKeyDown); + }; + }, [ + helper, + openConversationInternal, + selectedConversationId, + selectedMessageId, + startComposing, + ]); - const button: HTMLElement | null = scrollContainer.querySelector( - '.module-left-pane__archived-button' - ); - if (button && button.focus) { - button.focus(); - - return; - } - const items: NodeListOf = scrollContainer.querySelectorAll( - '.module-conversation-list-item' - ); - if (items && items.length > 0) { - const last = items[items.length - 1]; - - if (last && last.focus) { - last.focus(); - } - } - } + const preRowsNode = helper.getPreRowsNode({ + i18n, + onChangeComposeSearchTerm: event => { + setComposeSearchTerm(event.target.value); }, - 100, - { maxWait: 100 } - ); + }); + const getRow = useMemo(() => helper.getRow.bind(helper), [helper]); - public getLength = (): number => { - const { - archivedConversations, - conversations, - pinnedConversations, - showArchived, - } = this.props; + // We ensure that the listKey differs between some modes (e.g. inbox/archived), ensuring + // that AutoSizer properly detects the new size of its slot in the flexbox. The + // archive explainer text at the top of the archive view causes problems otherwise. + // It also ensures that we scroll to the top when switching views. + const listKey = preRowsNode ? 1 : 0; - if (!conversations || !archivedConversations || !pinnedConversations) { - return 0; - } - - if (showArchived) { - return archivedConversations.length; - } - - let { length } = conversations; - - if (pinnedConversations.length) { - if (length) { - // includes two additional rows for pinned/chats headers - length += 2; - } - length += pinnedConversations.length; - } - - // includes one additional row for 'archived conversations' button - if (archivedConversations.length) { - length += 1; - } - - return length; - }; - - public renderList = ({ - height, - width, - }: BoundingRect): JSX.Element | Array => { - const { - archivedConversations, - i18n, - conversations, - openConversationInternal, - pinnedConversations, - renderMessageSearchResult, - startNewConversation, - searchResults, - showArchived, - } = this.props; - - if (searchResults) { - return ( - - ); - } - - if (!conversations || !archivedConversations || !pinnedConversations) { - throw new Error( - 'render: must provided conversations and archivedConverstions if no search results are provided' - ); - } - - const length = this.getLength(); - - // We ensure that the listKey differs between inbox and archive views, which ensures - // that AutoSizer properly detects the new size of its slot in the flexbox. The - // archive explainer text at the top of the archive view causes problems otherwise. - // It also ensures that we scroll to the top when switching views. - const listKey = showArchived ? 1 : 0; - - // Note: conversations is not a known prop for List, but it is required to ensure that - // it re-renders when our conversation data changes. Otherwise it would just render - // on startup and scroll. - return ( -
- + return ( +
+
+ {helper.getHeaderContents({ i18n, showInbox }) || renderMainHeader()}
- ); - }; - - public renderArchivedHeader = (): JSX.Element => { - const { i18n, showInbox } = this.props; - - return ( -
-
- ); - }; - - public render(): JSX.Element { - const { - i18n, - renderExpiredBuildDialog, - renderMainHeader, - renderNetworkStatus, - renderRelinkDialog, - renderUpdateDialog, - showArchived, - } = this.props; - - // Relying on 3rd party code for contentRect.bounds - /* eslint-disable @typescript-eslint/no-non-null-assertion */ - return ( -
-
- {showArchived ? this.renderArchivedHeader() : renderMainHeader()} -
- {renderExpiredBuildDialog()} - {renderRelinkDialog()} - {renderNetworkStatus()} - {renderUpdateDialog()} - {showArchived && ( -
- {i18n('archiveHelperText')} -
- )} - - {({ contentRect, measureRef }: MeasuredComponentProps) => ( -
-
- {this.renderList(contentRect.bounds!)} + {renderExpiredBuildDialog()} + {renderRelinkDialog()} + {helper.shouldRenderNetworkStatusAndUpdateDialog() && ( + <> + {renderNetworkStatus()} + {renderUpdateDialog()} + + )} + {preRowsNode && {preRowsNode}} + + {({ contentRect, measureRef }: MeasuredComponentProps) => ( +
+
+
+ { + openConversationInternal({ + conversationId, + messageId, + switchToAssociatedView: true, + }); + }} + renderMessageSearchResult={renderMessageSearchResult} + rowCount={helper.getRowCount()} + scrollToRowIndex={helper.getRowIndexToScrollTo( + selectedConversationId + )} + shouldRecomputeRowHeights={shouldRecomputeRowHeights} + startNewConversationFromPhoneNumber={ + startNewConversationFromPhoneNumber + } + />
- )} - -
- ); - } - - componentDidUpdate(oldProps: PropsType): void { - const { - conversations: oldConversations = [], - pinnedConversations: oldPinnedConversations = [], - archivedConversations: oldArchivedConversations = [], - showArchived: oldShowArchived, - } = oldProps; - const { - conversations: newConversations = [], - pinnedConversations: newPinnedConversations = [], - archivedConversations: newArchivedConversations = [], - showArchived: newShowArchived, - } = this.props; - - const oldHasArchivedConversations = Boolean( - oldArchivedConversations.length - ); - const newHasArchivedConversations = Boolean( - newArchivedConversations.length - ); - - // This could probably be optimized further, but we want to be extra-careful that our - // heights are correct. - if ( - oldConversations.length !== newConversations.length || - oldPinnedConversations.length !== newPinnedConversations.length || - oldHasArchivedConversations !== newHasArchivedConversations || - oldShowArchived !== newShowArchived - ) { - this.recomputeRowHeights(); - } +
+ )} + +
+ ); +}; + +function keyboardKeyToNumericIndex(key: string): undefined | number { + if (key.length !== 1) { + return undefined; } + const result = parseInt(key, 10) - 1; + const isValidIndex = Number.isInteger(result) && result >= 0 && result <= 8; + return isValidIndex ? result : undefined; } diff --git a/ts/components/MainHeader.stories.tsx b/ts/components/MainHeader.stories.tsx index 42bae14499..22f8d5d0d0 100644 --- a/ts/components/MainHeader.stories.tsx +++ b/ts/components/MainHeader.stories.tsx @@ -57,6 +57,7 @@ const createProps = (overrideProps: Partial = {}): PropsType => ({ clearSearch: action('clearSearch'), showArchivedConversations: action('showArchivedConversations'), + startComposing: action('startComposing'), }); story.add('Basic', () => { diff --git a/ts/components/MainHeader.tsx b/ts/components/MainHeader.tsx index e0c7198551..f88550dd06 100644 --- a/ts/components/MainHeader.tsx +++ b/ts/components/MainHeader.tsx @@ -62,6 +62,7 @@ export type PropsType = { clearSearch: () => void; showArchivedConversations: () => void; + startComposing: () => void; }; type StateType = { @@ -340,6 +341,7 @@ export class MainHeader extends React.Component { color, i18n, name, + startComposing, phoneNumber, profileName, title, @@ -354,6 +356,10 @@ export class MainHeader extends React.Component { ? i18n('searchIn', [searchConversationName]) : i18n('search'); + const isSearching = Boolean( + searchConversationId || searchTerm.trim().length + ); + return (
@@ -456,6 +462,15 @@ export class MainHeader extends React.Component { /> ) : null}
+ {!isSearching && ( +
); } diff --git a/ts/components/MessageSearchResult.tsx b/ts/components/MessageSearchResult.tsx deleted file mode 100644 index 800dc2196b..0000000000 --- a/ts/components/MessageSearchResult.tsx +++ /dev/null @@ -1,182 +0,0 @@ -// Copyright 2019-2020 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import React from 'react'; -import classNames from 'classnames'; - -import { Avatar } from './Avatar'; -import { MessageBodyHighlight } from './MessageBodyHighlight'; -import { Timestamp } from './conversation/Timestamp'; -import { ContactName } from './conversation/ContactName'; - -import { LocalizerType } from '../types/Util'; -import { ColorType } from '../types/Colors'; - -export type PropsDataType = { - isSelected?: boolean; - isSearchingInConversation?: boolean; - - id: string; - conversationId: string; - sentAt?: number; - - snippet: string; - - from: { - phoneNumber?: string; - title: string; - isMe?: boolean; - name?: string; - color?: ColorType; - profileName?: string; - avatarPath?: string; - }; - - to: { - groupName?: string; - phoneNumber?: string; - title: string; - isMe?: boolean; - name?: string; - profileName?: string; - }; -}; - -type PropsHousekeepingType = { - i18n: LocalizerType; - openConversationInternal: ( - conversationId: string, - messageId?: string - ) => void; -}; - -export type PropsType = PropsDataType & PropsHousekeepingType; - -export class MessageSearchResult extends React.PureComponent { - public renderFromName(): JSX.Element { - const { from, i18n, to } = this.props; - - if (from.isMe && to.isMe) { - return ( - - {i18n('noteToSelf')} - - ); - } - if (from.isMe) { - return ( - - {i18n('you')} - - ); - } - - return ( - - ); - } - - public renderFrom(): JSX.Element { - const { i18n, to, isSearchingInConversation } = this.props; - const fromName = this.renderFromName(); - - if (!to.isMe && !isSearchingInConversation) { - return ( -
- {fromName} {i18n('toJoiner')}{' '} - - - -
- ); - } - - return ( -
- {fromName} -
- ); - } - - public renderAvatar(): JSX.Element { - const { from, i18n, to } = this.props; - const isNoteToSelf = from.isMe && to.isMe; - - return ( - - ); - } - - public render(): JSX.Element | null { - const { - from, - i18n, - id, - isSelected, - conversationId, - openConversationInternal, - sentAt, - snippet, - to, - } = this.props; - - if (!from || !to) { - return null; - } - - return ( - - ); - } -} diff --git a/ts/components/SearchResults.stories.tsx b/ts/components/SearchResults.stories.tsx deleted file mode 100644 index cfb9b972a0..0000000000 --- a/ts/components/SearchResults.stories.tsx +++ /dev/null @@ -1,423 +0,0 @@ -// Copyright 2020 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import * as React from 'react'; -import { storiesOf } from '@storybook/react'; -import { action } from '@storybook/addon-actions'; - -import { SearchResults } from './SearchResults'; -import { - MessageSearchResult, - PropsDataType as MessageSearchResultPropsType, -} from './MessageSearchResult'; -import { setup as setupI18n } from '../../js/modules/i18n'; -import enMessages from '../../_locales/en/messages.json'; -import { - gifUrl, - landscapeGreenUrl, - landscapePurpleUrl, - pngUrl, -} from '../storybook/Fixtures'; - -const i18n = setupI18n('en', enMessages); - -const messageLookup: Map = new Map(); - -const CONTACT = 'contact' as const; -const CONTACTS_HEADER = 'contacts-header' as const; -const CONVERSATION = 'conversation' as const; -const CONVERSATIONS_HEADER = 'conversations-header' as const; -const DIRECT = 'direct' as const; -const GROUP = 'group' as const; -const MESSAGE = 'message' as const; -const MESSAGES_HEADER = 'messages-header' as const; -const SENT = 'sent' as const; -const START_NEW_CONVERSATION = 'start-new-conversation' as const; -const SMS_MMS_NOT_SUPPORTED = 'sms-mms-not-supported-text' as const; - -messageLookup.set('1-guid-guid-guid-guid-guid', { - id: '1-guid-guid-guid-guid-guid', - conversationId: '(202) 555-0015', - sentAt: Date.now() - 5 * 60 * 1000, - snippet: '<>Everyone<>! Get in!', - - from: { - phoneNumber: '(202) 555-0020', - title: '(202) 555-0020', - isMe: true, - color: 'blue', - avatarPath: gifUrl, - }, - to: { - phoneNumber: '(202) 555-0015', - title: 'Mr. Fire 🔥', - name: 'Mr. Fire 🔥', - }, -}); - -messageLookup.set('2-guid-guid-guid-guid-guid', { - id: '2-guid-guid-guid-guid-guid', - conversationId: '(202) 555-0016', - sentAt: Date.now() - 20 * 60 * 1000, - snippet: 'Why is <>everyone<> so frustrated?', - from: { - phoneNumber: '(202) 555-0016', - name: 'Jon ❄️', - title: 'Jon ❄️', - color: 'green', - }, - to: { - phoneNumber: '(202) 555-0020', - title: '(202) 555-0020', - isMe: true, - }, -}); - -messageLookup.set('3-guid-guid-guid-guid-guid', { - id: '3-guid-guid-guid-guid-guid', - conversationId: 'EveryoneGroupID', - sentAt: Date.now() - 24 * 60 * 1000, - snippet: 'Hello, <>everyone<>! Woohooo!', - from: { - phoneNumber: '(202) 555-0011', - name: 'Someone', - title: 'Someone', - color: 'green', - avatarPath: pngUrl, - }, - to: { - phoneNumber: '(202) 555-0016', - name: "Y'all 🌆", - title: "Y'all 🌆", - }, -}); - -messageLookup.set('4-guid-guid-guid-guid-guid', { - id: '4-guid-guid-guid-guid-guid', - conversationId: 'EveryoneGroupID', - sentAt: Date.now() - 24 * 60 * 1000, - snippet: 'Well, <>everyone<>, happy new year!', - from: { - phoneNumber: '(202) 555-0020', - title: '(202) 555-0020', - isMe: true, - color: 'light_green', - avatarPath: gifUrl, - }, - to: { - phoneNumber: '(202) 555-0016', - name: "Y'all 🌆", - title: "Y'all 🌆", - }, -}); - -const defaultProps = { - discussionsLoading: false, - height: 700, - items: [], - i18n, - messagesLoading: false, - noResults: false, - openConversationInternal: action('open-conversation-internal'), - regionCode: 'US', - renderMessageSearchResult(id: string): JSX.Element { - const messageProps = messageLookup.get(id) as MessageSearchResultPropsType; - - return ( - - ); - }, - searchConversationName: undefined, - searchTerm: '1234567890', - selectedConversationId: undefined, - selectedMessageId: undefined, - startNewConversation: action('start-new-conversation'), - width: 320, -}; - -const conversations = [ - { - type: CONVERSATION, - data: { - id: '+12025550011', - phoneNumber: '(202) 555-0011', - name: 'Everyone 🌆', - title: 'Everyone 🌆', - type: GROUP, - color: 'signal-blue' as const, - avatarPath: landscapeGreenUrl, - isMe: false, - lastUpdated: Date.now() - 5 * 60 * 1000, - unreadCount: 0, - isSelected: false, - lastMessage: { - text: 'The rabbit hopped silently in the night.', - status: SENT, - }, - markedUnread: false, - }, - }, - { - type: CONVERSATION, - data: { - id: '+12025550012', - phoneNumber: '(202) 555-0012', - name: 'Everyone Else 🔥', - title: 'Everyone Else 🔥', - color: 'pink' as const, - type: DIRECT, - avatarPath: landscapePurpleUrl, - isMe: false, - lastUpdated: Date.now() - 5 * 60 * 1000, - unreadCount: 0, - isSelected: false, - lastMessage: { - text: "What's going on?", - status: SENT, - }, - markedUnread: false, - }, - }, -]; - -const contacts = [ - { - type: CONTACT, - data: { - id: '+12025550013', - phoneNumber: '(202) 555-0013', - name: 'The one Everyone', - title: 'The one Everyone', - color: 'blue' as const, - type: DIRECT, - avatarPath: gifUrl, - isMe: false, - lastUpdated: Date.now() - 10 * 60 * 1000, - unreadCount: 0, - isSelected: false, - markedUnread: false, - }, - }, - { - type: CONTACT, - data: { - id: '+12025550014', - phoneNumber: '(202) 555-0014', - name: 'No likey everyone', - title: 'No likey everyone', - type: DIRECT, - color: 'red' as const, - isMe: false, - lastUpdated: Date.now() - 11 * 60 * 1000, - unreadCount: 0, - isSelected: false, - markedUnread: false, - }, - }, -]; - -const messages = [ - { - type: MESSAGE, - data: '1-guid-guid-guid-guid-guid', - }, - { - type: MESSAGE, - data: '2-guid-guid-guid-guid-guid', - }, - { - type: MESSAGE, - data: '3-guid-guid-guid-guid-guid', - }, - { - type: MESSAGE, - data: '4-guid-guid-guid-guid-guid', - }, -]; - -const messagesMany = Array.from(Array(100), (_, i) => messages[i % 4]); - -const permutations = [ - { - title: 'SMS/MMS Not Supported Text', - props: { - items: [ - { - type: START_NEW_CONVERSATION, - data: undefined, - }, - { - type: SMS_MMS_NOT_SUPPORTED, - data: undefined, - }, - ], - }, - }, - { - title: 'All Result Types', - props: { - items: [ - { - type: CONVERSATIONS_HEADER, - data: undefined, - }, - ...conversations, - { - type: CONTACTS_HEADER, - data: undefined, - }, - ...contacts, - { - type: MESSAGES_HEADER, - data: undefined, - }, - ...messages, - ], - }, - }, - { - title: 'Start new Conversation', - props: { - items: [ - { - type: START_NEW_CONVERSATION, - data: undefined, - }, - { - type: CONVERSATIONS_HEADER, - data: undefined, - }, - ...conversations, - { - type: CONTACTS_HEADER, - data: undefined, - }, - ...contacts, - { - type: MESSAGES_HEADER, - data: undefined, - }, - ...messages, - ], - }, - }, - { - title: 'No Conversations', - props: { - items: [ - { - type: CONTACTS_HEADER, - data: undefined, - }, - ...contacts, - { - type: MESSAGES_HEADER, - data: undefined, - }, - ...messages, - ], - }, - }, - { - title: 'No Contacts', - props: { - items: [ - { - type: CONVERSATIONS_HEADER, - data: undefined, - }, - ...conversations, - { - type: MESSAGES_HEADER, - data: undefined, - }, - ...messages, - ], - }, - }, - { - title: 'No Messages', - props: { - items: [ - { - type: CONVERSATIONS_HEADER, - data: undefined, - }, - ...conversations, - { - type: CONTACTS_HEADER, - data: undefined, - }, - ...contacts, - ], - }, - }, - { - title: 'No Results', - props: { - noResults: true, - }, - }, - { - title: 'No Results, Searching in Conversation', - props: { - noResults: true, - searchInConversationName: 'Everyone 🔥', - searchTerm: 'something', - }, - }, - { - title: 'Searching in Conversation no search term', - props: { - noResults: true, - searchInConversationName: 'Everyone 🔥', - searchTerm: '', - }, - }, - { - title: 'Lots of results', - props: { - items: [ - { - type: CONVERSATIONS_HEADER, - data: undefined, - }, - ...conversations, - { - type: CONTACTS_HEADER, - data: undefined, - }, - ...contacts, - { - type: MESSAGES_HEADER, - data: undefined, - }, - ...messagesMany, - ], - }, - }, - { - title: 'Messages, no header', - props: { - items: messages, - }, - }, -]; - -storiesOf('Components/SearchResults', module).add('Iterations', () => { - return permutations.map(({ props, title }) => ( - <> -

{title}

-
- -
-
- - )); -}); diff --git a/ts/components/SearchResults.tsx b/ts/components/SearchResults.tsx deleted file mode 100644 index 135e7a2c0b..0000000000 --- a/ts/components/SearchResults.tsx +++ /dev/null @@ -1,611 +0,0 @@ -// Copyright 2019-2020 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import React, { CSSProperties } from 'react'; -import { CellMeasurer, CellMeasurerCache, List } from 'react-virtualized'; -import { debounce, get, isNumber } from 'lodash'; - -import { Intl } from './Intl'; -import { Emojify } from './conversation/Emojify'; -import { Spinner } from './Spinner'; -import { - ConversationListItem, - PropsData as ConversationListItemPropsType, -} from './ConversationListItem'; -import { StartNewConversation } from './StartNewConversation'; -import { cleanId } from './_util'; - -import { LocalizerType } from '../types/Util'; - -export type PropsDataType = { - discussionsLoading: boolean; - items: Array; - messagesLoading: boolean; - noResults: boolean; - regionCode: string; - searchConversationName?: string; - searchTerm: string; - selectedConversationId?: string; - selectedMessageId?: string; -}; - -type StartNewConversationType = { - type: 'start-new-conversation'; - data: undefined; -}; -type NotSupportedSMS = { - type: 'sms-mms-not-supported-text'; - data: undefined; -}; -type ConversationHeaderType = { - type: 'conversations-header'; - data: undefined; -}; -type ContactsHeaderType = { - type: 'contacts-header'; - data: undefined; -}; -type MessagesHeaderType = { - type: 'messages-header'; - data: undefined; -}; -type ConversationType = { - type: 'conversation'; - data: ConversationListItemPropsType; -}; -type ContactsType = { - type: 'contact'; - data: ConversationListItemPropsType; -}; -type MessageType = { - type: 'message'; - data: string; -}; -type SpinnerType = { - type: 'spinner'; - data: undefined; -}; - -export type SearchResultRowType = - | StartNewConversationType - | NotSupportedSMS - | ConversationHeaderType - | ContactsHeaderType - | MessagesHeaderType - | ConversationType - | ContactsType - | MessageType - | SpinnerType; - -type PropsHousekeepingType = { - i18n: LocalizerType; - openConversationInternal: (id: string, messageId?: string) => void; - startNewConversation: ( - query: string, - options: { regionCode: string } - ) => void; - height: number; - width: number; - - renderMessageSearchResult: (id: string) => JSX.Element; -}; - -type PropsType = PropsDataType & PropsHousekeepingType; -type StateType = { - scrollToIndex?: number; -}; - -// from https://github.com/bvaughn/react-virtualized/blob/fb3484ed5dcc41bffae8eab029126c0fb8f7abc0/source/List/types.js#L5 -type RowRendererParamsType = { - index: number; - isScrolling: boolean; - isVisible: boolean; - key: string; - parent: Record; - style: CSSProperties; -}; -type OnScrollParamsType = { - scrollTop: number; - clientHeight: number; - scrollHeight: number; - - clientWidth: number; - scrollWidth?: number; - scrollLeft?: number; - scrollToColumn?: number; - _hasScrolledToColumnTarget?: boolean; - scrollToRow?: number; - _hasScrolledToRowTarget?: boolean; -}; - -export class SearchResults extends React.Component { - public setFocusToFirstNeeded = false; - - public setFocusToLastNeeded = false; - - public cellSizeCache = new CellMeasurerCache({ - defaultHeight: 80, - fixedWidth: true, - }); - - public listRef = React.createRef(); - - public containerRef = React.createRef(); - - constructor(props: PropsType) { - super(props); - this.state = { - scrollToIndex: undefined, - }; - } - - public handleStartNewConversation = (): void => { - const { regionCode, searchTerm, startNewConversation } = this.props; - - startNewConversation(searchTerm, { regionCode }); - }; - - public handleKeyDown = (event: React.KeyboardEvent): void => { - const { items } = this.props; - const commandKey = get(window, 'platform') === 'darwin' && event.metaKey; - const controlKey = get(window, 'platform') !== 'darwin' && event.ctrlKey; - const commandOrCtrl = commandKey || controlKey; - - if (!items || items.length < 1) { - return; - } - - if (commandOrCtrl && !event.shiftKey && event.key === 'ArrowUp') { - this.setState({ scrollToIndex: 0 }); - this.setFocusToFirstNeeded = true; - - event.preventDefault(); - event.stopPropagation(); - - return; - } - - if (commandOrCtrl && !event.shiftKey && event.key === 'ArrowDown') { - const lastIndex = items.length - 1; - this.setState({ scrollToIndex: lastIndex }); - this.setFocusToLastNeeded = true; - - event.preventDefault(); - event.stopPropagation(); - } - }; - - public handleFocus = (): void => { - const { selectedConversationId, selectedMessageId } = this.props; - const { current: container } = this.containerRef; - - if (!container) { - return; - } - - if (document.activeElement === container) { - const scrollingContainer = this.getScrollContainer(); - - // First we try to scroll to the selected message - if (selectedMessageId && scrollingContainer) { - const target: HTMLElement | null = scrollingContainer.querySelector( - `.module-message-search-result[data-id="${selectedMessageId}"]` - ); - - if (target && target.focus) { - target.focus(); - - return; - } - } - - // Then we try for the selected conversation - if (selectedConversationId && scrollingContainer) { - const escapedId = cleanId(selectedConversationId).replace( - /["\\]/g, - '\\$&' - ); - const target: HTMLElement | null = scrollingContainer.querySelector( - `.module-conversation-list-item[data-id="${escapedId}"]` - ); - - if (target && target.focus) { - target.focus(); - - return; - } - } - - // Otherwise we set focus to the first non-header item - this.setFocusToFirst(); - } - }; - - public setFocusToFirst = (): void => { - const { current: container } = this.containerRef; - - if (container) { - const noResultsItem: HTMLElement | null = container.querySelector( - '.module-search-results__no-results' - ); - if (noResultsItem && noResultsItem.focus) { - noResultsItem.focus(); - - return; - } - } - - const scrollContainer = this.getScrollContainer(); - if (!scrollContainer) { - return; - } - - const startItem: HTMLElement | null = scrollContainer.querySelector( - '.module-start-new-conversation' - ); - if (startItem && startItem.focus) { - startItem.focus(); - - return; - } - - const conversationItem: HTMLElement | null = scrollContainer.querySelector( - '.module-conversation-list-item' - ); - if (conversationItem && conversationItem.focus) { - conversationItem.focus(); - - return; - } - - const messageItem: HTMLElement | null = scrollContainer.querySelector( - '.module-message-search-result' - ); - if (messageItem && messageItem.focus) { - messageItem.focus(); - } - }; - - public getScrollContainer = (): HTMLDivElement | null => { - if (!this.listRef || !this.listRef.current) { - return null; - } - - const list = this.listRef.current; - - // We're using an internal variable (_scrollingContainer)) here, - // so cannot rely on the public type. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const grid: any = list.Grid; - if (!grid || !grid._scrollingContainer) { - return null; - } - - return grid._scrollingContainer as HTMLDivElement; - }; - - public onScroll = debounce( - (data: OnScrollParamsType) => { - // Ignore scroll events generated as react-virtualized recursively scrolls and - // re-measures to get us where we want to go. - if ( - isNumber(data.scrollToRow) && - data.scrollToRow >= 0 && - !data._hasScrolledToRowTarget - ) { - return; - } - - this.setState({ scrollToIndex: undefined }); - - if (this.setFocusToFirstNeeded) { - this.setFocusToFirstNeeded = false; - this.setFocusToFirst(); - } - - if (this.setFocusToLastNeeded) { - this.setFocusToLastNeeded = false; - - const scrollContainer = this.getScrollContainer(); - if (!scrollContainer) { - return; - } - - const messageItems: NodeListOf = scrollContainer.querySelectorAll( - '.module-message-search-result' - ); - if (messageItems && messageItems.length > 0) { - const last = messageItems[messageItems.length - 1]; - - if (last && last.focus) { - last.focus(); - - return; - } - } - - const contactItems: NodeListOf = scrollContainer.querySelectorAll( - '.module-conversation-list-item' - ); - if (contactItems && contactItems.length > 0) { - const last = contactItems[contactItems.length - 1]; - - if (last && last.focus) { - last.focus(); - - return; - } - } - - const startItem = scrollContainer.querySelectorAll( - '.module-start-new-conversation' - ) as NodeListOf; - if (startItem && startItem.length > 0) { - const last = startItem[startItem.length - 1]; - - if (last && last.focus) { - last.focus(); - } - } - } - }, - 100, - { maxWait: 100 } - ); - - public renderRowContents(row: SearchResultRowType): JSX.Element { - const { - searchTerm, - i18n, - openConversationInternal, - renderMessageSearchResult, - } = this.props; - - if (row.type === 'start-new-conversation') { - return ( - - ); - } - if (row.type === 'sms-mms-not-supported-text') { - return ( -
- {i18n('notSupportedSMS')} -
- ); - } - if (row.type === 'conversations-header') { - return ( -
- {i18n('conversationsHeader')} -
- ); - } - if (row.type === 'conversation') { - const { data } = row; - - return ( - - ); - } - if (row.type === 'contacts-header') { - return ( -
- {i18n('contactsHeader')} -
- ); - } - if (row.type === 'contact') { - const { data } = row; - - return ( - - ); - } - if (row.type === 'messages-header') { - return ( -
- {i18n('messagesHeader')} -
- ); - } - if (row.type === 'message') { - const { data } = row; - - return renderMessageSearchResult(data); - } - if (row.type === 'spinner') { - return ( -
- -
- ); - } - throw new Error( - 'SearchResults.renderRowContents: Encountered unknown row type' - ); - } - - public renderRow = ({ - index, - key, - parent, - style, - }: RowRendererParamsType): JSX.Element => { - const { items, width } = this.props; - - const row = items[index]; - - return ( -
- - {this.renderRowContents(row)} - -
- ); - }; - - public componentDidUpdate(prevProps: PropsType): void { - const { - items, - searchTerm, - discussionsLoading, - messagesLoading, - } = this.props; - - if (searchTerm !== prevProps.searchTerm) { - this.resizeAll(); - } else if ( - discussionsLoading !== prevProps.discussionsLoading || - messagesLoading !== prevProps.messagesLoading - ) { - this.resizeAll(); - } else if ( - items && - prevProps.items && - prevProps.items.length !== items.length - ) { - this.resizeAll(); - } - } - - public getList = (): List | null => { - if (!this.listRef) { - return null; - } - - const { current } = this.listRef; - - return current; - }; - - public recomputeRowHeights = (row?: number): void => { - const list = this.getList(); - if (!list) { - return; - } - - list.recomputeRowHeights(row); - }; - - public resizeAll = (): void => { - this.cellSizeCache.clearAll(); - this.recomputeRowHeights(0); - }; - - public getRowCount(): number { - const { items } = this.props; - - return items ? items.length : 0; - } - - public render(): JSX.Element { - const { - height, - i18n, - items, - noResults, - searchConversationName, - searchTerm, - width, - } = this.props; - const { scrollToIndex } = this.state; - - if (noResults) { - return ( -
- {!searchConversationName || searchTerm ? ( -
- {searchConversationName ? ( - - ), - }} - /> - ) : ( - i18n('noSearchResults', [searchTerm]) - )} -
- ) : null} -
- ); - } - - return ( -
- -
- ); - } -} diff --git a/ts/components/ShortcutGuide.tsx b/ts/components/ShortcutGuide.tsx index b1810ece09..4adf76dbff 100644 --- a/ts/components/ShortcutGuide.tsx +++ b/ts/components/ShortcutGuide.tsx @@ -1,4 +1,4 @@ -// Copyright 2019-2020 Signal Messenger, LLC +// Copyright 2019-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; @@ -32,6 +32,7 @@ type KeyType = | 'J' | 'L' | 'M' + | 'N' | 'P' | 'R' | 'S' @@ -84,6 +85,10 @@ const NAVIGATION_SHORTCUTS: Array = [ description: 'Keyboard--open-conversation-menu', keys: [['commandOrCtrl', 'shift', 'L']], }, + { + description: 'Keyboard--new-conversation', + keys: [['commandOrCtrl', 'N']], + }, { description: 'Keyboard--search', keys: [['commandOrCtrl', 'F']], diff --git a/ts/components/StartNewConversation.stories.tsx b/ts/components/StartNewConversation.stories.tsx deleted file mode 100644 index d6bc78fda5..0000000000 --- a/ts/components/StartNewConversation.stories.tsx +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright 2020 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import * as React from 'react'; - -import { storiesOf } from '@storybook/react'; -import { action } from '@storybook/addon-actions'; -import { text } from '@storybook/addon-knobs'; -import { Props, StartNewConversation } from './StartNewConversation'; - -import { setup as setupI18n } from '../../js/modules/i18n'; -import enMessages from '../../_locales/en/messages.json'; - -const i18n = setupI18n('en', enMessages); - -const createProps = (overrideProps: Partial = {}): Props => ({ - i18n, - onClick: action('onClick'), - phoneNumber: text('phoneNumber', overrideProps.phoneNumber || ''), -}); - -const stories = storiesOf('Components/StartNewConversation', module); - -stories.add('Full Phone Number', () => { - const props = createProps({ - phoneNumber: '(202) 555-0011', - }); - - return ; -}); - -stories.add('Partial Phone Number', () => { - const props = createProps({ - phoneNumber: '202', - }); - - return ; -}); diff --git a/ts/components/StartNewConversation.tsx b/ts/components/StartNewConversation.tsx deleted file mode 100644 index 34cc5947a4..0000000000 --- a/ts/components/StartNewConversation.tsx +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright 2019-2020 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import React from 'react'; - -import { Avatar } from './Avatar'; - -import { LocalizerType } from '../types/Util'; - -export type Props = { - phoneNumber: string; - i18n: LocalizerType; - onClick: () => void; -}; - -export class StartNewConversation extends React.PureComponent { - public render(): JSX.Element { - const { phoneNumber, i18n, onClick } = this.props; - - return ( - - ); - } -} diff --git a/ts/components/conversation/About.tsx b/ts/components/conversation/About.tsx index 779cded2ea..fb3ee855a7 100644 --- a/ts/components/conversation/About.tsx +++ b/ts/components/conversation/About.tsx @@ -1,4 +1,4 @@ -// Copyright 2018-2020 Signal Messenger, LLC +// Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React from 'react'; @@ -6,16 +6,20 @@ import React from 'react'; import { Emojify } from './Emojify'; export type PropsType = { + className?: string; text?: string; }; -export const About = ({ text }: PropsType): JSX.Element | null => { +export const About = ({ + className = 'module-about__text', + text, +}: PropsType): JSX.Element | null => { if (!text) { return null; } return ( - + ); diff --git a/ts/components/conversationList/BaseConversationListItem.tsx b/ts/components/conversationList/BaseConversationListItem.tsx new file mode 100644 index 0000000000..7307eb8f28 --- /dev/null +++ b/ts/components/conversationList/BaseConversationListItem.tsx @@ -0,0 +1,143 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { ReactNode, CSSProperties, FunctionComponent } from 'react'; +import classNames from 'classnames'; +import { isBoolean, isNumber } from 'lodash'; + +import { Avatar, AvatarSize } from '../Avatar'; +import { Timestamp } from '../conversation/Timestamp'; +import { isConversationUnread } from '../../util/isConversationUnread'; +import { cleanId } from '../_util'; +import { ColorType } from '../../types/Colors'; +import { LocalizerType } from '../../types/Util'; + +const BASE_CLASS_NAME = + 'module-conversation-list__item--contact-or-conversation'; +const CONTENT_CLASS_NAME = `${BASE_CLASS_NAME}__content`; +const HEADER_CLASS_NAME = `${CONTENT_CLASS_NAME}__header`; +export const DATE_CLASS_NAME = `${HEADER_CLASS_NAME}__date`; +const TIMESTAMP_CLASS_NAME = `${DATE_CLASS_NAME}__timestamp`; +export const MESSAGE_CLASS_NAME = `${CONTENT_CLASS_NAME}__message`; +export const MESSAGE_TEXT_CLASS_NAME = `${MESSAGE_CLASS_NAME}__text`; + +type PropsType = { + avatarPath?: string; + color?: ColorType; + conversationType: 'group' | 'direct'; + headerDate?: number; + headerName: ReactNode; + i18n: LocalizerType; + id?: string; + isMe?: boolean; + isNoteToSelf?: boolean; + isSelected: boolean; + markedUnread?: boolean; + messageId?: string; + messageStatusIcon?: ReactNode; + messageText?: ReactNode; + name?: string; + onClick: () => void; + phoneNumber?: string; + profileName?: string; + style: CSSProperties; + title: string; + unreadCount?: number; +}; + +export const BaseConversationListItem: FunctionComponent = React.memo( + ({ + avatarPath, + color, + conversationType, + headerDate, + headerName, + i18n, + id, + isMe, + isNoteToSelf, + isSelected, + markedUnread, + messageStatusIcon, + messageText, + name, + onClick, + phoneNumber, + profileName, + style, + title, + unreadCount, + }) => { + const isUnread = isConversationUnread({ markedUnread, unreadCount }); + + const isAvatarNoteToSelf = isBoolean(isNoteToSelf) + ? isNoteToSelf + : Boolean(isMe); + + return ( + + ); + } +); diff --git a/ts/components/conversationList/ContactListItem.tsx b/ts/components/conversationList/ContactListItem.tsx new file mode 100644 index 0000000000..7632ad6f65 --- /dev/null +++ b/ts/components/conversationList/ContactListItem.tsx @@ -0,0 +1,86 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { useCallback, CSSProperties, FunctionComponent } from 'react'; + +import { BaseConversationListItem } from './BaseConversationListItem'; +import { ColorType } from '../../types/Colors'; +import { LocalizerType } from '../../types/Util'; +import { ContactName } from '../conversation/ContactName'; +import { About } from '../conversation/About'; + +export type PropsDataType = { + about?: string; + avatarPath?: string; + color?: ColorType; + id: string; + isMe?: boolean; + name?: string; + phoneNumber?: string; + profileName?: string; + title: string; + type: 'group' | 'direct'; +}; + +type PropsHousekeepingType = { + i18n: LocalizerType; + style: CSSProperties; + onClick: (id: string) => void; +}; + +type PropsType = PropsDataType & PropsHousekeepingType; + +export const ContactListItem: FunctionComponent = React.memo( + ({ + about, + avatarPath, + color, + i18n, + id, + isMe, + name, + onClick, + phoneNumber, + profileName, + style, + title, + type, + }) => { + const headerName = isMe ? ( + i18n('noteToSelf') + ) : ( + + ); + + const messageText = + about && !isMe ? : null; + + const onClickItem = useCallback(() => onClick(id), [onClick, id]); + + return ( + + ); + } +); diff --git a/ts/components/conversationList/ConversationListItem.tsx b/ts/components/conversationList/ConversationListItem.tsx new file mode 100644 index 0000000000..86ac06dfdf --- /dev/null +++ b/ts/components/conversationList/ConversationListItem.tsx @@ -0,0 +1,206 @@ +// Copyright 2018-2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { + useCallback, + CSSProperties, + FunctionComponent, + ReactNode, +} from 'react'; +import classNames from 'classnames'; + +import { + BaseConversationListItem, + MESSAGE_CLASS_NAME, + MESSAGE_TEXT_CLASS_NAME, +} from './BaseConversationListItem'; +import { MessageBody } from '../conversation/MessageBody'; +import { ContactName } from '../conversation/ContactName'; +import { TypingAnimation } from '../conversation/TypingAnimation'; + +import { LocalizerType } from '../../types/Util'; +import { ColorType } from '../../types/Colors'; + +const MESSAGE_STATUS_ICON_CLASS_NAME = `${MESSAGE_TEXT_CLASS_NAME}__status-icon`; + +export const MessageStatuses = [ + 'sending', + 'sent', + 'delivered', + 'read', + 'error', + 'partial-sent', +] as const; + +export type MessageStatusType = typeof MessageStatuses[number]; + +export type PropsData = { + id: string; + phoneNumber?: string; + color?: ColorType; + profileName?: string; + title: string; + name?: string; + type: 'group' | 'direct'; + avatarPath?: string; + isMe?: boolean; + muteExpiresAt?: number; + + lastUpdated?: number; + unreadCount?: number; + markedUnread?: boolean; + isSelected?: boolean; + + acceptedMessageRequest?: boolean; + draftPreview?: string; + shouldShowDraft?: boolean; + + typingContact?: unknown; + lastMessage?: { + status: MessageStatusType; + text: string; + deletedForEveryone?: boolean; + }; + isPinned?: boolean; +}; + +type PropsHousekeeping = { + i18n: LocalizerType; + style: CSSProperties; + onClick: (id: string) => void; +}; + +export type Props = PropsData & PropsHousekeeping; + +export const ConversationListItem: FunctionComponent = React.memo( + ({ + acceptedMessageRequest, + avatarPath, + color, + draftPreview, + i18n, + id, + isMe, + isSelected, + lastMessage, + lastUpdated, + markedUnread, + muteExpiresAt, + name, + onClick, + phoneNumber, + profileName, + shouldShowDraft, + style, + title, + type, + typingContact, + unreadCount, + }) => { + const headerName = isMe ? ( + i18n('noteToSelf') + ) : ( + + ); + + let messageText: ReactNode = null; + let messageStatusIcon: ReactNode = null; + + if (lastMessage || typingContact) { + const messageBody = lastMessage ? lastMessage.text : ''; + const showingDraft = shouldShowDraft && draftPreview; + const deletedForEveryone = Boolean( + lastMessage && lastMessage.deletedForEveryone + ); + + /* eslint-disable no-nested-ternary */ + messageText = ( + <> + {muteExpiresAt && Date.now() < muteExpiresAt && ( + + )} + {!acceptedMessageRequest ? ( + + {i18n('ConversationListItem--message-request')} + + ) : typingContact ? ( + + ) : ( + <> + {showingDraft ? ( + <> + + {i18n('ConversationListItem--draft-prefix')} + + + + ) : deletedForEveryone ? ( + + {i18n('message--deletedForEveryone')} + + ) : ( + + )} + + )} + + ); + /* eslint-enable no-nested-ternary */ + + if (!showingDraft && lastMessage && lastMessage.status) { + messageStatusIcon = ( +
+ ); + } + } + + const onClickItem = useCallback(() => onClick(id), [onClick, id]); + + return ( + + ); + } +); diff --git a/ts/components/MessageBodyHighlight.stories.tsx b/ts/components/conversationList/MessageBodyHighlight.stories.tsx similarity index 90% rename from ts/components/MessageBodyHighlight.stories.tsx rename to ts/components/conversationList/MessageBodyHighlight.stories.tsx index ff689af1bf..0700a6c2ed 100644 --- a/ts/components/MessageBodyHighlight.stories.tsx +++ b/ts/components/conversationList/MessageBodyHighlight.stories.tsx @@ -1,12 +1,12 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; import { storiesOf } from '@storybook/react'; import { text, withKnobs } from '@storybook/addon-knobs'; -import { setup as setupI18n } from '../../js/modules/i18n'; -import enMessages from '../../_locales/en/messages.json'; +import { setup as setupI18n } from '../../../js/modules/i18n'; +import enMessages from '../../../_locales/en/messages.json'; import { MessageBodyHighlight, Props } from './MessageBodyHighlight'; const i18n = setupI18n('en', enMessages); diff --git a/ts/components/MessageBodyHighlight.tsx b/ts/components/conversationList/MessageBodyHighlight.tsx similarity index 76% rename from ts/components/MessageBodyHighlight.tsx rename to ts/components/conversationList/MessageBodyHighlight.tsx index 14a59c1467..43729cbcf5 100644 --- a/ts/components/MessageBodyHighlight.tsx +++ b/ts/components/conversationList/MessageBodyHighlight.tsx @@ -1,15 +1,18 @@ // Copyright 2019-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React from 'react'; +import React, { ReactNode } from 'react'; -import { MessageBody } from './conversation/MessageBody'; -import { Emojify } from './conversation/Emojify'; -import { AddNewLines } from './conversation/AddNewLines'; +import { MESSAGE_TEXT_CLASS_NAME } from './BaseConversationListItem'; +import { MessageBody } from '../conversation/MessageBody'; +import { Emojify } from '../conversation/Emojify'; +import { AddNewLines } from '../conversation/AddNewLines'; -import { SizeClassType } from './emoji/lib'; +import { SizeClassType } from '../emoji/lib'; -import { LocalizerType, RenderTextCallbackType } from '../types/Util'; +import { LocalizerType, RenderTextCallbackType } from '../../types/Util'; + +const CLASS_NAME = `${MESSAGE_TEXT_CLASS_NAME}__message-search-result-contents`; export type Props = { text: string; @@ -41,7 +44,7 @@ const renderEmoji = ({ ); export class MessageBodyHighlight extends React.Component { - public render(): JSX.Element | Array { + private renderContents(): ReactNode { const { text, i18n } = this.props; const results: Array = []; const FIND_BEGIN_END = /<>(.+?)<>/g; @@ -106,4 +109,8 @@ export class MessageBodyHighlight extends React.Component { return results; } + + public render(): ReactNode { + return
{this.renderContents()}
; + } } diff --git a/ts/components/MessageSearchResult.stories.tsx b/ts/components/conversationList/MessageSearchResult.stories.tsx similarity index 93% rename from ts/components/MessageSearchResult.stories.tsx rename to ts/components/conversationList/MessageSearchResult.stories.tsx index c7a6923cad..cde32a194a 100644 --- a/ts/components/MessageSearchResult.stories.tsx +++ b/ts/components/conversationList/MessageSearchResult.stories.tsx @@ -1,4 +1,4 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; @@ -6,8 +6,8 @@ import { storiesOf } from '@storybook/react'; import { action } from '@storybook/addon-actions'; import { boolean, text, withKnobs } from '@storybook/addon-knobs'; -import { setup as setupI18n } from '../../js/modules/i18n'; -import enMessages from '../../_locales/en/messages.json'; +import { setup as setupI18n } from '../../../js/modules/i18n'; +import enMessages from '../../../_locales/en/messages.json'; import { MessageSearchResult, PropsType } from './MessageSearchResult'; const i18n = setupI18n('en', enMessages); @@ -51,6 +51,7 @@ const createProps = (overrideProps: Partial = {}): PropsType => ({ 'isSearchingInConversation', overrideProps.isSearchingInConversation || false ), + style: {}, }); story.add('Default', () => { @@ -135,7 +136,7 @@ story.add('Long Search Result', () => { }); }); -story.add('Empty', () => { +story.add('Empty (should be invalid)', () => { const props = createProps(); return ; diff --git a/ts/components/conversationList/MessageSearchResult.tsx b/ts/components/conversationList/MessageSearchResult.tsx new file mode 100644 index 0000000000..4b603bdd46 --- /dev/null +++ b/ts/components/conversationList/MessageSearchResult.tsx @@ -0,0 +1,140 @@ +// Copyright 2019-2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { + useCallback, + CSSProperties, + FunctionComponent, + ReactNode, +} from 'react'; + +import { MessageBodyHighlight } from './MessageBodyHighlight'; +import { ContactName } from '../conversation/ContactName'; + +import { LocalizerType } from '../../types/Util'; +import { ColorType } from '../../types/Colors'; +import { BaseConversationListItem } from './BaseConversationListItem'; + +export type PropsDataType = { + isSelected?: boolean; + isSearchingInConversation?: boolean; + + id: string; + conversationId: string; + sentAt?: number; + + snippet: string; + + from: { + phoneNumber?: string; + title: string; + isMe?: boolean; + name?: string; + color?: ColorType; + profileName?: string; + avatarPath?: string; + }; + + to: { + groupName?: string; + phoneNumber?: string; + title: string; + isMe?: boolean; + name?: string; + profileName?: string; + }; +}; + +type PropsHousekeepingType = { + i18n: LocalizerType; + openConversationInternal: (_: { + conversationId: string; + messageId?: string; + }) => void; + style: CSSProperties; +}; + +export type PropsType = PropsDataType & PropsHousekeepingType; + +const renderPerson = ( + i18n: LocalizerType, + person: Readonly<{ + isMe?: boolean; + name?: string; + phoneNumber?: string; + profileName?: string; + title: string; + }> +): ReactNode => + person.isMe ? ( + i18n('you') + ) : ( + + ); + +export const MessageSearchResult: FunctionComponent = React.memo( + ({ + id, + conversationId, + from, + to, + sentAt, + i18n, + openConversationInternal, + style, + snippet, + }) => { + const onClickItem = useCallback(() => { + openConversationInternal({ conversationId, messageId: id }); + }, [openConversationInternal, conversationId, id]); + + if (!from || !to) { + return
; + } + + const isNoteToSelf = from.isMe && to.isMe; + + let headerName: ReactNode; + if (isNoteToSelf) { + headerName = i18n('noteToSelf'); + } else { + // This isn't perfect because (1) it doesn't work with RTL languages (2) + // capitalization may be incorrect for some languages, like English. + headerName = ( + <> + {renderPerson(i18n, from)} {i18n('toJoiner')} {renderPerson(i18n, to)} + + ); + } + + const messageText = ; + + return ( + + ); + } +); diff --git a/ts/components/conversationList/StartNewConversation.tsx b/ts/components/conversationList/StartNewConversation.tsx new file mode 100644 index 0000000000..5e140a161d --- /dev/null +++ b/ts/components/conversationList/StartNewConversation.tsx @@ -0,0 +1,48 @@ +// Copyright 2019-2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { CSSProperties, FunctionComponent } from 'react'; + +import { + BaseConversationListItem, + MESSAGE_TEXT_CLASS_NAME, +} from './BaseConversationListItem'; + +import { LocalizerType } from '../../types/Util'; + +const TEXT_CLASS_NAME = `${MESSAGE_TEXT_CLASS_NAME}__start-new-conversation`; + +type PropsData = { + phoneNumber: string; +}; + +type PropsHousekeeping = { + i18n: LocalizerType; + style: CSSProperties; + onClick: () => void; +}; + +export type Props = PropsData & PropsHousekeeping; + +export const StartNewConversation: FunctionComponent = React.memo( + ({ i18n, onClick, phoneNumber, style }) => { + const messageText = ( +
{i18n('startConversation')}
+ ); + + return ( + + ); + } +); diff --git a/ts/components/leftPane/LeftPaneArchiveHelper.tsx b/ts/components/leftPane/LeftPaneArchiveHelper.tsx new file mode 100644 index 0000000000..634bbc9cbb --- /dev/null +++ b/ts/components/leftPane/LeftPaneArchiveHelper.tsx @@ -0,0 +1,113 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { ReactChild } from 'react'; +import { last } from 'lodash'; + +import { LeftPaneHelper, ToFindType } from './LeftPaneHelper'; +import { getConversationInDirection } from './getConversationInDirection'; +import { Row, RowType } from '../ConversationList'; +import { PropsData as ConversationListItemPropsType } from '../conversationList/ConversationListItem'; +import { LocalizerType } from '../../types/Util'; + +export type LeftPaneArchivePropsType = { + archivedConversations: ReadonlyArray; +}; + +/* eslint-disable class-methods-use-this */ + +export class LeftPaneArchiveHelper extends LeftPaneHelper< + LeftPaneArchivePropsType +> { + private readonly archivedConversations: ReadonlyArray< + ConversationListItemPropsType + >; + + constructor({ archivedConversations }: Readonly) { + super(); + + this.archivedConversations = archivedConversations; + } + + getHeaderContents({ + i18n, + showInbox, + }: Readonly<{ + i18n: LocalizerType; + showInbox: () => void; + }>): ReactChild { + return ( +
+
+ ); + } + + getPreRowsNode({ i18n }: Readonly<{ i18n: LocalizerType }>): ReactChild { + return ( +
+ {i18n('archiveHelperText')} +
+ ); + } + + getRowCount(): number { + return this.archivedConversations.length; + } + + getRow(rowIndex: number): undefined | Row { + const conversation = this.archivedConversations[rowIndex]; + return conversation + ? { + type: RowType.Conversation, + conversation, + } + : undefined; + } + + getRowIndexToScrollTo( + selectedConversationId: undefined | string + ): undefined | number { + if (!selectedConversationId) { + return undefined; + } + const result = this.archivedConversations.findIndex( + conversation => conversation.id === selectedConversationId + ); + return result === -1 ? undefined : result; + } + + getConversationAndMessageAtIndex( + conversationIndex: number + ): undefined | { conversationId: string } { + const { archivedConversations } = this; + const conversation = + archivedConversations[conversationIndex] || last(archivedConversations); + return conversation ? { conversationId: conversation.id } : undefined; + } + + getConversationAndMessageInDirection( + toFind: Readonly, + selectedConversationId: undefined | string, + _selectedMessageId: unknown + ): undefined | { conversationId: string } { + return getConversationInDirection( + this.archivedConversations, + toFind, + selectedConversationId + ); + } + + shouldRecomputeRowHeights(_old: unknown): boolean { + return false; + } +} diff --git a/ts/components/leftPane/LeftPaneComposeHelper.tsx b/ts/components/leftPane/LeftPaneComposeHelper.tsx new file mode 100644 index 0000000000..5800c8101d --- /dev/null +++ b/ts/components/leftPane/LeftPaneComposeHelper.tsx @@ -0,0 +1,171 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { ReactChild, ChangeEvent } from 'react'; +import { PhoneNumber } from 'google-libphonenumber'; + +import { LeftPaneHelper } from './LeftPaneHelper'; +import { Row, RowType } from '../ConversationList'; +import { PropsDataType as ContactListItemPropsType } from '../conversationList/ContactListItem'; +import { LocalizerType } from '../../types/Util'; +import { + instance as phoneNumberInstance, + PhoneNumberFormat, +} from '../../util/libphonenumberInstance'; + +export type LeftPaneComposePropsType = { + composeContacts: ReadonlyArray; + regionCode: string; + searchTerm: string; +}; + +/* eslint-disable class-methods-use-this */ + +export class LeftPaneComposeHelper extends LeftPaneHelper< + LeftPaneComposePropsType +> { + private readonly composeContacts: ReadonlyArray; + + private readonly searchTerm: string; + + private readonly phoneNumber: undefined | PhoneNumber; + + constructor({ + composeContacts, + regionCode, + searchTerm, + }: Readonly) { + super(); + + this.composeContacts = composeContacts; + this.searchTerm = searchTerm; + this.phoneNumber = parsePhoneNumber(searchTerm, regionCode); + } + + getHeaderContents({ + i18n, + showInbox, + }: Readonly<{ + i18n: LocalizerType; + showInbox: () => void; + }>): ReactChild { + return ( +
+
+ ); + } + + getPreRowsNode({ + i18n, + onChangeComposeSearchTerm, + }: Readonly<{ + i18n: LocalizerType; + onChangeComposeSearchTerm: ( + event: ChangeEvent + ) => unknown; + }>): ReactChild { + return ( + <> +
+ +
+ + {this.getRowCount() ? null : ( +
+ {i18n('newConversationNoContacts')} +
+ )} + + ); + } + + getRowCount(): number { + return this.composeContacts.length + (this.phoneNumber ? 1 : 0); + } + + getRow(rowIndex: number): undefined | Row { + let contactIndex = rowIndex; + + if (this.phoneNumber) { + if (rowIndex === 0) { + return { + type: RowType.StartNewConversation, + phoneNumber: phoneNumberInstance.format( + this.phoneNumber, + PhoneNumberFormat.E164 + ), + }; + } + + contactIndex -= 1; + } + + const contact = this.composeContacts[contactIndex]; + return contact + ? { + type: RowType.Contact, + contact, + } + : undefined; + } + + // This is deliberately unimplemented because these keyboard shortcuts shouldn't work in + // the composer. The same is true for the "in direction" function below. + getConversationAndMessageAtIndex( + ..._args: ReadonlyArray + ): undefined { + return undefined; + } + + getConversationAndMessageInDirection( + ..._args: ReadonlyArray + ): undefined { + return undefined; + } + + shouldRecomputeRowHeights(_old: unknown): boolean { + return false; + } +} + +function focusRef(el: HTMLElement | null) { + if (el) { + el.focus(); + } +} + +function parsePhoneNumber( + str: string, + regionCode: string +): undefined | PhoneNumber { + let result: PhoneNumber; + try { + result = phoneNumberInstance.parse(str, regionCode); + } catch (err) { + return undefined; + } + + if (!phoneNumberInstance.isValidNumber(result)) { + return undefined; + } + + return result; +} diff --git a/ts/components/leftPane/LeftPaneHelper.tsx b/ts/components/leftPane/LeftPaneHelper.tsx new file mode 100644 index 0000000000..b51e6ade96 --- /dev/null +++ b/ts/components/leftPane/LeftPaneHelper.tsx @@ -0,0 +1,67 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { ChangeEvent, ReactChild } from 'react'; + +import { Row } from '../ConversationList'; +import { LocalizerType } from '../../types/Util'; + +export enum FindDirection { + Up, + Down, +} + +export type ToFindType = { + direction: FindDirection; + unreadOnly: boolean; +}; + +/* eslint-disable class-methods-use-this */ + +export abstract class LeftPaneHelper { + getHeaderContents( + _: Readonly<{ + i18n: LocalizerType; + showInbox: () => void; + }> + ): null | ReactChild { + return null; + } + + shouldRenderNetworkStatusAndUpdateDialog(): boolean { + return false; + } + + getPreRowsNode( + _: Readonly<{ + i18n: LocalizerType; + onChangeComposeSearchTerm: ( + event: ChangeEvent + ) => unknown; + }> + ): null | ReactChild { + return null; + } + + abstract getRowCount(): number; + + abstract getRow(rowIndex: number): undefined | Row; + + getRowIndexToScrollTo( + _selectedConversationId: undefined | string + ): undefined | number { + return undefined; + } + + abstract getConversationAndMessageAtIndex( + conversationIndex: number + ): undefined | { conversationId: string; messageId?: string }; + + abstract getConversationAndMessageInDirection( + toFind: Readonly, + selectedConversationId: undefined | string, + selectedMessageId: undefined | string + ): undefined | { conversationId: string; messageId?: string }; + + abstract shouldRecomputeRowHeights(old: Readonly): boolean; +} diff --git a/ts/components/leftPane/LeftPaneInboxHelper.ts b/ts/components/leftPane/LeftPaneInboxHelper.ts new file mode 100644 index 0000000000..b461aee0ef --- /dev/null +++ b/ts/components/leftPane/LeftPaneInboxHelper.ts @@ -0,0 +1,192 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { last } from 'lodash'; + +import { LeftPaneHelper, ToFindType } from './LeftPaneHelper'; +import { getConversationInDirection } from './getConversationInDirection'; +import { Row, RowType } from '../ConversationList'; +import { PropsData as ConversationListItemPropsType } from '../conversationList/ConversationListItem'; + +export type LeftPaneInboxPropsType = { + conversations: ReadonlyArray; + archivedConversations: ReadonlyArray; + pinnedConversations: ReadonlyArray; +}; + +/* eslint-disable class-methods-use-this */ + +export class LeftPaneInboxHelper extends LeftPaneHelper< + LeftPaneInboxPropsType +> { + private readonly conversations: ReadonlyArray; + + private readonly archivedConversations: ReadonlyArray< + ConversationListItemPropsType + >; + + private readonly pinnedConversations: ReadonlyArray< + ConversationListItemPropsType + >; + + constructor({ + conversations, + archivedConversations, + pinnedConversations, + }: Readonly) { + super(); + + this.conversations = conversations; + this.archivedConversations = archivedConversations; + this.pinnedConversations = pinnedConversations; + } + + shouldRenderNetworkStatusAndUpdateDialog(): boolean { + return true; + } + + getRowCount(): number { + const headerCount = this.hasPinnedAndNonpinned() ? 2 : 0; + const buttonCount = this.archivedConversations.length ? 1 : 0; + return ( + headerCount + + this.pinnedConversations.length + + this.conversations.length + + buttonCount + ); + } + + getRow(rowIndex: number): undefined | Row { + const { conversations, archivedConversations, pinnedConversations } = this; + + const archivedConversationsCount = archivedConversations.length; + + if (this.hasPinnedAndNonpinned()) { + switch (rowIndex) { + case 0: + return { + type: RowType.Header, + i18nKey: 'LeftPane--pinned', + }; + case pinnedConversations.length + 1: + return { + type: RowType.Header, + i18nKey: 'LeftPane--chats', + }; + case pinnedConversations.length + conversations.length + 2: + if (archivedConversationsCount) { + return { + type: RowType.ArchiveButton, + archivedConversationsCount, + }; + } + return undefined; + default: { + const pinnedConversation = pinnedConversations[rowIndex - 1]; + if (pinnedConversation) { + return { + type: RowType.Conversation, + conversation: pinnedConversation, + }; + } + const conversation = + conversations[rowIndex - pinnedConversations.length - 2]; + return conversation + ? { + type: RowType.Conversation, + conversation, + } + : undefined; + } + } + } + + const onlyConversations = pinnedConversations.length + ? pinnedConversations + : conversations; + if (rowIndex < onlyConversations.length) { + const conversation = onlyConversations[rowIndex]; + return conversation + ? { + type: RowType.Conversation, + conversation, + } + : undefined; + } + + if (rowIndex === onlyConversations.length && archivedConversationsCount) { + return { + type: RowType.ArchiveButton, + archivedConversationsCount, + }; + } + + return undefined; + } + + getRowIndexToScrollTo( + selectedConversationId: undefined | string + ): undefined | number { + if (!selectedConversationId) { + return undefined; + } + + const isConversationSelected = ( + conversation: Readonly + ) => conversation.id === selectedConversationId; + const hasHeaders = this.hasPinnedAndNonpinned(); + + const pinnedConversationIndex = this.pinnedConversations.findIndex( + isConversationSelected + ); + if (pinnedConversationIndex !== -1) { + const headerOffset = hasHeaders ? 1 : 0; + return pinnedConversationIndex + headerOffset; + } + + const conversationIndex = this.conversations.findIndex( + isConversationSelected + ); + if (conversationIndex !== -1) { + const pinnedOffset = this.pinnedConversations.length; + const headerOffset = hasHeaders ? 2 : 0; + return conversationIndex + pinnedOffset + headerOffset; + } + + return undefined; + } + + shouldRecomputeRowHeights(old: Readonly): boolean { + return old.pinnedConversations.length !== this.pinnedConversations.length; + } + + getConversationAndMessageAtIndex( + conversationIndex: number + ): undefined | { conversationId: string } { + const { conversations, pinnedConversations } = this; + const conversation = + pinnedConversations[conversationIndex] || + conversations[conversationIndex - pinnedConversations.length] || + last(conversations) || + last(pinnedConversations); + return conversation ? { conversationId: conversation.id } : undefined; + } + + getConversationAndMessageInDirection( + toFind: Readonly, + selectedConversationId: undefined | string, + _selectedMessageId: unknown + ): undefined | { conversationId: string } { + return getConversationInDirection( + [...this.pinnedConversations, ...this.conversations], + toFind, + selectedConversationId + ); + } + + private hasPinnedAndNonpinned(): boolean { + return Boolean( + this.pinnedConversations.length && this.conversations.length + ); + } +} diff --git a/ts/components/leftPane/LeftPaneSearchHelper.tsx b/ts/components/leftPane/LeftPaneSearchHelper.tsx new file mode 100644 index 0000000000..ca4cffa29d --- /dev/null +++ b/ts/components/leftPane/LeftPaneSearchHelper.tsx @@ -0,0 +1,240 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { ReactChild } from 'react'; + +import { LeftPaneHelper, ToFindType } from './LeftPaneHelper'; +import { LocalizerType } from '../../types/Util'; +import { Row, RowType } from '../ConversationList'; +import { PropsData as ConversationListItemPropsType } from '../conversationList/ConversationListItem'; + +import { Intl } from '../Intl'; +import { Emojify } from '../conversation/Emojify'; + +type MaybeLoadedSearchResultsType = + | { isLoading: true } + | { isLoading: false; results: Array }; + +export type LeftPaneSearchPropsType = { + conversationResults: MaybeLoadedSearchResultsType< + ConversationListItemPropsType + >; + contactResults: MaybeLoadedSearchResultsType; + messageResults: MaybeLoadedSearchResultsType<{ + id: string; + conversationId: string; + }>; + searchConversationName?: string; + searchTerm: string; +}; + +const searchResultKeys: Array< + 'conversationResults' | 'contactResults' | 'messageResults' +> = ['conversationResults', 'contactResults', 'messageResults']; + +export class LeftPaneSearchHelper extends LeftPaneHelper< + LeftPaneSearchPropsType +> { + private readonly conversationResults: MaybeLoadedSearchResultsType< + ConversationListItemPropsType + >; + + private readonly contactResults: MaybeLoadedSearchResultsType< + ConversationListItemPropsType + >; + + private readonly messageResults: MaybeLoadedSearchResultsType<{ + id: string; + conversationId: string; + }>; + + private readonly searchConversationName?: string; + + private readonly searchTerm: string; + + constructor({ + conversationResults, + contactResults, + messageResults, + searchConversationName, + searchTerm, + }: Readonly) { + super(); + + this.conversationResults = conversationResults; + this.contactResults = contactResults; + this.messageResults = messageResults; + this.searchConversationName = searchConversationName; + this.searchTerm = searchTerm; + } + + getPreRowsNode({ + i18n, + }: Readonly<{ i18n: LocalizerType }>): null | ReactChild { + const mightHaveSearchResults = this.allResults().some( + searchResult => searchResult.isLoading || searchResult.results.length + ); + if (mightHaveSearchResults) { + return null; + } + + const { searchConversationName, searchTerm } = this; + + return !searchConversationName || searchTerm ? ( +
+ {searchConversationName ? ( + + ), + }} + /> + ) : ( + i18n('noSearchResults', [searchTerm]) + )} +
+ ) : null; + } + + getRowCount(): number { + return this.allResults().reduce( + (result: number, searchResults) => + result + getRowCountForSearchResult(searchResults), + 0 + ); + } + + // This is currently unimplemented. See DESKTOP-1170. + // eslint-disable-next-line class-methods-use-this + getRowIndexToScrollTo( + _selectedConversationId: undefined | string + ): undefined | number { + return undefined; + } + + getRow(rowIndex: number): undefined | Row { + const { conversationResults, contactResults, messageResults } = this; + + const conversationRowCount = getRowCountForSearchResult( + conversationResults + ); + const contactRowCount = getRowCountForSearchResult(contactResults); + const messageRowCount = getRowCountForSearchResult(messageResults); + + if (rowIndex < conversationRowCount) { + if (rowIndex === 0) { + return { + type: RowType.Header, + i18nKey: 'conversationsHeader', + }; + } + if (conversationResults.isLoading) { + return { type: RowType.Spinner }; + } + const conversation = conversationResults.results[rowIndex - 1]; + return conversation + ? { + type: RowType.Conversation, + conversation, + } + : undefined; + } + + if (rowIndex < conversationRowCount + contactRowCount) { + const localIndex = rowIndex - conversationRowCount; + if (localIndex === 0) { + return { + type: RowType.Header, + i18nKey: 'contactsHeader', + }; + } + if (contactResults.isLoading) { + return { type: RowType.Spinner }; + } + const conversation = contactResults.results[localIndex - 1]; + return conversation + ? { + type: RowType.Conversation, + conversation, + } + : undefined; + } + + if (rowIndex >= conversationRowCount + contactRowCount + messageRowCount) { + return undefined; + } + + const localIndex = rowIndex - conversationRowCount - contactRowCount; + if (localIndex === 0) { + return { + type: RowType.Header, + i18nKey: 'messagesHeader', + }; + } + if (messageResults.isLoading) { + return { type: RowType.Spinner }; + } + const message = messageResults.results[localIndex - 1]; + return message + ? { + type: RowType.MessageSearchResult, + messageId: message.id, + } + : undefined; + } + + shouldRecomputeRowHeights(old: Readonly): boolean { + return searchResultKeys.some( + key => + getRowCountForSearchResult(old[key]) !== + getRowCountForSearchResult(this[key]) + ); + } + + // This is currently unimplemented. See DESKTOP-1170. + // eslint-disable-next-line class-methods-use-this + getConversationAndMessageAtIndex( + _conversationIndex: number + ): undefined | { conversationId: string; messageId?: string } { + return undefined; + } + + // This is currently unimplemented. See DESKTOP-1170. + // eslint-disable-next-line class-methods-use-this + getConversationAndMessageInDirection( + _toFind: Readonly, + _selectedConversationId: undefined | string, + _selectedMessageId: unknown + ): undefined | { conversationId: string } { + return undefined; + } + + private allResults() { + return [this.conversationResults, this.contactResults, this.messageResults]; + } +} + +function getRowCountForSearchResult( + searchResults: Readonly> +): number { + let hasHeader: boolean; + let resultRows: number; + if (searchResults.isLoading) { + hasHeader = true; + resultRows = 1; // For the spinner. + } else { + const resultCount = searchResults.results.length; + hasHeader = Boolean(resultCount); + resultRows = resultCount; + } + return (hasHeader ? 1 : 0) + resultRows; +} diff --git a/ts/components/leftPane/getConversationInDirection.ts b/ts/components/leftPane/getConversationInDirection.ts new file mode 100644 index 0000000000..1220aef807 --- /dev/null +++ b/ts/components/leftPane/getConversationInDirection.ts @@ -0,0 +1,63 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { find as findFirst, findLast, first, last } from 'lodash'; + +import { PropsData as ConversationListItemPropsType } from '../conversationList/ConversationListItem'; +import { isConversationUnread } from '../../util/isConversationUnread'; +import { FindDirection, ToFindType } from './LeftPaneHelper'; + +/** + * This will look up or down in an array of conversations for the next one to select. + * Refer to the tests for the intended behavior. + */ +export const getConversationInDirection = ( + conversations: ReadonlyArray, + toFind: Readonly, + selectedConversationId: undefined | string +): undefined | { conversationId: string } => { + // As an optimization, we don't need to search if no conversation is selected. + const selectedConversationIndex = selectedConversationId + ? conversations.findIndex(({ id }) => id === selectedConversationId) + : -1; + + let conversation: ConversationListItemPropsType | undefined; + + if (selectedConversationIndex < 0) { + if (toFind.unreadOnly) { + conversation = + toFind.direction === FindDirection.Up + ? findLast(conversations, isConversationUnread) + : findFirst(conversations, isConversationUnread); + } else { + conversation = + toFind.direction === FindDirection.Up + ? last(conversations) + : first(conversations); + } + } else if (toFind.unreadOnly) { + conversation = + toFind.direction === FindDirection.Up + ? findLast( + conversations.slice(0, selectedConversationIndex), + isConversationUnread + ) + : findFirst( + conversations.slice(selectedConversationIndex + 1), + isConversationUnread + ); + } else { + const newIndex = + selectedConversationIndex + + (toFind.direction === FindDirection.Up ? -1 : 1); + if (newIndex < 0) { + conversation = last(conversations); + } else if (newIndex >= conversations.length) { + conversation = first(conversations); + } else { + conversation = conversations[newIndex]; + } + } + + return conversation ? { conversationId: conversation.id } : undefined; +}; diff --git a/ts/groups/joinViaLink.ts b/ts/groups/joinViaLink.ts index f2bcb75e84..1d23c1a24a 100644 --- a/ts/groups/joinViaLink.ts +++ b/ts/groups/joinViaLink.ts @@ -58,9 +58,9 @@ export async function joinViaLink(hash: string): Promise { window.log.warn( `joinViaLink/${logId}: Already a member of group, opening conversation` ); - window.reduxActions.conversations.openConversationInternal( - existingConversation.id - ); + window.reduxActions.conversations.openConversationInternal({ + conversationId: existingConversation.id, + }); window.window.Whisper.ToastView.show( window.Whisper.AlreadyGroupMemberToast, document.getElementsByClassName('conversation-stack')[0] @@ -132,9 +132,9 @@ export async function joinViaLink(hash: string): Promise { window.log.warn( `joinViaLink/${logId}: Already awaiting approval, opening conversation` ); - window.reduxActions.conversations.openConversationInternal( - existingConversation.id - ); + window.reduxActions.conversations.openConversationInternal({ + conversationId: existingConversation.id, + }); window.Whisper.ToastView.show( window.Whisper.AlreadyRequestedToJoinToast, @@ -221,9 +221,9 @@ export async function joinViaLink(hash: string): Promise { window.log.warn( `joinViaLink/${logId}: User is part of group on second check, opening conversation` ); - window.reduxActions.conversations.openConversationInternal( - targetConversation.id - ); + window.reduxActions.conversations.openConversationInternal({ + conversationId: targetConversation.id, + }); return; } @@ -302,9 +302,9 @@ export async function joinViaLink(hash: string): Promise { ); } - window.reduxActions.conversations.openConversationInternal( - targetConversation.id - ); + window.reduxActions.conversations.openConversationInternal({ + conversationId: targetConversation.id, + }); } catch (error) { // Delete newly-created conversation if we encountered any errors if (tempConversation) { diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 129ec242c5..c896e055e1 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -24,6 +24,7 @@ import { import { ColorType } from '../types/Colors'; import { MessageModel } from './messages'; import { isMuted } from '../util/isMuted'; +import { isConversationUnregistered } from '../util/isConversationUnregistered'; import { missingCaseError } from '../util/missingCaseError'; import { sniffImageMimeType } from '../util/sniffImageMimeType'; import { MIMEType, IMAGE_WEBP } from '../types/MIME'; @@ -736,15 +737,7 @@ export class ConversationModel extends window.Backbone.Model< } isUnregistered(): boolean { - const now = Date.now(); - const sixHoursAgo = now - 1000 * 60 * 60 * 6; - const discoveredUnregisteredAt = this.get('discoveredUnregisteredAt'); - - if (discoveredUnregisteredAt && discoveredUnregisteredAt > sixHoursAgo) { - return true; - } - - return false; + return isConversationUnregistered(this.attributes); } setUnregistered(): void { @@ -1316,6 +1309,7 @@ export class ConversationModel extends window.Backbone.Model< canEditGroupInfo: this.canEditGroupInfo(), avatarPath: this.getAvatarPath()!, color, + discoveredUnregisteredAt: this.get('discoveredUnregisteredAt'), draftBodyRanges, draftPreview, draftText, @@ -1329,7 +1323,6 @@ export class ConversationModel extends window.Backbone.Model< isMe: this.isMe(), isGroupV1AndDisabled: this.isGroupV1AndDisabled(), isPinned: this.get('isPinned'), - isMissingMandatoryProfileSharing: this.isMissingRequiredProfileSharing(), isUntrusted: this.isUntrusted(), isVerified: this.isVerified(), lastMessage: { @@ -1354,6 +1347,7 @@ export class ConversationModel extends window.Backbone.Model< name: this.get('name')!, phoneNumber: this.getNumber()!, profileName: this.getProfileName()!, + profileSharing: this.get('profileSharing'), publicParams: this.get('publicParams'), secretParams: this.get('secretParams'), sharedGroupNames: this.get('sharedGroupNames')!, diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index ed2d3613c7..f849659029 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -2414,7 +2414,7 @@ async function searchMessages( const rows = await db.all( `SELECT messages.json, - snippet(messages_fts, -1, '<>', '<>', '...', 15) as snippet + snippet(messages_fts, -1, '<>', '<>', '...', 10) as snippet FROM messages_fts INNER JOIN messages on messages_fts.id = messages.id WHERE @@ -2442,7 +2442,7 @@ async function searchMessagesInConversation( const rows = await db.all( `SELECT messages.json, - snippet(messages_fts, -1, '<>', '<>', '...', 15) as snippet + snippet(messages_fts, -1, '<>', '<>', '...', 10) as snippet FROM messages_fts INNER JOIN messages on messages_fts.id = messages.id WHERE diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 0c73476dbb..1777ea3562 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -18,8 +18,8 @@ import { import { StateType as RootStateType } from '../reducer'; import { calling } from '../../services/calling'; import { getOwn } from '../../util/getOwn'; +import { assert } from '../../util/assert'; import { trigger } from '../../shims/events'; -import { NoopActionType } from './noop'; import { AttachmentType } from '../../types/Attachment'; import { ColorType } from '../../types/Colors'; import { BodyRangeType } from '../../types/Util'; @@ -65,6 +65,7 @@ export type ConversationType = { canChangeTimer?: boolean; canEditGroupInfo?: boolean; color?: ColorType; + discoveredUnregisteredAt?: number; isAccepted?: boolean; isArchived?: boolean; isBlocked?: boolean; @@ -110,6 +111,7 @@ export type ConversationType = { profileName?: string; } | null; recentMediaItems?: Array; + profileSharing?: boolean; shouldShowDraft?: boolean; draftText?: string | null; @@ -120,7 +122,6 @@ export type ConversationType = { groupVersion?: 1 | 2; groupId?: string; groupLink?: string; - isMissingMandatoryProfileSharing?: boolean; messageRequestsEnabled?: boolean; acceptedMessageRequest?: boolean; secretParams?: string; @@ -231,6 +232,9 @@ export type ConversationsStateType = { selectedConversationTitle?: string; selectedConversationPanelDepth: number; showArchived: boolean; + composer?: { + contactSearchTerm: string; + }; // Note: it's very important that both of these locations are always kept up to date messagesLookup: MessageLookupType; @@ -431,6 +435,10 @@ export type ShowArchivedConversationsActionType = { type: 'SHOW_ARCHIVED_CONVERSATIONS'; payload: null; }; +type SetComposeSearchTermActionType = { + type: 'SET_COMPOSE_SEARCH_TERM'; + payload: { contactSearchTerm: string }; +}; type SetRecentMediaItemsActionType = { type: 'SET_RECENT_MEDIA_ITEMS'; payload: { @@ -438,6 +446,13 @@ type SetRecentMediaItemsActionType = { recentMediaItems: Array; }; }; +type StartComposingActionType = { + type: 'START_COMPOSING'; +}; +export type SwitchToAssociatedViewActionType = { + type: 'SWITCH_TO_ASSOCIATED_VIEW'; + payload: { conversationId: string }; +}; export type ConversationActionType = | ClearChangedMessagesActionType @@ -458,6 +473,7 @@ export type ConversationActionType = | RepairOldestMessageActionType | ScrollToMessageActionType | SelectedConversationChangedActionType + | SetComposeSearchTermActionType | SetConversationHeaderTitleActionType | SetIsNearBottomActionType | SetLoadCountdownStartActionType @@ -466,7 +482,9 @@ export type ConversationActionType = | SetRecentMediaItemsActionType | SetSelectedConversationPanelDepthActionType | ShowArchivedConversationsActionType - | ShowInboxActionType; + | ShowInboxActionType + | StartComposingActionType + | SwitchToAssociatedViewActionType; // Action Creators @@ -490,6 +508,7 @@ export const actions = { repairOldestMessage, scrollToMessage, selectMessage, + setComposeSearchTerm, setIsNearBottom, setLoadCountdownStart, setMessagesLoading, @@ -499,6 +518,8 @@ export const actions = { setSelectedConversationPanelDepth, showArchivedConversations, showInbox, + startComposing, + startNewConversationFromPhoneNumber, }; function setPreJoinConversation( @@ -770,19 +791,56 @@ function scrollToMessage( }; } +function setComposeSearchTerm( + contactSearchTerm: string +): SetComposeSearchTermActionType { + return { + type: 'SET_COMPOSE_SEARCH_TERM', + payload: { contactSearchTerm }, + }; +} + +function startComposing(): StartComposingActionType { + return { type: 'START_COMPOSING' }; +} + +function startNewConversationFromPhoneNumber( + e164: string +): ThunkAction { + return dispatch => { + trigger('showConversation', e164); + + dispatch(showInbox()); + }; +} + // Note: we need two actions here to simplify. Operations outside of the left pane can // trigger an 'openConversation' so we go through Whisper.events for all // conversation selection. Internal just triggers the Whisper.event, and External // makes the changes to the store. -function openConversationInternal( - id: string, - messageId?: string -): NoopActionType { - trigger('showConversation', id, messageId); +function openConversationInternal({ + conversationId, + messageId, + switchToAssociatedView, +}: Readonly<{ + conversationId: string; + messageId?: string; + switchToAssociatedView?: boolean; +}>): ThunkAction< + void, + RootStateType, + unknown, + SwitchToAssociatedViewActionType +> { + return dispatch => { + trigger('showConversation', conversationId, messageId); - return { - type: 'NOOP', - payload: null, + if (switchToAssociatedView) { + dispatch({ + type: 'SWITCH_TO_ASSOCIATED_VIEW', + payload: { conversationId }, + }); + } }; } function openConversationExternal( @@ -1626,13 +1684,13 @@ export function reducer( } if (action.type === 'SHOW_INBOX') { return { - ...state, + ...omit(state, 'composer'), showArchived: false, }; } if (action.type === 'SHOW_ARCHIVED_CONVERSATIONS') { return { - ...state, + ...omit(state, 'composer'), showArchived: true, }; } @@ -1669,5 +1727,52 @@ export function reducer( }; } + if (action.type === 'START_COMPOSING') { + if (state.composer) { + return state; + } + + return { + ...state, + showArchived: false, + composer: { + contactSearchTerm: '', + }, + }; + } + + if (action.type === 'SET_COMPOSE_SEARCH_TERM') { + const { composer } = state; + if (!composer) { + assert( + false, + 'Setting compose search term with the composer closed is a no-op' + ); + return state; + } + + return { + ...state, + composer: { + ...composer, + contactSearchTerm: action.payload.contactSearchTerm, + }, + }; + } + + if (action.type === 'SWITCH_TO_ASSOCIATED_VIEW') { + const conversation = getOwn( + state.conversationLookup, + action.payload.conversationId + ); + if (!conversation) { + return state; + } + return { + ...omit(state, 'composer'), + showArchived: Boolean(conversation.isArchived), + }; + } + return state; } diff --git a/ts/state/ducks/search.ts b/ts/state/ducks/search.ts index 4976649f23..b6073483ac 100644 --- a/ts/state/ducks/search.ts +++ b/ts/state/ducks/search.ts @@ -4,7 +4,6 @@ import { omit, reject } from 'lodash'; import { normalize } from '../../types/PhoneNumber'; -import { trigger } from '../../shims/events'; import { cleanSearchTerm } from '../../util/cleanSearchTerm'; import dataInterface from '../../sql/Client'; import { makeLookup } from '../../util/makeLookup'; @@ -39,9 +38,8 @@ export type SearchStateType = { startSearchCounter: number; searchConversationId?: string; searchConversationName?: string; - // We store just ids of conversations, since that data is always cached in memory - contacts: Array; - conversations: Array; + contactIds: Array; + conversationIds: Array; query: string; normalizedPhoneNumber?: string; messageIds: Array; @@ -63,8 +61,8 @@ type SearchMessagesResultsPayloadType = SearchResultsBaseType & { messages: Array; }; type SearchDiscussionsResultsPayloadType = SearchResultsBaseType & { - conversations: Array; - contacts: Array; + conversationIds: Array; + contactIds: Array; }; type SearchMessagesResultsKickoffActionType = { type: 'SEARCH_MESSAGES_RESULTS'; @@ -135,7 +133,6 @@ export const actions = { clearConversationSearch, searchInConversation, updateSearchTerm, - startNewConversation, }; function searchMessages( @@ -190,7 +187,7 @@ async function doSearchDiscussions( } ): Promise { const { ourConversationId, noteToSelf } = options; - const { conversations, contacts } = await queryConversationsAndContacts( + const { conversationIds, contactIds } = await queryConversationsAndContacts( query, { ourConversationId, @@ -199,8 +196,8 @@ async function doSearchDiscussions( ); return { - conversations, - contacts, + conversationIds, + contactIds, query, }; } @@ -243,22 +240,6 @@ function updateSearchTerm(query: string): UpdateSearchTermActionType { }, }; } -function startNewConversation( - query: string, - options: { regionCode: string } -): ClearSearchActionType { - const { regionCode } = options; - const normalized = normalize(query, { regionCode }); - if (!normalized) { - throw new Error('Attempted to start new conversation with invalid number'); - } - trigger('showConversation', normalized); - - return { - type: 'SEARCH_CLEAR', - payload: null, - }; -} async function queryMessages(query: string, searchConversationId?: string) { try { @@ -280,7 +261,10 @@ async function queryConversationsAndContacts( ourConversationId: string; noteToSelf: string; } -) { +): Promise<{ + contactIds: Array; + conversationIds: Array; +}> { const { ourConversationId, noteToSelf } = options; const query = providedQuery.replace(/[+.()]*/g, ''); @@ -289,16 +273,16 @@ async function queryConversationsAndContacts( ); // Split into two groups - active conversations and items just from address book - let conversations: Array = []; - let contacts: Array = []; + let conversationIds: Array = []; + let contactIds: Array = []; const max = searchResults.length; for (let i = 0; i < max; i += 1) { const conversation = searchResults[i]; if (conversation.type === 'private' && !conversation.lastMessage) { - contacts.push(conversation.id); + contactIds.push(conversation.id); } else { - conversations.push(conversation.id); + conversationIds.push(conversation.id); } } @@ -312,13 +296,13 @@ async function queryConversationsAndContacts( // Inject synthetic Note to Self entry if query matches localized 'Note to Self' if (noteToSelf.indexOf(providedQuery.toLowerCase()) !== -1) { // ensure that we don't have duplicates in our results - contacts = contacts.filter(id => id !== ourConversationId); - conversations = conversations.filter(id => id !== ourConversationId); + contactIds = contactIds.filter(id => id !== ourConversationId); + conversationIds = conversationIds.filter(id => id !== ourConversationId); - contacts.unshift(ourConversationId); + contactIds.unshift(ourConversationId); } - return { conversations, contacts }; + return { conversationIds, contactIds }; } // Reducer @@ -329,8 +313,8 @@ export function getEmptyState(): SearchStateType { query: '', messageIds: [], messageLookup: {}, - conversations: [], - contacts: [], + conversationIds: [], + contactIds: [], discussionsLoading: false, messagesLoading: false, }; @@ -373,8 +357,8 @@ export function reducer( messageIds: [], messageLookup: {}, discussionsLoading: !isWithinConversation, - contacts: [], - conversations: [], + contactIds: [], + conversationIds: [], } : {}), }; @@ -431,12 +415,12 @@ export function reducer( if (action.type === 'SEARCH_DISCUSSIONS_RESULTS_FULFILLED') { const { payload } = action; - const { contacts, conversations } = payload; + const { contactIds, conversationIds } = payload; return { ...state, - contacts, - conversations, + contactIds, + conversationIds, discussionsLoading: false, }; } diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index 079f7a9e99..f00bdb9963 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -2,8 +2,9 @@ // SPDX-License-Identifier: AGPL-3.0-only import memoizee from 'memoizee'; -import { fromPairs, isNumber } from 'lodash'; +import { fromPairs, isNumber, isString } from 'lodash'; import { createSelector } from 'reselect'; +import Fuse, { FuseOptions } from 'fuse.js'; import { StateType } from '../reducer'; import { @@ -16,6 +17,7 @@ import { MessageType, PreJoinConversationType, } from '../ducks/conversations'; +import { LocalizerType } from '../../types/Util'; import { getOwn } from '../../util/getOwn'; import type { CallsByConversationType } from '../ducks/calling'; import { getCallsByConversation } from './calling'; @@ -23,6 +25,7 @@ import { getBubbleProps } from '../../shims/Whisper'; import { PropsDataType as TimelinePropsType } from '../../components/conversation/Timeline'; import { TimelineItemType } from '../../components/conversation/TimelineItem'; import { assert } from '../../util/assert'; +import { isConversationUnregistered } from '../../util/isConversationUnregistered'; import { getInteractionMode, @@ -135,6 +138,16 @@ export const getShowArchived = createSelector( } ); +const getComposerState = createSelector( + getConversations, + (state: ConversationsStateType) => state.composer +); + +export const isComposing = createSelector( + getComposerState, + (composerState): boolean => Boolean(composerState) +); + export const getMessages = createSelector( getConversations, (state: ConversationsStateType): MessageLookupType => { @@ -148,6 +161,20 @@ export const getMessagesByConversation = createSelector( } ); +export const getIsConversationEmptySelector = createSelector( + getMessagesByConversation, + (messagesByConversation: MessagesByConversationType) => ( + conversationId: string + ): boolean => { + const messages = getOwn(messagesByConversation, conversationId); + if (!messages) { + assert(false, 'Could not find conversation with this ID'); + return true; + } + return messages.messageIds.length === 0; + } +); + const collator = new Intl.Collator(); // Note: we will probably want to put i18n and regionCode back when we are formatting @@ -256,6 +283,86 @@ export const getMe = createSelector( } ); +export const getComposerContactSearchTerm = createSelector( + getComposerState, + (composer): string => { + if (!composer) { + assert(false, 'getComposerContactSearchTerm: composer is not open'); + return ''; + } + return composer.contactSearchTerm; + } +); + +/** + * This returns contacts for the composer, which isn't just your primary's system + * contacts. It may include false positives, which is better than missing contacts. + * + * Because it filters unregistered contacts and that's (partially) determined by the + * current time, it's possible for this to return stale contacts that have unregistered + * if no other conversations change. This should be a rare false positive. + */ +const getContacts = createSelector( + getConversationLookup, + (conversationLookup: ConversationLookupType): Array => + Object.values(conversationLookup).filter( + contact => + contact.type === 'direct' && + !contact.isMe && + !contact.isBlocked && + !isConversationUnregistered(contact) && + (isString(contact.name) || contact.profileSharing) + ) +); + +const getNormalizedComposerContactSearchTerm = createSelector( + getComposerContactSearchTerm, + (searchTerm: string): string => searchTerm.trim() +); + +const getNoteToSelfTitle = createSelector(getIntl, (i18n: LocalizerType) => + i18n('noteToSelf').toLowerCase() +); + +const COMPOSE_CONTACTS_FUSE_OPTIONS: FuseOptions = { + // A small-but-nonzero threshold lets us match parts of E164s better, and makes the + // search a little more forgiving. + threshold: 0.05, + keys: ['title', 'name', 'e164'], +}; + +export const getComposeContacts = createSelector( + getNormalizedComposerContactSearchTerm, + getContacts, + getMe, + getNoteToSelfTitle, + ( + searchTerm: string, + contacts: Array, + noteToSelf: ConversationType, + noteToSelfTitle: string + ): Array => { + let result: Array; + + if (searchTerm.length) { + const fuse = new Fuse( + contacts, + COMPOSE_CONTACTS_FUSE_OPTIONS + ); + result = fuse.search(searchTerm); + if (noteToSelfTitle.includes(searchTerm)) { + result.push(noteToSelf); + } + } else { + result = contacts.concat(); + result.sort((a, b) => collator.compare(a.title, b.title)); + result.push(noteToSelf); + } + + return result; + } +); + // This is where we will put Conversation selector logic, replicating what // is currently in models/conversation.getProps() // What needs to happen to pull that selector logic here? diff --git a/ts/state/selectors/search.ts b/ts/state/selectors/search.ts index 2bca7627d3..51085eb797 100644 --- a/ts/state/selectors/search.ts +++ b/ts/state/selectors/search.ts @@ -1,9 +1,10 @@ -// Copyright 2019-2020 Signal Messenger, LLC +// Copyright 2019-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import memoizee from 'memoizee'; import { createSelector } from 'reselect'; -import { instance } from '../../util/libphonenumberInstance'; + +import { deconstructLookup } from '../../util/deconstructLookup'; import { StateType } from '../reducer'; @@ -17,19 +18,14 @@ import { ConversationType, } from '../ducks/conversations'; -import { - PropsDataType as SearchResultsPropsType, - SearchResultRowType, -} from '../../components/SearchResults'; -import { PropsDataType as MessageSearchResultPropsDataType } from '../../components/MessageSearchResult'; +import { LeftPaneSearchPropsType } from '../../components/leftPane/LeftPaneSearchHelper'; +import { PropsDataType as MessageSearchResultPropsDataType } from '../../components/conversationList/MessageSearchResult'; -import { getRegionCode, getUserConversationId } from './user'; -import { getUserAgent } from './items'; +import { getUserConversationId } from './user'; import { GetConversationByIdType, getConversationLookup, getConversationSelector, - getSelectedConversationId, } from './conversations'; export const getSearch = (state: StateType): SearchStateType => state.search; @@ -72,148 +68,44 @@ export const getMessageSearchResultLookup = createSelector( getSearch, (state: SearchStateType) => state.messageLookup ); + export const getSearchResults = createSelector( - [ - getSearch, - getRegionCode, - getUserAgent, - getConversationLookup, - getSelectedConversationId, - getSelectedMessage, - ], + [getSearch, getConversationLookup], ( state: SearchStateType, - regionCode: string, - userAgent: string, - lookup: ConversationLookupType, - selectedConversationId?: string, - selectedMessageId?: string - ): SearchResultsPropsType | undefined => { + conversationLookup: ConversationLookupType + ): LeftPaneSearchPropsType => { const { - contacts, - conversations, + contactIds, + conversationIds, discussionsLoading, messageIds, + messageLookup, messagesLoading, searchConversationName, } = state; - const showStartNewConversation = Boolean( - state.normalizedPhoneNumber && !lookup[state.normalizedPhoneNumber] - ); - const haveConversations = conversations && conversations.length; - const haveContacts = contacts && contacts.length; - const haveMessages = messageIds && messageIds.length; - const noResults = - !discussionsLoading && - !messagesLoading && - !showStartNewConversation && - !haveConversations && - !haveContacts && - !haveMessages; - - const items: Array = []; - - if (showStartNewConversation) { - items.push({ - type: 'start-new-conversation', - data: undefined, - }); - - const isIOS = userAgent === 'OWI'; - let isValidNumber = false; - try { - // Sometimes parse() throws, like for invalid country codes - const parsedNumber = instance.parse(state.query, regionCode); - isValidNumber = instance.isValidNumber(parsedNumber); - } catch (_) { - // no-op - } - - if (!isIOS && isValidNumber) { - items.push({ - type: 'sms-mms-not-supported-text', - data: undefined, - }); - } - } - - if (haveConversations) { - items.push({ - type: 'conversations-header', - data: undefined, - }); - conversations.forEach(id => { - const data = lookup[id]; - items.push({ - type: 'conversation', - data: { - ...data, - isSelected: Boolean(data && id === selectedConversationId), - }, - }); - }); - } else if (discussionsLoading) { - items.push({ - type: 'conversations-header', - data: undefined, - }); - items.push({ - type: 'spinner', - data: undefined, - }); - } - - if (haveContacts) { - items.push({ - type: 'contacts-header', - data: undefined, - }); - contacts.forEach(id => { - const data = lookup[id]; - - items.push({ - type: 'contact', - data: { - ...data, - isSelected: Boolean(data && id === selectedConversationId), - }, - }); - }); - } - - if (haveMessages) { - items.push({ - type: 'messages-header', - data: undefined, - }); - messageIds.forEach(messageId => { - items.push({ - type: 'message', - data: messageId, - }); - }); - } else if (messagesLoading) { - items.push({ - type: 'messages-header', - data: undefined, - }); - items.push({ - type: 'spinner', - data: undefined, - }); - } - return { - discussionsLoading, - items, - messagesLoading, - noResults, - regionCode, + conversationResults: discussionsLoading + ? { isLoading: true } + : { + isLoading: false, + results: deconstructLookup(conversationLookup, conversationIds), + }, + contactResults: discussionsLoading + ? { isLoading: true } + : { + isLoading: false, + results: deconstructLookup(conversationLookup, contactIds), + }, + messageResults: messagesLoading + ? { isLoading: true } + : { + isLoading: false, + results: deconstructLookup(messageLookup, messageIds), + }, searchConversationName, searchTerm: state.query, - selectedConversationId, - selectedMessageId, }; } ); diff --git a/ts/state/smart/CompositionArea.tsx b/ts/state/smart/CompositionArea.tsx index 10f473bd51..1bed153beb 100644 --- a/ts/state/smart/CompositionArea.tsx +++ b/ts/state/smart/CompositionArea.tsx @@ -1,4 +1,4 @@ -// Copyright 2019-2020 Signal Messenger, LLC +// Copyright 2019-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { connect } from 'react-redux'; @@ -10,7 +10,10 @@ import { StateType } from '../reducer'; import { isShortName } from '../../components/emoji/lib'; import { getIntl } from '../selectors/user'; -import { getConversationSelector } from '../selectors/conversations'; +import { + getConversationSelector, + getIsConversationEmptySelector, +} from '../selectors/conversations'; import { getBlessedStickerPacks, getInstalledStickerPacks, @@ -78,6 +81,10 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => { // Message Requests ...conversation, conversationType: conversation.type, + isMissingMandatoryProfileSharing: + !conversation.profileSharing && + window.Signal.RemoteConfig.isEnabled('desktop.mandatoryProfileSharing') && + !getIsConversationEmptySelector(state)(id), }; }; diff --git a/ts/state/smart/ConversationHeader.tsx b/ts/state/smart/ConversationHeader.tsx index 1b19a38957..53905d1da3 100644 --- a/ts/state/smart/ConversationHeader.tsx +++ b/ts/state/smart/ConversationHeader.tsx @@ -7,7 +7,10 @@ import { ConversationHeader, OutgoingCallButtonStyle, } from '../../components/conversation/ConversationHeader'; -import { getConversationSelector } from '../selectors/conversations'; +import { + getConversationSelector, + getIsConversationEmptySelector, +} from '../selectors/conversations'; import { StateType } from '../reducer'; import { CallMode } from '../../types/Calling'; import { @@ -78,7 +81,9 @@ const getOutgoingCallButtonStyle = ( }; const mapStateToProps = (state: StateType, ownProps: OwnProps) => { - const conversation = getConversationSelector(state)(ownProps.id); + const { id } = ownProps; + + const conversation = getConversationSelector(state)(id); if (!conversation) { throw new Error('Could not find conversation'); } @@ -92,7 +97,6 @@ const mapStateToProps = (state: StateType, ownProps: OwnProps) => { 'expireTimer', 'isArchived', 'isMe', - 'isMissingMandatoryProfileSharing', 'isPinned', 'isVerified', 'left', @@ -106,6 +110,10 @@ const mapStateToProps = (state: StateType, ownProps: OwnProps) => { 'groupVersion', ]), conversationTitle: state.conversations.selectedConversationTitle, + isMissingMandatoryProfileSharing: + !conversation.profileSharing && + window.Signal.RemoteConfig.isEnabled('desktop.mandatoryProfileSharing') && + !getIsConversationEmptySelector(state)(id), i18n: getIntl(state), showBackButton: state.conversations.selectedConversationPanelDepth > 0, outgoingCallButtonStyle: getOutgoingCallButtonStyle(conversation, state), diff --git a/ts/state/smart/LeftPane.tsx b/ts/state/smart/LeftPane.tsx index 839e1d8d96..7f0ffb5026 100644 --- a/ts/state/smart/LeftPane.tsx +++ b/ts/state/smart/LeftPane.tsx @@ -1,18 +1,26 @@ -// Copyright 2019-2020 Signal Messenger, LLC +// Copyright 2019-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React from 'react'; +import React, { CSSProperties } from 'react'; import { connect } from 'react-redux'; import { mapDispatchToProps } from '../actions'; -import { LeftPane } from '../../components/LeftPane'; +import { + LeftPane, + LeftPaneMode, + PropsType as LeftPanePropsType, +} from '../../components/LeftPane'; import { StateType } from '../reducer'; import { getSearchResults, isSearching } from '../selectors/search'; -import { getIntl } from '../selectors/user'; +import { getIntl, getRegionCode } from '../selectors/user'; import { + getComposeContacts, + getComposerContactSearchTerm, getLeftPaneLists, getSelectedConversationId, + getSelectedMessage, getShowArchived, + isComposing, } from '../selectors/conversations'; import { SmartExpiredBuildDialog } from './ExpiredBuildDialog'; @@ -34,8 +42,11 @@ function renderExpiredBuildDialog(): JSX.Element { function renderMainHeader(): JSX.Element { return ; } -function renderMessageSearchResult(id: string): JSX.Element { - return ; +function renderMessageSearchResult( + id: string, + style: CSSProperties +): JSX.Element { + return ; } function renderNetworkStatus(): JSX.Element { return ; @@ -47,19 +58,47 @@ function renderUpdateDialog(): JSX.Element { return ; } -const mapStateToProps = (state: StateType) => { - const showSearch = isSearching(state); +const getModeSpecificProps = ( + state: StateType +): LeftPanePropsType['modeSpecificProps'] => { + if (isComposing(state)) { + return { + mode: LeftPaneMode.Compose, + composeContacts: getComposeContacts(state), + regionCode: getRegionCode(state), + searchTerm: getComposerContactSearchTerm(state), + }; + } - const lists = showSearch ? undefined : getLeftPaneLists(state); - const searchResults = showSearch ? getSearchResults(state) : undefined; - const selectedConversationId = getSelectedConversationId(state); + if (getShowArchived(state)) { + const { archivedConversations } = getLeftPaneLists(state); + return { + mode: LeftPaneMode.Archive, + archivedConversations, + }; + } + + if (isSearching(state)) { + return { + mode: LeftPaneMode.Search, + ...getSearchResults(state), + }; + } return { - ...lists, - searchResults, - selectedConversationId, + mode: LeftPaneMode.Inbox, + ...getLeftPaneLists(state), + }; +}; + +const mapStateToProps = (state: StateType) => { + return { + modeSpecificProps: getModeSpecificProps(state), + selectedConversationId: getSelectedConversationId(state), + selectedMessageId: getSelectedMessage(state)?.id, showArchived: getShowArchived(state), i18n: getIntl(state), + regionCode: getRegionCode(state), renderExpiredBuildDialog, renderMainHeader, renderMessageSearchResult, diff --git a/ts/state/smart/MessageSearchResult.tsx b/ts/state/smart/MessageSearchResult.tsx index 5e3ebfdf90..78e5f60048 100644 --- a/ts/state/smart/MessageSearchResult.tsx +++ b/ts/state/smart/MessageSearchResult.tsx @@ -1,27 +1,30 @@ -// Copyright 2019-2020 Signal Messenger, LLC +// Copyright 2019-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import { CSSProperties } from 'react'; import { connect } from 'react-redux'; import { mapDispatchToProps } from '../actions'; import { StateType } from '../reducer'; -import { MessageSearchResult } from '../../components/MessageSearchResult'; +import { MessageSearchResult } from '../../components/conversationList/MessageSearchResult'; import { getIntl } from '../selectors/user'; import { getMessageSearchResultSelector } from '../selectors/search'; type SmartProps = { id: string; + style: CSSProperties; }; function mapStateToProps(state: StateType, ourProps: SmartProps) { - const { id } = ourProps; + const { id, style } = ourProps; const props = getMessageSearchResultSelector(state)(id); return { ...props, i18n: getIntl(state), + style, }; } const smart = connect(mapStateToProps, mapDispatchToProps); diff --git a/ts/test-both/state/selectors/conversations_test.ts b/ts/test-both/state/selectors/conversations_test.ts index 6ba2a611b6..4238107c67 100644 --- a/ts/test-both/state/selectors/conversations_test.ts +++ b/ts/test-both/state/selectors/conversations_test.ts @@ -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 = { diff --git a/ts/test-both/state/selectors/search_test.ts b/ts/test-both/state/selectors/search_test.ts index b0bdb4acaa..07a4bf1a74 100644 --- a/ts/test-both/state/selectors/search_test.ts +++ b/ts/test-both/state/selectors/search_test.ts @@ -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 = [ + getDefaultConversation('1'), + getDefaultConversation('2'), + ]; + const contacts: Array = [ + getDefaultConversation('3'), + getDefaultConversation('4'), + getDefaultConversation('5'), + ]; + const messages: Array = [ + 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', + }); + }); + }); }); diff --git a/ts/test-both/util/deconstructLookup_test.ts b/ts/test-both/util/deconstructLookup_test.ts new file mode 100644 index 0000000000..6dc73fb947 --- /dev/null +++ b/ts/test-both/util/deconstructLookup_test.ts @@ -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]); + }); +}); diff --git a/ts/test-both/util/isConversationUnread_test.ts b/ts/test-both/util/isConversationUnread_test.ts new file mode 100644 index 0000000000..18ad7723f3 --- /dev/null +++ b/ts/test-both/util/isConversationUnread_test.ts @@ -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 })); + }); +}); diff --git a/ts/test-both/util/isConversationUnregistered_test.ts b/ts/test-both/util/isConversationUnregistered_test.ts new file mode 100644 index 0000000000..c00a3f5bd3 --- /dev/null +++ b/ts/test-both/util/isConversationUnregistered_test.ts @@ -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(), + }) + ); + }); +}); diff --git a/ts/test-electron/state/ducks/conversations_test.ts b/ts/test-electron/state/ducks/conversations_test.ts index 871c62a2f6..56e163253b 100644 --- a/ts/test-electron/state/ducks/conversations_test.ts +++ b/ts/test-electron/state/ducks/conversations_test.ts @@ -1,8 +1,11 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { assert } from 'chai'; +import * as sinon from 'sinon'; import { set } from 'lodash/fp'; +import { reducer as rootReducer } from '../../../state/reducer'; +import { noopAction } from '../../../state/ducks/noop'; import { actions, ConversationMessageType, @@ -13,17 +16,35 @@ import { MessageType, reducer, updateConversationLookups, + SwitchToAssociatedViewActionType, } from '../../../state/ducks/conversations'; import { CallMode } from '../../../types/Calling'; const { messageSizeChanged, + openConversationInternal, repairNewestMessage, repairOldestMessage, + setComposeSearchTerm, setPreJoinConversation, + showArchivedConversations, + showInbox, + startComposing, } = actions; describe('both/state/ducks/conversations', () => { + const getEmptyRootState = () => rootReducer(undefined, noopAction()); + + let sinonSandbox: sinon.SinonSandbox; + + beforeEach(() => { + sinonSandbox = sinon.createSandbox(); + }); + + afterEach(() => { + sinonSandbox.restore(); + }); + describe('helpers', () => { describe('getConversationCallMode', () => { const fakeConversation: ConversationType = { @@ -295,6 +316,132 @@ describe('both/state/ducks/conversations', () => { }; } + describe('openConversationInternal', () => { + beforeEach(() => { + sinonSandbox.stub(window.Whisper.events, 'trigger'); + }); + + it("returns a thunk that triggers a 'showConversation' event when passed a conversation ID", () => { + const dispatch = sinon.spy(); + + openConversationInternal({ conversationId: 'abc123' })( + dispatch, + getEmptyRootState, + null + ); + + sinon.assert.calledOnce( + window.Whisper.events.trigger as sinon.SinonSpy + ); + sinon.assert.calledWith( + window.Whisper.events.trigger as sinon.SinonSpy, + 'showConversation', + 'abc123', + undefined + ); + }); + + it("returns a thunk that triggers a 'showConversation' event when passed a conversation ID and message ID", () => { + const dispatch = sinon.spy(); + + openConversationInternal({ + conversationId: 'abc123', + messageId: 'xyz987', + })(dispatch, getEmptyRootState, null); + + sinon.assert.calledOnce( + window.Whisper.events.trigger as sinon.SinonSpy + ); + sinon.assert.calledWith( + window.Whisper.events.trigger as sinon.SinonSpy, + 'showConversation', + 'abc123', + 'xyz987' + ); + }); + + it("returns a thunk that doesn't dispatch any actions by default", () => { + const dispatch = sinon.spy(); + + openConversationInternal({ conversationId: 'abc123' })( + dispatch, + getEmptyRootState, + null + ); + + sinon.assert.notCalled(dispatch); + }); + + it('dispatches a SWITCH_TO_ASSOCIATED_VIEW action if called with a flag', () => { + const dispatch = sinon.spy(); + + openConversationInternal({ + conversationId: 'abc123', + switchToAssociatedView: true, + })(dispatch, getEmptyRootState, null); + + sinon.assert.calledWith(dispatch, { + type: 'SWITCH_TO_ASSOCIATED_VIEW', + payload: { conversationId: 'abc123' }, + }); + }); + + describe('SWITCH_TO_ASSOCIATED_VIEW', () => { + let action: SwitchToAssociatedViewActionType; + + beforeEach(() => { + const dispatch = sinon.spy(); + openConversationInternal({ + conversationId: 'fake-conversation-id', + switchToAssociatedView: true, + })(dispatch, getEmptyRootState, null); + [action] = dispatch.getCall(0).args; + }); + + it('shows the inbox if the conversation is not archived', () => { + const state = { + ...getEmptyState(), + conversationLookup: { + 'fake-conversation-id': { + id: 'fake-conversation-id', + type: 'direct' as const, + title: 'Foo Bar', + }, + }, + }; + const result = reducer(state, action); + + assert.isUndefined(result.composer); + assert.isFalse(result.showArchived); + }); + + it('shows the archive if the conversation is archived', () => { + const state = { + ...getEmptyState(), + conversationLookup: { + 'fake-conversation-id': { + id: 'fake-conversation-id', + type: 'group' as const, + title: 'Baz Qux', + isArchived: true, + }, + }, + }; + const result = reducer(state, action); + + assert.isUndefined(result.composer); + assert.isTrue(result.showArchived); + }); + + it('does nothing if the conversation is not found', () => { + const state = getEmptyState(); + const result = reducer(state, action); + + assert.strictEqual(result, state); + }); + }); + }); + describe('MESSAGE_SIZE_CHANGED', () => { const stateWithActiveConversation = { ...getEmptyState(), @@ -579,6 +726,21 @@ describe('both/state/ducks/conversations', () => { }); }); + describe('SET_COMPOSE_SEARCH_TERM', () => { + it('updates the contact search term', () => { + const state = { + ...getEmptyState(), + composer: { + contactSearchTerm: '', + }, + }; + const action = setComposeSearchTerm('foo bar'); + const result = reducer(state, action); + + assert.strictEqual(result.composer?.contactSearchTerm, 'foo bar'); + }); + }); + describe('SET_PRE_JOIN_CONVERSATION', () => { const startState = { ...getEmptyState(), @@ -612,5 +774,116 @@ describe('both/state/ducks/conversations', () => { assert.isUndefined(resetState.preJoinConversation); }); }); + + 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: { + contactSearchTerm: '', + }, + }; + 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: { + contactSearchTerm: '', + }, + }; + const action = showInbox(); + const result = reducer(state, action); + + assert.isFalse(result.showArchived); + assert.isUndefined(result.composer); + }); + }); + + describe('START_COMPOSING', () => { + it('if already at the composer, does nothing', () => { + const state = { + ...getEmptyState(), + composer: { + contactSearchTerm: 'foo bar', + }, + }; + const action = startComposing(); + const result = reducer(state, action); + + assert.isFalse(result.showArchived); + assert.deepEqual(result.composer, { contactSearchTerm: 'foo bar' }); + }); + + 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, { contactSearchTerm: '' }); + }); + + 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, { contactSearchTerm: '' }); + }); + }); }); }); diff --git a/ts/test-node/components/LeftPane_test.tsx b/ts/test-node/components/LeftPane_test.tsx deleted file mode 100644 index 755414a438..0000000000 --- a/ts/test-node/components/LeftPane_test.tsx +++ /dev/null @@ -1,208 +0,0 @@ -// Copyright 2020 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import React from 'react'; -import { assert } from 'chai'; - -import { LeftPane, RowType, HeaderType } from '../../components/LeftPane'; -import { setup as setupI18n } from '../../../js/modules/i18n'; -import enMessages from '../../../_locales/en/messages.json'; - -const i18n = setupI18n('en', enMessages); - -describe('LeftPane', () => { - const defaultProps = { - archivedConversations: [], - conversations: [], - i18n, - openConversationInternal: () => null, - pinnedConversations: [], - renderExpiredBuildDialog: () =>
, - renderMainHeader: () =>
, - renderMessageSearchResult: () =>
, - renderNetworkStatus: () =>
, - renderRelinkDialog: () =>
, - renderUpdateDialog: () =>
, - showArchivedConversations: () => null, - showInbox: () => null, - startNewConversation: () => null, - }; - - describe('getRowFromIndex', () => { - describe('given only pinned chats', () => { - it('returns pinned chats, not headers', () => { - const leftPane = new LeftPane({ - ...defaultProps, - pinnedConversations: [ - { - id: 'philly-convo', - isPinned: true, - isSelected: false, - lastUpdated: Date.now(), - markedUnread: false, - title: 'Philip Glass', - type: 'direct', - }, - { - id: 'robbo-convo', - isPinned: true, - isSelected: false, - lastUpdated: Date.now(), - markedUnread: false, - title: 'Robert Moog', - type: 'direct', - }, - ], - }); - - assert.deepEqual(leftPane.getRowFromIndex(0), { - index: 0, - type: RowType.PinnedConversation, - }); - assert.deepEqual(leftPane.getRowFromIndex(1), { - index: 1, - type: RowType.PinnedConversation, - }); - }); - }); - - describe('given only non-pinned chats', () => { - it('returns conversations, not headers', () => { - const leftPane = new LeftPane({ - ...defaultProps, - conversations: [ - { - id: 'fred-convo', - isSelected: false, - lastUpdated: Date.now(), - markedUnread: false, - title: 'Fred Willard', - type: 'direct', - }, - { - id: 'robbo-convo', - isPinned: false, - isSelected: false, - lastUpdated: Date.now(), - markedUnread: false, - title: 'Robert Moog', - type: 'direct', - }, - ], - }); - - assert.deepEqual(leftPane.getRowFromIndex(0), { - index: 0, - type: RowType.Conversation, - }); - assert.deepEqual(leftPane.getRowFromIndex(1), { - index: 1, - type: RowType.Conversation, - }); - }); - }); - - describe('given only pinned and non-pinned chats', () => { - it('returns headers and conversations', () => { - const leftPane = new LeftPane({ - ...defaultProps, - conversations: [ - { - id: 'fred-convo', - isSelected: false, - lastUpdated: Date.now(), - markedUnread: false, - title: 'Fred Willard', - type: 'direct', - }, - ], - pinnedConversations: [ - { - id: 'philly-convo', - isPinned: true, - isSelected: false, - lastUpdated: Date.now(), - markedUnread: false, - title: 'Philip Glass', - type: 'direct', - }, - ], - }); - - assert.deepEqual(leftPane.getRowFromIndex(0), { - headerType: HeaderType.Pinned, - type: RowType.Header, - }); - assert.deepEqual(leftPane.getRowFromIndex(1), { - index: 0, - type: RowType.PinnedConversation, - }); - assert.deepEqual(leftPane.getRowFromIndex(2), { - headerType: HeaderType.Chats, - type: RowType.Header, - }); - assert.deepEqual(leftPane.getRowFromIndex(3), { - index: 0, - type: RowType.Conversation, - }); - }); - }); - - describe('given not showing archive with archived conversation', () => { - it('returns an archive button last', () => { - const leftPane = new LeftPane({ - ...defaultProps, - archivedConversations: [ - { - id: 'jerry-convo', - isSelected: false, - lastUpdated: Date.now(), - markedUnread: false, - title: 'Jerry Jordan', - type: 'direct', - }, - ], - conversations: [ - { - id: 'fred-convo', - isSelected: false, - lastUpdated: Date.now(), - markedUnread: false, - title: 'Fred Willard', - type: 'direct', - }, - ], - showArchived: false, - }); - - assert.deepEqual(leftPane.getRowFromIndex(1), { - type: RowType.ArchiveButton, - }); - }); - }); - - describe('given showing archive and archive chats', () => { - it('returns archived conversations', () => { - const leftPane = new LeftPane({ - ...defaultProps, - archivedConversations: [ - { - id: 'fred-convo', - isSelected: false, - lastUpdated: Date.now(), - markedUnread: false, - title: 'Fred Willard', - type: 'direct', - }, - ], - showArchived: true, - }); - - assert.deepEqual(leftPane.getRowFromIndex(0), { - index: 0, - type: RowType.ArchivedConversation, - }); - }); - }); - }); -}); diff --git a/ts/test-node/components/leftPane/LeftPaneArchiveHelper_test.ts b/ts/test-node/components/leftPane/LeftPaneArchiveHelper_test.ts new file mode 100644 index 0000000000..1d83e9d2c4 --- /dev/null +++ b/ts/test-node/components/leftPane/LeftPaneArchiveHelper_test.ts @@ -0,0 +1,162 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import { v4 as uuid } from 'uuid'; +import { RowType } from '../../../components/ConversationList'; +import { FindDirection } from '../../../components/leftPane/LeftPaneHelper'; + +import { LeftPaneArchiveHelper } from '../../../components/leftPane/LeftPaneArchiveHelper'; + +describe('LeftPaneArchiveHelper', () => { + const fakeConversation = () => ({ + id: uuid(), + title: uuid(), + type: 'direct' as const, + }); + + describe('getRowCount', () => { + it('returns the number of archived conversations', () => { + assert.strictEqual( + new LeftPaneArchiveHelper({ archivedConversations: [] }).getRowCount(), + 0 + ); + assert.strictEqual( + new LeftPaneArchiveHelper({ + archivedConversations: [fakeConversation(), fakeConversation()], + }).getRowCount(), + 2 + ); + }); + }); + + describe('getRowIndexToScrollTo', () => { + it('returns undefined if no conversation is selected', () => { + const helper = new LeftPaneArchiveHelper({ + archivedConversations: [fakeConversation(), fakeConversation()], + }); + + assert.isUndefined(helper.getRowIndexToScrollTo(undefined)); + }); + + it('returns undefined if the selected conversation is not pinned or non-pinned', () => { + const helper = new LeftPaneArchiveHelper({ + archivedConversations: [fakeConversation(), fakeConversation()], + }); + + assert.isUndefined(helper.getRowIndexToScrollTo(uuid())); + }); + + it("returns the archived conversation's index", () => { + const archivedConversations = [fakeConversation(), fakeConversation()]; + const helper = new LeftPaneArchiveHelper({ archivedConversations }); + + assert.strictEqual( + helper.getRowIndexToScrollTo(archivedConversations[0].id), + 0 + ); + assert.strictEqual( + helper.getRowIndexToScrollTo(archivedConversations[1].id), + 1 + ); + }); + }); + + describe('getRow', () => { + it('returns each conversation as a row', () => { + const archivedConversations = [fakeConversation(), fakeConversation()]; + const helper = new LeftPaneArchiveHelper({ archivedConversations }); + + assert.deepEqual(helper.getRow(0), { + type: RowType.Conversation, + conversation: archivedConversations[0], + }); + assert.deepEqual(helper.getRow(1), { + type: RowType.Conversation, + conversation: archivedConversations[1], + }); + }); + }); + + describe('getConversationAndMessageAtIndex', () => { + it('returns the conversation at the given index when it exists', () => { + const archivedConversations = [fakeConversation(), fakeConversation()]; + const helper = new LeftPaneArchiveHelper({ archivedConversations }); + + assert.strictEqual( + helper.getConversationAndMessageAtIndex(0)?.conversationId, + archivedConversations[0].id + ); + assert.strictEqual( + helper.getConversationAndMessageAtIndex(1)?.conversationId, + archivedConversations[1].id + ); + }); + + it('when requesting an index out of bounds, returns the last conversation', () => { + const archivedConversations = [fakeConversation(), fakeConversation()]; + const helper = new LeftPaneArchiveHelper({ archivedConversations }); + + assert.strictEqual( + helper.getConversationAndMessageAtIndex(2)?.conversationId, + archivedConversations[1].id + ); + assert.strictEqual( + helper.getConversationAndMessageAtIndex(99)?.conversationId, + archivedConversations[1].id + ); + + // This is mostly a resilience measure in case we're ever called with an invalid + // index. + assert.strictEqual( + helper.getConversationAndMessageAtIndex(-1)?.conversationId, + archivedConversations[1].id + ); + }); + + it('returns undefined if there are no archived conversations', () => { + const helper = new LeftPaneArchiveHelper({ archivedConversations: [] }); + + assert.isUndefined(helper.getConversationAndMessageAtIndex(0)); + assert.isUndefined(helper.getConversationAndMessageAtIndex(1)); + assert.isUndefined(helper.getConversationAndMessageAtIndex(-1)); + }); + }); + + describe('getConversationAndMessageInDirection', () => { + it('returns the next conversation when searching downward', () => { + const archivedConversations = [fakeConversation(), fakeConversation()]; + const helper = new LeftPaneArchiveHelper({ archivedConversations }); + + assert.deepEqual( + helper.getConversationAndMessageInDirection( + { direction: FindDirection.Down, unreadOnly: false }, + archivedConversations[0].id, + undefined + ), + { conversationId: archivedConversations[1].id } + ); + }); + + // Additional tests are found with `getConversationInDirection`. + }); + + describe('shouldRecomputeRowHeights', () => { + it('always returns false because row heights are constant', () => { + const helper = new LeftPaneArchiveHelper({ + archivedConversations: [fakeConversation(), fakeConversation()], + }); + + assert.isFalse( + helper.shouldRecomputeRowHeights({ + archivedConversations: [fakeConversation()], + }) + ); + assert.isFalse( + helper.shouldRecomputeRowHeights({ + archivedConversations: [fakeConversation(), fakeConversation()], + }) + ); + }); + }); +}); diff --git a/ts/test-node/components/leftPane/LeftPaneComposeHelper_test.ts b/ts/test-node/components/leftPane/LeftPaneComposeHelper_test.ts new file mode 100644 index 0000000000..9967ac8453 --- /dev/null +++ b/ts/test-node/components/leftPane/LeftPaneComposeHelper_test.ts @@ -0,0 +1,144 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import { v4 as uuid } from 'uuid'; +import { RowType } from '../../../components/ConversationList'; +import { FindDirection } from '../../../components/leftPane/LeftPaneHelper'; + +import { LeftPaneComposeHelper } from '../../../components/leftPane/LeftPaneComposeHelper'; + +describe('LeftPaneComposeHelper', () => { + const fakeContact = () => ({ + id: uuid(), + title: uuid(), + type: 'direct' as const, + }); + + describe('getRowCount', () => { + it('returns the number of contacts if not searching for a phone number', () => { + assert.strictEqual( + new LeftPaneComposeHelper({ + composeContacts: [], + regionCode: 'US', + searchTerm: 'foo bar', + }).getRowCount(), + 0 + ); + assert.strictEqual( + new LeftPaneComposeHelper({ + composeContacts: [fakeContact(), fakeContact()], + regionCode: 'US', + searchTerm: '', + }).getRowCount(), + 2 + ); + }); + + it('returns the number of contacts + 1 if searching for a phone number', () => { + assert.strictEqual( + new LeftPaneComposeHelper({ + composeContacts: [fakeContact(), fakeContact()], + regionCode: 'US', + searchTerm: '+16505551234', + }).getRowCount(), + 3 + ); + }); + }); + + describe('getRow', () => { + it('returns each contact as a row if not searching for a phone number', () => { + const composeContacts = [fakeContact(), fakeContact()]; + const helper = new LeftPaneComposeHelper({ + composeContacts, + regionCode: 'US', + searchTerm: '', + }); + + assert.deepEqual(helper.getRow(0), { + type: RowType.Contact, + contact: composeContacts[0], + }); + assert.deepEqual(helper.getRow(1), { + type: RowType.Contact, + contact: composeContacts[1], + }); + }); + + it('returns a "start new conversation" row if searching for a phone number', () => { + const composeContacts = [fakeContact(), fakeContact()]; + const helper = new LeftPaneComposeHelper({ + composeContacts, + regionCode: 'US', + searchTerm: '+16505551234', + }); + + assert.deepEqual(helper.getRow(0), { + type: RowType.StartNewConversation, + phoneNumber: '+16505551234', + }); + assert.deepEqual(helper.getRow(1), { + type: RowType.Contact, + contact: composeContacts[0], + }); + assert.deepEqual(helper.getRow(2), { + type: RowType.Contact, + contact: composeContacts[1], + }); + }); + }); + + describe('getConversationAndMessageAtIndex', () => { + it('returns undefined because keyboard shortcuts are not supported', () => { + const helper = new LeftPaneComposeHelper({ + composeContacts: [fakeContact(), fakeContact()], + regionCode: 'US', + searchTerm: 'foo bar', + }); + + assert.isUndefined(helper.getConversationAndMessageAtIndex(0)); + }); + }); + + describe('getConversationAndMessageInDirection', () => { + it('returns undefined because keyboard shortcuts are not supported', () => { + const helper = new LeftPaneComposeHelper({ + composeContacts: [fakeContact(), fakeContact()], + regionCode: 'US', + searchTerm: 'foo bar', + }); + + assert.isUndefined( + helper.getConversationAndMessageInDirection( + { direction: FindDirection.Down, unreadOnly: false }, + undefined, + undefined + ) + ); + }); + }); + + describe('shouldRecomputeRowHeights', () => { + it('always returns false because row heights are constant', () => { + const helper = new LeftPaneComposeHelper({ + composeContacts: [fakeContact(), fakeContact()], + regionCode: 'US', + searchTerm: 'foo bar', + }); + + assert.isFalse( + helper.shouldRecomputeRowHeights({ + composeContacts: [fakeContact()], + searchTerm: 'foo bar', + }) + ); + assert.isFalse( + helper.shouldRecomputeRowHeights({ + composeContacts: [fakeContact(), fakeContact(), fakeContact()], + searchTerm: '', + }) + ); + }); + }); +}); diff --git a/ts/test-node/components/leftPane/LeftPaneInboxHelper_test.ts b/ts/test-node/components/leftPane/LeftPaneInboxHelper_test.ts new file mode 100644 index 0000000000..0e9568ac12 --- /dev/null +++ b/ts/test-node/components/leftPane/LeftPaneInboxHelper_test.ts @@ -0,0 +1,635 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import { v4 as uuid } from 'uuid'; +import { RowType } from '../../../components/ConversationList'; +import { FindDirection } from '../../../components/leftPane/LeftPaneHelper'; + +import { LeftPaneInboxHelper } from '../../../components/leftPane/LeftPaneInboxHelper'; + +describe('LeftPaneInboxHelper', () => { + const fakeConversation = () => ({ + id: uuid(), + title: uuid(), + type: 'direct' as const, + }); + + describe('getRowCount', () => { + it('returns 0 if there are no conversations', () => { + const helper = new LeftPaneInboxHelper({ + conversations: [], + pinnedConversations: [], + archivedConversations: [], + }); + + assert.strictEqual(helper.getRowCount(), 0); + }); + + it('returns 1 if there are only archived conversations', () => { + const helper = new LeftPaneInboxHelper({ + conversations: [], + pinnedConversations: [], + archivedConversations: [fakeConversation()], + }); + + assert.strictEqual(helper.getRowCount(), 1); + }); + + it("returns the number of non-pinned conversations if that's all there is", () => { + const helper = new LeftPaneInboxHelper({ + conversations: [ + fakeConversation(), + fakeConversation(), + fakeConversation(), + ], + pinnedConversations: [], + archivedConversations: [], + }); + + assert.strictEqual(helper.getRowCount(), 3); + }); + + it("returns the number of pinned conversations if that's all there is", () => { + const helper = new LeftPaneInboxHelper({ + conversations: [], + pinnedConversations: [ + fakeConversation(), + fakeConversation(), + fakeConversation(), + ], + archivedConversations: [], + }); + + assert.strictEqual(helper.getRowCount(), 3); + }); + + it('adds 2 rows for each header if there are pinned and non-pinned conversations,', () => { + const helper = new LeftPaneInboxHelper({ + conversations: [ + fakeConversation(), + fakeConversation(), + fakeConversation(), + ], + pinnedConversations: [fakeConversation()], + archivedConversations: [], + }); + + assert.strictEqual(helper.getRowCount(), 6); + }); + + it('adds 1 row for the archive button if there are any archived conversations', () => { + const helper = new LeftPaneInboxHelper({ + conversations: [ + fakeConversation(), + fakeConversation(), + fakeConversation(), + ], + pinnedConversations: [], + archivedConversations: [fakeConversation()], + }); + + assert.strictEqual(helper.getRowCount(), 4); + }); + }); + + describe('getRowIndexToScrollTo', () => { + it('returns undefined if no conversation is selected', () => { + const helper = new LeftPaneInboxHelper({ + conversations: [fakeConversation(), fakeConversation()], + pinnedConversations: [fakeConversation()], + archivedConversations: [], + }); + + assert.isUndefined(helper.getRowIndexToScrollTo(undefined)); + }); + + it('returns undefined if the selected conversation is not pinned or non-pinned', () => { + const archivedConversations = [fakeConversation()]; + const helper = new LeftPaneInboxHelper({ + conversations: [fakeConversation(), fakeConversation()], + pinnedConversations: [fakeConversation()], + archivedConversations, + }); + + assert.isUndefined( + helper.getRowIndexToScrollTo(archivedConversations[0].id) + ); + }); + + it("returns the pinned conversation's index if there are only pinned conversations", () => { + const pinnedConversations = [fakeConversation(), fakeConversation()]; + const helper = new LeftPaneInboxHelper({ + conversations: [], + pinnedConversations, + archivedConversations: [], + }); + + assert.strictEqual( + helper.getRowIndexToScrollTo(pinnedConversations[0].id), + 0 + ); + assert.strictEqual( + helper.getRowIndexToScrollTo(pinnedConversations[1].id), + 1 + ); + }); + + it("returns the conversation's index if there are only non-pinned conversations", () => { + const conversations = [fakeConversation(), fakeConversation()]; + const helper = new LeftPaneInboxHelper({ + conversations, + pinnedConversations: [], + archivedConversations: [], + }); + + assert.strictEqual(helper.getRowIndexToScrollTo(conversations[0].id), 0); + assert.strictEqual(helper.getRowIndexToScrollTo(conversations[1].id), 1); + }); + + it("returns the pinned conversation's index + 1 (for the header) if there are both pinned and non-pinned conversations", () => { + const pinnedConversations = [fakeConversation(), fakeConversation()]; + const helper = new LeftPaneInboxHelper({ + conversations: [fakeConversation()], + pinnedConversations, + archivedConversations: [], + }); + + assert.strictEqual( + helper.getRowIndexToScrollTo(pinnedConversations[0].id), + 1 + ); + assert.strictEqual( + helper.getRowIndexToScrollTo(pinnedConversations[1].id), + 2 + ); + }); + + it("returns the non-pinned conversation's index + pinnedConversations.length + 2 (for the headers) if there are both pinned and non-pinned conversations", () => { + const conversations = [fakeConversation(), fakeConversation()]; + const helper = new LeftPaneInboxHelper({ + conversations, + pinnedConversations: [ + fakeConversation(), + fakeConversation(), + fakeConversation(), + ], + archivedConversations: [], + }); + + assert.strictEqual(helper.getRowIndexToScrollTo(conversations[0].id), 5); + assert.strictEqual(helper.getRowIndexToScrollTo(conversations[1].id), 6); + }); + }); + + describe('getRow', () => { + it('returns the archive button if there are only archived conversations', () => { + const helper = new LeftPaneInboxHelper({ + conversations: [], + pinnedConversations: [], + archivedConversations: [fakeConversation(), fakeConversation()], + }); + + assert.deepEqual(helper.getRow(0), { + type: RowType.ArchiveButton, + archivedConversationsCount: 2, + }); + assert.isUndefined(helper.getRow(1)); + }); + + it("returns pinned conversations if that's all there are", () => { + const pinnedConversations = [fakeConversation(), fakeConversation()]; + + const helper = new LeftPaneInboxHelper({ + conversations: [], + pinnedConversations, + archivedConversations: [], + }); + + assert.deepEqual(helper.getRow(0), { + type: RowType.Conversation, + conversation: pinnedConversations[0], + }); + assert.deepEqual(helper.getRow(1), { + type: RowType.Conversation, + conversation: pinnedConversations[1], + }); + assert.isUndefined(helper.getRow(2)); + }); + + it('returns pinned conversations and an archive button if there are no non-pinned conversations', () => { + const pinnedConversations = [fakeConversation(), fakeConversation()]; + + const helper = new LeftPaneInboxHelper({ + conversations: [], + pinnedConversations, + archivedConversations: [fakeConversation()], + }); + + assert.deepEqual(helper.getRow(0), { + type: RowType.Conversation, + conversation: pinnedConversations[0], + }); + assert.deepEqual(helper.getRow(1), { + type: RowType.Conversation, + conversation: pinnedConversations[1], + }); + assert.deepEqual(helper.getRow(2), { + type: RowType.ArchiveButton, + archivedConversationsCount: 1, + }); + assert.isUndefined(helper.getRow(3)); + }); + + it("returns non-pinned conversations if that's all there are", () => { + const conversations = [fakeConversation(), fakeConversation()]; + + const helper = new LeftPaneInboxHelper({ + conversations, + pinnedConversations: [], + archivedConversations: [], + }); + + assert.deepEqual(helper.getRow(0), { + type: RowType.Conversation, + conversation: conversations[0], + }); + assert.deepEqual(helper.getRow(1), { + type: RowType.Conversation, + conversation: conversations[1], + }); + assert.isUndefined(helper.getRow(2)); + }); + + it('returns non-pinned conversations and an archive button if there are no pinned conversations', () => { + const conversations = [fakeConversation(), fakeConversation()]; + + const helper = new LeftPaneInboxHelper({ + conversations, + pinnedConversations: [], + archivedConversations: [fakeConversation()], + }); + + assert.deepEqual(helper.getRow(0), { + type: RowType.Conversation, + conversation: conversations[0], + }); + assert.deepEqual(helper.getRow(1), { + type: RowType.Conversation, + conversation: conversations[1], + }); + assert.deepEqual(helper.getRow(2), { + type: RowType.ArchiveButton, + archivedConversationsCount: 1, + }); + assert.isUndefined(helper.getRow(3)); + }); + + it('returns headers if there are both pinned and non-pinned conversations', () => { + const conversations = [ + fakeConversation(), + fakeConversation(), + fakeConversation(), + ]; + const pinnedConversations = [fakeConversation(), fakeConversation()]; + + const helper = new LeftPaneInboxHelper({ + conversations, + pinnedConversations, + archivedConversations: [], + }); + + assert.deepEqual(helper.getRow(0), { + type: RowType.Header, + i18nKey: 'LeftPane--pinned', + }); + assert.deepEqual(helper.getRow(1), { + type: RowType.Conversation, + conversation: pinnedConversations[0], + }); + assert.deepEqual(helper.getRow(2), { + type: RowType.Conversation, + conversation: pinnedConversations[1], + }); + assert.deepEqual(helper.getRow(3), { + type: RowType.Header, + i18nKey: 'LeftPane--chats', + }); + assert.deepEqual(helper.getRow(4), { + type: RowType.Conversation, + conversation: conversations[0], + }); + assert.deepEqual(helper.getRow(5), { + type: RowType.Conversation, + conversation: conversations[1], + }); + assert.deepEqual(helper.getRow(6), { + type: RowType.Conversation, + conversation: conversations[2], + }); + assert.isUndefined(helper.getRow(7)); + }); + + it('returns headers if there are both pinned and non-pinned conversations, and an archive button', () => { + const conversations = [ + fakeConversation(), + fakeConversation(), + fakeConversation(), + ]; + const pinnedConversations = [fakeConversation(), fakeConversation()]; + + const helper = new LeftPaneInboxHelper({ + conversations, + pinnedConversations, + archivedConversations: [fakeConversation()], + }); + + assert.deepEqual(helper.getRow(0), { + type: RowType.Header, + i18nKey: 'LeftPane--pinned', + }); + assert.deepEqual(helper.getRow(1), { + type: RowType.Conversation, + conversation: pinnedConversations[0], + }); + assert.deepEqual(helper.getRow(2), { + type: RowType.Conversation, + conversation: pinnedConversations[1], + }); + assert.deepEqual(helper.getRow(3), { + type: RowType.Header, + i18nKey: 'LeftPane--chats', + }); + assert.deepEqual(helper.getRow(4), { + type: RowType.Conversation, + conversation: conversations[0], + }); + assert.deepEqual(helper.getRow(5), { + type: RowType.Conversation, + conversation: conversations[1], + }); + assert.deepEqual(helper.getRow(6), { + type: RowType.Conversation, + conversation: conversations[2], + }); + assert.deepEqual(helper.getRow(7), { + type: RowType.ArchiveButton, + archivedConversationsCount: 1, + }); + assert.isUndefined(helper.getRow(8)); + }); + }); + + describe('getConversationAndMessageAtIndex', () => { + it('returns pinned converastions, then non-pinned conversations', () => { + const conversations = [ + fakeConversation(), + fakeConversation(), + fakeConversation(), + ]; + const pinnedConversations = [fakeConversation(), fakeConversation()]; + + const helper = new LeftPaneInboxHelper({ + conversations, + pinnedConversations, + archivedConversations: [], + }); + + assert.strictEqual( + helper.getConversationAndMessageAtIndex(0)?.conversationId, + pinnedConversations[0].id + ); + assert.strictEqual( + helper.getConversationAndMessageAtIndex(1)?.conversationId, + pinnedConversations[1].id + ); + assert.strictEqual( + helper.getConversationAndMessageAtIndex(2)?.conversationId, + conversations[0].id + ); + assert.strictEqual( + helper.getConversationAndMessageAtIndex(3)?.conversationId, + conversations[1].id + ); + assert.strictEqual( + helper.getConversationAndMessageAtIndex(4)?.conversationId, + conversations[2].id + ); + }); + + it("when requesting an index out of bounds, returns the last pinned conversation when that's all there is", () => { + const pinnedConversations = [fakeConversation(), fakeConversation()]; + + const helper = new LeftPaneInboxHelper({ + conversations: [], + pinnedConversations, + archivedConversations: [], + }); + + assert.strictEqual( + helper.getConversationAndMessageAtIndex(2)?.conversationId, + pinnedConversations[1].id + ); + assert.strictEqual( + helper.getConversationAndMessageAtIndex(99)?.conversationId, + pinnedConversations[1].id + ); + // This is mostly a resilience measure in case we're ever called with an invalid + // index. + assert.strictEqual( + helper.getConversationAndMessageAtIndex(-1)?.conversationId, + pinnedConversations[1].id + ); + }); + + it("when requesting an index out of bounds, returns the last non-pinned conversation when that's all there is", () => { + const conversations = [fakeConversation(), fakeConversation()]; + + const helper = new LeftPaneInboxHelper({ + conversations, + pinnedConversations: [], + archivedConversations: [], + }); + + assert.strictEqual( + helper.getConversationAndMessageAtIndex(2)?.conversationId, + conversations[1].id + ); + assert.strictEqual( + helper.getConversationAndMessageAtIndex(99)?.conversationId, + conversations[1].id + ); + // This is mostly a resilience measure in case we're ever called with an invalid + // index. + assert.strictEqual( + helper.getConversationAndMessageAtIndex(-1)?.conversationId, + conversations[1].id + ); + }); + + it('when requesting an index out of bounds, returns the last non-pinned conversation when there are both pinned and non-pinned conversations', () => { + const conversations = [fakeConversation(), fakeConversation()]; + const pinnedConversations = [fakeConversation(), fakeConversation()]; + + const helper = new LeftPaneInboxHelper({ + conversations, + pinnedConversations, + archivedConversations: [], + }); + + assert.strictEqual( + helper.getConversationAndMessageAtIndex(4)?.conversationId, + conversations[1].id + ); + assert.strictEqual( + helper.getConversationAndMessageAtIndex(99)?.conversationId, + conversations[1].id + ); + // This is mostly a resilience measure in case we're ever called with an invalid + // index. + assert.strictEqual( + helper.getConversationAndMessageAtIndex(-1)?.conversationId, + conversations[1].id + ); + }); + + it('returns undefined if there are no conversations', () => { + const helper = new LeftPaneInboxHelper({ + conversations: [], + pinnedConversations: [], + archivedConversations: [fakeConversation()], + }); + + assert.isUndefined(helper.getConversationAndMessageAtIndex(0)); + assert.isUndefined(helper.getConversationAndMessageAtIndex(1)); + assert.isUndefined(helper.getConversationAndMessageAtIndex(-1)); + }); + }); + + describe('getConversationAndMessageInDirection', () => { + it('returns the next conversation when searching downward', () => { + const pinnedConversations = [fakeConversation(), fakeConversation()]; + const conversations = [fakeConversation()]; + const helper = new LeftPaneInboxHelper({ + conversations, + pinnedConversations, + archivedConversations: [], + }); + + assert.deepEqual( + helper.getConversationAndMessageInDirection( + { direction: FindDirection.Down, unreadOnly: false }, + pinnedConversations[1].id, + undefined + ), + { conversationId: conversations[0].id } + ); + }); + + // Additional tests are found with `getConversationInDirection`. + }); + + describe('shouldRecomputeRowHeights', () => { + it("returns false if the number of conversations in each section doesn't change", () => { + const helper = new LeftPaneInboxHelper({ + conversations: [ + fakeConversation(), + fakeConversation(), + fakeConversation(), + ], + pinnedConversations: [fakeConversation(), fakeConversation()], + archivedConversations: [fakeConversation()], + }); + + assert.isFalse( + helper.shouldRecomputeRowHeights({ + conversations: [ + fakeConversation(), + fakeConversation(), + fakeConversation(), + ], + pinnedConversations: [fakeConversation(), fakeConversation()], + archivedConversations: [fakeConversation(), fakeConversation()], + }) + ); + }); + + it('returns false if the only thing changed is whether conversations are archived', () => { + const helper = new LeftPaneInboxHelper({ + conversations: [ + fakeConversation(), + fakeConversation(), + fakeConversation(), + ], + pinnedConversations: [fakeConversation(), fakeConversation()], + archivedConversations: [fakeConversation()], + }); + + assert.isFalse( + helper.shouldRecomputeRowHeights({ + conversations: [ + fakeConversation(), + fakeConversation(), + fakeConversation(), + ], + pinnedConversations: [fakeConversation(), fakeConversation()], + archivedConversations: [], + }) + ); + }); + + it('returns false if the only thing changed is the number of non-pinned conversations', () => { + const helper = new LeftPaneInboxHelper({ + conversations: [ + fakeConversation(), + fakeConversation(), + fakeConversation(), + ], + pinnedConversations: [fakeConversation(), fakeConversation()], + archivedConversations: [fakeConversation()], + }); + + assert.isFalse( + helper.shouldRecomputeRowHeights({ + conversations: [fakeConversation()], + pinnedConversations: [fakeConversation(), fakeConversation()], + archivedConversations: [fakeConversation(), fakeConversation()], + }) + ); + }); + + it('returns true if the number of pinned conversations changes', () => { + const helper = new LeftPaneInboxHelper({ + conversations: [fakeConversation()], + pinnedConversations: [fakeConversation(), fakeConversation()], + archivedConversations: [fakeConversation()], + }); + + assert.isTrue( + helper.shouldRecomputeRowHeights({ + conversations: [fakeConversation()], + pinnedConversations: [ + fakeConversation(), + fakeConversation(), + fakeConversation(), + ], + archivedConversations: [fakeConversation()], + }) + ); + assert.isTrue( + helper.shouldRecomputeRowHeights({ + conversations: [fakeConversation()], + pinnedConversations: [fakeConversation()], + archivedConversations: [fakeConversation()], + }) + ); + assert.isTrue( + helper.shouldRecomputeRowHeights({ + conversations: [fakeConversation()], + pinnedConversations: [], + archivedConversations: [fakeConversation()], + }) + ); + }); + }); +}); diff --git a/ts/test-node/components/leftPane/LeftPaneSearchHelper_test.ts b/ts/test-node/components/leftPane/LeftPaneSearchHelper_test.ts new file mode 100644 index 0000000000..5c9e558f97 --- /dev/null +++ b/ts/test-node/components/leftPane/LeftPaneSearchHelper_test.ts @@ -0,0 +1,331 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import { v4 as uuid } from 'uuid'; +import { RowType } from '../../../components/ConversationList'; + +import { LeftPaneSearchHelper } from '../../../components/leftPane/LeftPaneSearchHelper'; + +describe('LeftPaneSearchHelper', () => { + const fakeConversation = () => ({ + id: uuid(), + title: uuid(), + type: 'direct' as const, + }); + + const fakeMessage = () => ({ + id: uuid(), + conversationId: uuid(), + }); + + describe('getRowCount', () => { + it('returns 0 when there are no search results', () => { + const helper = new LeftPaneSearchHelper({ + conversationResults: { isLoading: false, results: [] }, + contactResults: { isLoading: false, results: [] }, + messageResults: { isLoading: false, results: [] }, + searchTerm: 'foo', + }); + + assert.strictEqual(helper.getRowCount(), 0); + }); + + it("returns 2 rows for each section of search results that's loading", () => { + const helper = new LeftPaneSearchHelper({ + conversationResults: { isLoading: true }, + contactResults: { isLoading: false, results: [] }, + messageResults: { isLoading: true }, + searchTerm: 'foo', + }); + + assert.strictEqual(helper.getRowCount(), 4); + }); + + it('returns 1 + the number of results, dropping empty sections', () => { + const helper = new LeftPaneSearchHelper({ + conversationResults: { + isLoading: false, + results: [fakeConversation(), fakeConversation()], + }, + contactResults: { isLoading: false, results: [] }, + messageResults: { isLoading: false, results: [fakeMessage()] }, + searchTerm: 'foo', + }); + + assert.strictEqual(helper.getRowCount(), 5); + }); + }); + + describe('getRow', () => { + it('returns header + spinner for loading sections', () => { + const helper = new LeftPaneSearchHelper({ + conversationResults: { isLoading: true }, + contactResults: { isLoading: true }, + messageResults: { isLoading: true }, + searchTerm: 'foo', + }); + + assert.deepEqual(helper.getRow(0), { + type: RowType.Header, + i18nKey: 'conversationsHeader', + }); + assert.deepEqual(helper.getRow(1), { + type: RowType.Spinner, + }); + assert.deepEqual(helper.getRow(2), { + type: RowType.Header, + i18nKey: 'contactsHeader', + }); + assert.deepEqual(helper.getRow(3), { + type: RowType.Spinner, + }); + assert.deepEqual(helper.getRow(4), { + type: RowType.Header, + i18nKey: 'messagesHeader', + }); + assert.deepEqual(helper.getRow(5), { + type: RowType.Spinner, + }); + }); + + it('returns header + results when all sections have loaded with results', () => { + const conversations = [fakeConversation(), fakeConversation()]; + const contacts = [fakeConversation()]; + const messages = [fakeMessage(), fakeMessage()]; + + const helper = new LeftPaneSearchHelper({ + conversationResults: { + isLoading: false, + results: conversations, + }, + contactResults: { isLoading: false, results: contacts }, + messageResults: { isLoading: false, results: messages }, + searchTerm: 'foo', + }); + + assert.deepEqual(helper.getRow(0), { + type: RowType.Header, + i18nKey: 'conversationsHeader', + }); + assert.deepEqual(helper.getRow(1), { + type: RowType.Conversation, + conversation: conversations[0], + }); + assert.deepEqual(helper.getRow(2), { + type: RowType.Conversation, + conversation: conversations[1], + }); + assert.deepEqual(helper.getRow(3), { + type: RowType.Header, + i18nKey: 'contactsHeader', + }); + assert.deepEqual(helper.getRow(4), { + type: RowType.Conversation, + conversation: contacts[0], + }); + assert.deepEqual(helper.getRow(5), { + type: RowType.Header, + i18nKey: 'messagesHeader', + }); + assert.deepEqual(helper.getRow(6), { + type: RowType.MessageSearchResult, + messageId: messages[0].id, + }); + assert.deepEqual(helper.getRow(7), { + type: RowType.MessageSearchResult, + messageId: messages[1].id, + }); + }); + + it('omits conversations when there are no conversation results', () => { + const contacts = [fakeConversation()]; + const messages = [fakeMessage(), fakeMessage()]; + + const helper = new LeftPaneSearchHelper({ + conversationResults: { + isLoading: false, + results: [], + }, + contactResults: { isLoading: false, results: contacts }, + messageResults: { isLoading: false, results: messages }, + searchTerm: 'foo', + }); + + assert.deepEqual(helper.getRow(0), { + type: RowType.Header, + i18nKey: 'contactsHeader', + }); + assert.deepEqual(helper.getRow(1), { + type: RowType.Conversation, + conversation: contacts[0], + }); + assert.deepEqual(helper.getRow(2), { + type: RowType.Header, + i18nKey: 'messagesHeader', + }); + assert.deepEqual(helper.getRow(3), { + type: RowType.MessageSearchResult, + messageId: messages[0].id, + }); + assert.deepEqual(helper.getRow(4), { + type: RowType.MessageSearchResult, + messageId: messages[1].id, + }); + }); + + it('omits contacts when there are no contact results', () => { + const conversations = [fakeConversation(), fakeConversation()]; + const messages = [fakeMessage(), fakeMessage()]; + + const helper = new LeftPaneSearchHelper({ + conversationResults: { + isLoading: false, + results: conversations, + }, + contactResults: { isLoading: false, results: [] }, + messageResults: { isLoading: false, results: messages }, + searchTerm: 'foo', + }); + + assert.deepEqual(helper.getRow(0), { + type: RowType.Header, + i18nKey: 'conversationsHeader', + }); + assert.deepEqual(helper.getRow(1), { + type: RowType.Conversation, + conversation: conversations[0], + }); + assert.deepEqual(helper.getRow(2), { + type: RowType.Conversation, + conversation: conversations[1], + }); + assert.deepEqual(helper.getRow(3), { + type: RowType.Header, + i18nKey: 'messagesHeader', + }); + assert.deepEqual(helper.getRow(4), { + type: RowType.MessageSearchResult, + messageId: messages[0].id, + }); + assert.deepEqual(helper.getRow(5), { + type: RowType.MessageSearchResult, + messageId: messages[1].id, + }); + }); + }); + + it('omits messages when there are no message results', () => { + const conversations = [fakeConversation(), fakeConversation()]; + const contacts = [fakeConversation()]; + + const helper = new LeftPaneSearchHelper({ + conversationResults: { + isLoading: false, + results: conversations, + }, + contactResults: { isLoading: false, results: contacts }, + messageResults: { isLoading: false, results: [] }, + searchTerm: 'foo', + }); + + assert.deepEqual(helper.getRow(0), { + type: RowType.Header, + i18nKey: 'conversationsHeader', + }); + assert.deepEqual(helper.getRow(1), { + type: RowType.Conversation, + conversation: conversations[0], + }); + assert.deepEqual(helper.getRow(2), { + type: RowType.Conversation, + conversation: conversations[1], + }); + assert.deepEqual(helper.getRow(3), { + type: RowType.Header, + i18nKey: 'contactsHeader', + }); + assert.deepEqual(helper.getRow(4), { + type: RowType.Conversation, + conversation: contacts[0], + }); + assert.isUndefined(helper.getRow(5)); + }); + + describe('shouldRecomputeRowHeights', () => { + it("returns false if the number of results doesn't change", () => { + const helper = new LeftPaneSearchHelper({ + conversationResults: { + isLoading: false, + results: [fakeConversation(), fakeConversation()], + }, + contactResults: { isLoading: false, results: [] }, + messageResults: { + isLoading: false, + results: [fakeMessage(), fakeMessage(), fakeMessage()], + }, + searchTerm: 'foo', + }); + + assert.isFalse( + helper.shouldRecomputeRowHeights({ + conversationResults: { + isLoading: false, + results: [fakeConversation(), fakeConversation()], + }, + contactResults: { isLoading: false, results: [] }, + messageResults: { + isLoading: false, + results: [fakeMessage(), fakeMessage(), fakeMessage()], + }, + searchTerm: 'bar', + }) + ); + }); + + it('returns false when a section goes from loading to loaded with 1 result', () => { + const helper = new LeftPaneSearchHelper({ + conversationResults: { isLoading: true }, + contactResults: { isLoading: true }, + messageResults: { isLoading: true }, + searchTerm: 'foo', + }); + + assert.isFalse( + helper.shouldRecomputeRowHeights({ + conversationResults: { + isLoading: false, + results: [fakeConversation()], + }, + contactResults: { isLoading: true }, + messageResults: { isLoading: true }, + searchTerm: 'bar', + }) + ); + }); + + it('returns true if the number of results in a section changes', () => { + const helper = new LeftPaneSearchHelper({ + conversationResults: { + isLoading: false, + results: [fakeConversation(), fakeConversation()], + }, + contactResults: { isLoading: false, results: [] }, + messageResults: { isLoading: false, results: [] }, + searchTerm: 'foo', + }); + + assert.isTrue( + helper.shouldRecomputeRowHeights({ + conversationResults: { + isLoading: false, + results: [fakeConversation()], + }, + contactResults: { isLoading: true }, + messageResults: { isLoading: true }, + searchTerm: 'bar', + }) + ); + }); + }); +}); diff --git a/ts/test-node/components/leftPane/getConversationInDirection_test.ts b/ts/test-node/components/leftPane/getConversationInDirection_test.ts new file mode 100644 index 0000000000..02455f3b84 --- /dev/null +++ b/ts/test-node/components/leftPane/getConversationInDirection_test.ts @@ -0,0 +1,199 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import { v4 as uuid } from 'uuid'; +import { + FindDirection, + ToFindType, +} from '../../../components/leftPane/LeftPaneHelper'; + +import { getConversationInDirection } from '../../../components/leftPane/getConversationInDirection'; + +describe('getConversationInDirection', () => { + const fakeConversation = (markedUnread = false) => ({ + id: uuid(), + title: uuid(), + type: 'direct' as const, + markedUnread, + }); + + const fakeConversations = [ + fakeConversation(), + fakeConversation(true), + fakeConversation(true), + fakeConversation(), + ]; + + describe('searching for any conversation', () => { + const up: ToFindType = { + direction: FindDirection.Up, + unreadOnly: false, + }; + const down: ToFindType = { + direction: FindDirection.Down, + unreadOnly: false, + }; + + it('returns undefined if there are no conversations', () => { + assert.isUndefined(getConversationInDirection([], up, undefined)); + assert.isUndefined(getConversationInDirection([], down, undefined)); + }); + + it('if no conversation is selected, returns the last conversation when going up', () => { + assert.deepEqual( + getConversationInDirection(fakeConversations, up, undefined), + { conversationId: fakeConversations[3].id } + ); + }); + + it('if no conversation is selected, returns the first conversation when going down', () => { + assert.deepEqual( + getConversationInDirection(fakeConversations, down, undefined), + { conversationId: fakeConversations[0].id } + ); + }); + + it('if the first conversation is selected, returns the last conversation when going up', () => { + assert.deepEqual( + getConversationInDirection( + fakeConversations, + up, + fakeConversations[0].id + ), + { conversationId: fakeConversations[3].id } + ); + }); + + it('if the last conversation is selected, returns the first conversation when going down', () => { + assert.deepEqual( + getConversationInDirection( + fakeConversations, + down, + fakeConversations[3].id + ), + { conversationId: fakeConversations[0].id } + ); + }); + + it('goes up one conversation in normal cases', () => { + assert.deepEqual( + getConversationInDirection( + fakeConversations, + up, + fakeConversations[2].id + ), + { conversationId: fakeConversations[1].id } + ); + }); + + it('goes down one conversation in normal cases', () => { + assert.deepEqual( + getConversationInDirection( + fakeConversations, + down, + fakeConversations[0].id + ), + { conversationId: fakeConversations[1].id } + ); + }); + }); + + describe('searching for unread conversations', () => { + const up: ToFindType = { + direction: FindDirection.Up, + unreadOnly: true, + }; + const down: ToFindType = { + direction: FindDirection.Down, + unreadOnly: true, + }; + + const noUnreads = [ + fakeConversation(), + fakeConversation(), + fakeConversation(), + ]; + + it('returns undefined if there are no conversations', () => { + assert.isUndefined(getConversationInDirection([], up, undefined)); + assert.isUndefined(getConversationInDirection([], down, undefined)); + }); + + it('if no conversation is selected, finds the last unread conversation (if it exists) when searching up', () => { + assert.deepEqual( + getConversationInDirection(fakeConversations, up, undefined), + { conversationId: fakeConversations[2].id } + ); + assert.isUndefined(getConversationInDirection(noUnreads, up, undefined)); + }); + + it('if no conversation is selected, finds the first unread conversation (if it exists) when searching down', () => { + assert.deepEqual( + getConversationInDirection(fakeConversations, down, undefined), + { conversationId: fakeConversations[1].id } + ); + assert.isUndefined( + getConversationInDirection(noUnreads, down, undefined) + ); + }); + + it("searches up for unread conversations, returning undefined if no conversation exists (doesn't wrap around)", () => { + assert.deepEqual( + getConversationInDirection( + fakeConversations, + up, + fakeConversations[3].id + ), + { conversationId: fakeConversations[2].id } + ); + assert.deepEqual( + getConversationInDirection( + fakeConversations, + up, + fakeConversations[2].id + ), + { conversationId: fakeConversations[1].id } + ); + assert.isUndefined( + getConversationInDirection( + fakeConversations, + up, + fakeConversations[1].id + ) + ); + assert.isUndefined( + getConversationInDirection(noUnreads, up, noUnreads[2].id) + ); + }); + + it("searches down for unread conversations, returning undefined if no conversation exists (doesn't wrap around)", () => { + assert.deepEqual( + getConversationInDirection( + fakeConversations, + down, + fakeConversations[0].id + ), + { conversationId: fakeConversations[1].id } + ); + assert.deepEqual( + getConversationInDirection( + fakeConversations, + down, + fakeConversations[1].id + ), + { conversationId: fakeConversations[2].id } + ); + assert.isUndefined( + getConversationInDirection( + fakeConversations, + down, + fakeConversations[2].id + ) + ); + assert.isUndefined( + getConversationInDirection(noUnreads, down, noUnreads[1].id) + ); + }); + }); +}); diff --git a/ts/util/deconstructLookup.ts b/ts/util/deconstructLookup.ts new file mode 100644 index 0000000000..c89dc940af --- /dev/null +++ b/ts/util/deconstructLookup.ts @@ -0,0 +1,21 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { getOwn } from './getOwn'; +import { assert } from './assert'; + +export const deconstructLookup = ( + lookup: Record, + keys: ReadonlyArray +): Array => { + const result: Array = []; + keys.forEach((key: string) => { + const value = getOwn(lookup, key); + if (value) { + result.push(value); + } else { + assert(false, `deconstructLookup: lookup failed for ${key}; dropping`); + } + }); + return result; +}; diff --git a/ts/util/isConversationUnread.ts b/ts/util/isConversationUnread.ts new file mode 100644 index 0000000000..a4a6422503 --- /dev/null +++ b/ts/util/isConversationUnread.ts @@ -0,0 +1,13 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { isNumber } from 'lodash'; + +export const isConversationUnread = ({ + markedUnread, + unreadCount, +}: Readonly<{ + unreadCount?: number; + markedUnread?: boolean; +}>): boolean => + Boolean(markedUnread || (isNumber(unreadCount) && unreadCount > 0)); diff --git a/ts/util/isConversationUnregistered.ts b/ts/util/isConversationUnregistered.ts new file mode 100644 index 0000000000..e50b0f1197 --- /dev/null +++ b/ts/util/isConversationUnregistered.ts @@ -0,0 +1,13 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +const SIX_HOURS = 1000 * 60 * 60 * 6; + +export function isConversationUnregistered({ + discoveredUnregisteredAt, +}: Readonly<{ discoveredUnregisteredAt?: number }>): boolean { + return Boolean( + discoveredUnregisteredAt && + discoveredUnregisteredAt < Date.now() - SIX_HOURS + ); +} diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 63727572f2..0255e82074 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -14531,6 +14531,15 @@ "updated": "2020-10-26T23:56:13.482Z", "reasonDetail": "Doesn't refer to a DOM element." }, + { + "rule": "React-useRef", + "path": "ts/components/ConversationList.js", + "line": " const listRef = react_1.useRef(null);", + "lineNumber": 44, + "reasonCategory": "usageTrusted", + "updated": "2021-02-12T16:25:08.285Z", + "reasonDetail": "Used for scroll calculations" + }, { "rule": "React-useRef", "path": "ts/components/DirectCallRemoteParticipant.js", @@ -14584,22 +14593,22 @@ "updated": "2020-07-21T18:34:59.251Z" }, { - "rule": "React-createRef", + "rule": "React-useRef", "path": "ts/components/LeftPane.js", - "line": " this.listRef = react_1.default.createRef();", - "lineNumber": 33, + "line": " const previousModeSpecificPropsRef = react_1.useRef(modeSpecificProps);", + "lineNumber": 47, "reasonCategory": "usageTrusted", - "updated": "2020-09-11T17:24:56.124Z", - "reasonDetail": "Used for scroll calculations" + "updated": "2021-02-12T16:25:08.285Z", + "reasonDetail": "Doesn't interact with the DOM." }, { - "rule": "React-createRef", - "path": "ts/components/LeftPane.js", - "line": " this.containerRef = react_1.default.createRef();", - "lineNumber": 34, + "rule": "React-useRef", + "path": "ts/components/LeftPane.tsx", + "line": " const previousModeSpecificPropsRef = useRef(modeSpecificProps);", + "lineNumber": 104, "reasonCategory": "usageTrusted", - "updated": "2020-09-11T17:24:56.124Z", - "reasonDetail": "Used for scroll calculations" + "updated": "2021-02-12T16:25:08.285Z", + "reasonDetail": "Doesn't interact with the DOM." }, { "rule": "React-createRef", @@ -14640,7 +14649,7 @@ "rule": "React-createRef", "path": "ts/components/MainHeader.tsx", "line": " this.inputRef = React.createRef();", - "lineNumber": 78, + "lineNumber": 79, "reasonCategory": "usageTrusted", "updated": "2020-02-14T20:02:37.507Z", "reasonDetail": "Used only to set focus" @@ -14654,29 +14663,11 @@ "updated": "2020-06-23T06:48:06.829Z", "reasonDetail": "Used to focus cancel button when dialog opens" }, - { - "rule": "React-createRef", - "path": "ts/components/SearchResults.js", - "line": " this.listRef = react_1.default.createRef();", - "lineNumber": 27, - "reasonCategory": "usageTrusted", - "updated": "2019-08-09T00:44:31.008Z", - "reasonDetail": "SearchResults needs to interact with its child List directly" - }, - { - "rule": "React-createRef", - "path": "ts/components/SearchResults.js", - "line": " this.containerRef = react_1.default.createRef();", - "lineNumber": 28, - "reasonCategory": "usageTrusted", - "updated": "2019-08-09T00:44:31.008Z", - "reasonDetail": "SearchResults needs to interact with its child List directly" - }, { "rule": "React-useRef", "path": "ts/components/ShortcutGuide.js", "line": " const focusRef = React.useRef(null);", - "lineNumber": 182, + "lineNumber": 186, "reasonCategory": "usageTrusted", "updated": "2020-10-26T19:12:24.410Z", "reasonDetail": "Only used to focus the element." @@ -15321,4 +15312,4 @@ "updated": "2021-01-08T15:46:32.143Z", "reasonDetail": "Doesn't manipulate the DOM. This is just a function." } -] \ No newline at end of file +] From c33fea35dc51b5b0df76baf2f0ceeac7a195349c Mon Sep 17 00:00:00 2001 From: Jack Lloyd Date: Tue, 23 Feb 2021 18:34:23 -0500 Subject: [PATCH 016/181] Fingerprint using SignalClient library --- ts/util/safetyNumber.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/ts/util/safetyNumber.ts b/ts/util/safetyNumber.ts index 57be845081..8356d9f059 100644 --- a/ts/util/safetyNumber.ts +++ b/ts/util/safetyNumber.ts @@ -1,6 +1,7 @@ // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import { PublicKey, Fingerprint } from 'libsignal-client'; import { ConversationType } from '../state/ducks/conversations'; export async function generateSecurityNumber( @@ -9,12 +10,22 @@ export async function generateSecurityNumber( theirNumber: string, theirKey: ArrayBuffer ): Promise { - return new window.libsignal.FingerprintGenerator(5200).createFor( - ourNumber, - ourKey, - theirNumber, - theirKey + const ourNumberBuf = Buffer.from(ourNumber); + const ourKeyObj = PublicKey.deserialize(Buffer.from(ourKey)); + const theirNumberBuf = Buffer.from(theirNumber); + const theirKeyObj = PublicKey.deserialize(Buffer.from(theirKey)); + + const fingerprint = Fingerprint.new( + 5200, + 2, + ourNumberBuf, + ourKeyObj, + theirNumberBuf, + theirKeyObj ); + + const fingerprintString = fingerprint.displayableFingerprint().toString(); + return Promise.resolve(fingerprintString); } export function getPlaceholder(): string { From 254cddc5146d35505eb70c2249170f675e8df541 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Wed, 24 Feb 2021 11:07:05 -0800 Subject: [PATCH 017/181] Move getPlaceholder to component that needs it --- ts/components/SafetyNumberViewer.tsx | 7 ++++++- ts/util/index.ts | 6 +----- ts/util/safetyNumber.ts | 6 ------ 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/ts/components/SafetyNumberViewer.tsx b/ts/components/SafetyNumberViewer.tsx index 3945112dbd..7c804f2fb3 100644 --- a/ts/components/SafetyNumberViewer.tsx +++ b/ts/components/SafetyNumberViewer.tsx @@ -4,7 +4,6 @@ import React from 'react'; import { ConversationType } from '../state/ducks/conversations'; import { LocalizerType } from '../types/Util'; -import { getPlaceholder } from '../util/safetyNumber'; import { Intl } from './Intl'; export type PropsType = { @@ -112,3 +111,9 @@ export const SafetyNumberViewer = ({
); }; + +function getPlaceholder(): string { + return Array.from(Array(12)) + .map(() => 'XXXXX') + .join(' '); +} diff --git a/ts/util/index.ts b/ts/util/index.ts index b8c53db379..eab75b1aee 100644 --- a/ts/util/index.ts +++ b/ts/util/index.ts @@ -9,10 +9,7 @@ import { createBatcher } from './batcher'; import { createWaitBatcher } from './waitBatcher'; import { deleteForEveryone } from './deleteForEveryone'; import { downloadAttachment } from './downloadAttachment'; -import { - generateSecurityNumber, - getPlaceholder as getSafetyNumberPlaceholder, -} from './safetyNumber'; +import { generateSecurityNumber } from './safetyNumber'; import { getStringForProfileChange } from './getStringForProfileChange'; import { getTextWithMentions } from './getTextWithMentions'; import { getUserAgent } from './getUserAgent'; @@ -36,7 +33,6 @@ export { downloadAttachment, fromWebSafeBase64, generateSecurityNumber, - getSafetyNumberPlaceholder, getStringForProfileChange, getTextWithMentions, getUserAgent, diff --git a/ts/util/safetyNumber.ts b/ts/util/safetyNumber.ts index 8356d9f059..15985d98a1 100644 --- a/ts/util/safetyNumber.ts +++ b/ts/util/safetyNumber.ts @@ -28,12 +28,6 @@ export async function generateSecurityNumber( return Promise.resolve(fingerprintString); } -export function getPlaceholder(): string { - return Array.from(Array(12)) - .map(() => 'XXXXX') - .join(' '); -} - export async function generateSecurityNumberBlock( contact: ConversationType ): Promise> { From 4b28fd896a4b06d5549434765833548f337a3f70 Mon Sep 17 00:00:00 2001 From: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com> Date: Wed, 24 Feb 2021 16:53:49 -0600 Subject: [PATCH 018/181] Disabled
- + + diff --git a/debug_log.html b/debug_log.html index fb5d01cfb6..1ae262a282 100644 --- a/debug_log.html +++ b/debug_log.html @@ -55,8 +55,6 @@ - - diff --git a/debug_log_preload.js b/debug_log_preload.js index 1e97475bca..6f8c18fa91 100644 --- a/debug_log_preload.js +++ b/debug_log_preload.js @@ -29,6 +29,9 @@ window.nodeSetImmediate = setImmediate; window.getNodeVersion = () => config.node_version; window.getEnvironment = getEnvironment; +window.Backbone = require('backbone'); +require('./ts/backbone/views/whisper_view'); +require('./ts/backbone/views/toast_view'); require('./ts/logging/set_up_renderer_logging'); window.closeDebugLog = () => ipcRenderer.send('close-debug-log'); diff --git a/js/views/banner_view.js b/js/views/banner_view.js index 70eea630bf..92e9e6f06f 100644 --- a/js/views/banner_view.js +++ b/js/views/banner_view.js @@ -1,7 +1,7 @@ // Copyright 2017-2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -/* global Whisper */ +/* global Whisper, $ */ // eslint-disable-next-line func-names (function () { @@ -9,7 +9,7 @@ Whisper.BannerView = Whisper.View.extend({ className: 'banner', - templateName: 'banner', + template: () => $('#banner').html(), events: { 'click .dismiss': 'onDismiss', 'click .body': 'onClick', diff --git a/js/views/clear_data_view.js b/js/views/clear_data_view.js index ebdae23e46..b09acaac86 100644 --- a/js/views/clear_data_view.js +++ b/js/views/clear_data_view.js @@ -1,10 +1,7 @@ // Copyright 2018-2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -/* global i18n: false */ -/* global Whisper: false */ - -/* eslint-disable no-new */ +/* global i18n, Whisper, $ */ // eslint-disable-next-line func-names (function () { @@ -16,7 +13,7 @@ DELETING: 2, }; window.Whisper.ClearDataView = Whisper.View.extend({ - templateName: 'clear-data', + template: () => $('#clear-data').html(), className: 'full-screen-flow overlay', events: { 'click .cancel': 'onCancel', diff --git a/js/views/contact_list_view.js b/js/views/contact_list_view.js index fdbe5d2b69..39c32e6133 100644 --- a/js/views/contact_list_view.js +++ b/js/views/contact_list_view.js @@ -1,8 +1,7 @@ // Copyright 2015-2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -/* global Whisper: false */ -/* global textsecure: false */ +/* global Whisper, textsecure, $ */ // eslint-disable-next-line func-names (function () { @@ -13,7 +12,7 @@ itemView: Whisper.View.extend({ tagName: 'div', className: 'contact', - templateName: 'contact', + template: () => $('#contact').html(), initialize(options) { this.ourNumber = textsecure.storage.user.getNumber(); this.listenBack = options.listenBack; diff --git a/js/views/debug_log_view.js b/js/views/debug_log_view.js index c4b737679a..239ae7da36 100644 --- a/js/views/debug_log_view.js +++ b/js/views/debug_log_view.js @@ -1,8 +1,7 @@ // Copyright 2015-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -/* global i18n: false */ -/* global Whisper: false */ +/* global i18n, Whisper, $ */ // eslint-disable-next-line func-names (function () { @@ -31,7 +30,7 @@ }); Whisper.DebugLogLinkView = Whisper.View.extend({ - templateName: 'debug-log-link', + template: () => $('#debug-log-link').html(), initialize(options) { this.url = options.url; }, @@ -66,7 +65,7 @@ * edit them in their own editor. This is mostly a stopgap solution. */ Whisper.DebugLogView = Whisper.View.extend({ - templateName: 'debug-log', + template: () => $('#debug-log').html(), className: 'debug-log modal', initialize() { this.render(); diff --git a/js/views/group_member_list_view.js b/js/views/group_member_list_view.js index d0c75b6161..2ade0085aa 100644 --- a/js/views/group_member_list_view.js +++ b/js/views/group_member_list_view.js @@ -1,16 +1,15 @@ // Copyright 2015-2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -/* global Whisper, i18n */ +/* global Whisper, i18n, $ */ // eslint-disable-next-line func-names (function () { window.Whisper = window.Whisper || {}; - // TODO: take a title string which could replace the 'members' header Whisper.GroupMemberList = Whisper.View.extend({ className: 'group-member-list panel', - templateName: 'group-member-list', + template: () => $('#group-member-list').html(), initialize(options) { this.needVerify = options.needVerify; diff --git a/js/views/identicon_svg_view.js b/js/views/identicon_svg_view.js index 9933ca8acb..7bad681e71 100644 --- a/js/views/identicon_svg_view.js +++ b/js/views/identicon_svg_view.js @@ -1,7 +1,7 @@ // Copyright 2015-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -/* global Whisper, loadImage */ +/* global Whisper, loadImage, $ */ // eslint-disable-next-line func-names (function () { @@ -11,7 +11,7 @@ * Render an avatar identicon to an svg for use in a notification. */ Whisper.IdenticonSVGView = Whisper.View.extend({ - templateName: 'identicon-svg', + template: () => $('#identicon-svg').html(), initialize(options) { this.render_attributes = options; this.render_attributes.color = COLORS[this.render_attributes.color]; diff --git a/js/views/inbox_view.js b/js/views/inbox_view.js index 7d23b4bb96..f6db2621e6 100644 --- a/js/views/inbox_view.js +++ b/js/views/inbox_view.js @@ -5,7 +5,8 @@ ConversationController, i18n, Whisper, - Signal + Signal, + $ */ // eslint-disable-next-line func-names @@ -60,7 +61,7 @@ }); Whisper.AppLoadingScreen = Whisper.View.extend({ - templateName: 'app-loading-screen', + template: () => $('#app-loading-screen').html(), className: 'app-loading-screen', updateProgress(count) { if (count > 0) { @@ -74,7 +75,7 @@ }); Whisper.InboxView = Whisper.View.extend({ - templateName: 'two-column', + template: () => $('#two-column').html(), className: 'inbox index', initialize(options = {}) { this.ready = false; diff --git a/js/views/install_view.js b/js/views/install_view.js index 221ccb888f..a188abd486 100644 --- a/js/views/install_view.js +++ b/js/views/install_view.js @@ -24,7 +24,7 @@ const TOO_OLD = 409; Whisper.InstallView = Whisper.View.extend({ - templateName: 'link-flow-template', + template: () => $('#link-flow-template').html(), className: 'main full-screen-flow', events: { 'click .try-again': 'connect', diff --git a/js/views/key_verification_view.js b/js/views/key_verification_view.js index 116f3cce94..d5c49a68e7 100644 --- a/js/views/key_verification_view.js +++ b/js/views/key_verification_view.js @@ -1,9 +1,7 @@ // Copyright 2015-2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -/* global Backbone, Signal, Whisper */ - -/* eslint-disable more/no-then */ +/* global Backbone, Signal, Whisper, $ */ // eslint-disable-next-line func-names (function () { @@ -11,7 +9,7 @@ Whisper.KeyVerificationPanelView = Whisper.View.extend({ className: 'panel', - templateName: 'key-verification', + template: () => $('#key-verification').html(), initialize(options) { this.render(); diff --git a/js/views/phone-input-view.js b/js/views/phone-input-view.js index 8e185fcc96..1b74d6500d 100644 --- a/js/views/phone-input-view.js +++ b/js/views/phone-input-view.js @@ -1,7 +1,7 @@ // Copyright 2015-2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -/* global libphonenumber, Whisper */ +/* global libphonenumber, Whisper, $ */ // eslint-disable-next-line func-names (function () { @@ -10,7 +10,7 @@ Whisper.PhoneInputView = Whisper.View.extend({ tagName: 'div', className: 'phone-input', - templateName: 'phone-number', + template: () => $('#phone-number').html(), initialize() { this.$('input.number').intlTelInput(); }, diff --git a/js/views/recorder_view.js b/js/views/recorder_view.js index b481da405c..a982f9f4ff 100644 --- a/js/views/recorder_view.js +++ b/js/views/recorder_view.js @@ -11,7 +11,7 @@ Whisper.RecorderView = Whisper.View.extend({ className: 'recorder clearfix', - templateName: 'recorder', + template: () => $('#recorder').html(), initialize() { this.startTime = Date.now(); this.interval = setInterval(this.updateTime.bind(this), 1000); diff --git a/js/views/safety_number_change_dialog_view.js b/js/views/safety_number_change_dialog_view.js index 64ff820016..792ae258e1 100644 --- a/js/views/safety_number_change_dialog_view.js +++ b/js/views/safety_number_change_dialog_view.js @@ -1,14 +1,14 @@ // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -/* global Whisper, Signal */ +/* global Whisper, Signal, $ */ // eslint-disable-next-line func-names (function () { window.Whisper = window.Whisper || {}; Whisper.SafetyNumberChangeDialogView = Whisper.View.extend({ - templateName: 'safety-number-change-dialog', + template: () => $('#safety-number-change-dialog').html(), initialize(options) { const dialog = new Whisper.ReactWrapperView({ Component: window.Signal.Components.SafetyNumberChangeDialog, diff --git a/js/views/settings_view.js b/js/views/settings_view.js index d5eff2de3e..ebc652996d 100644 --- a/js/views/settings_view.js +++ b/js/views/settings_view.js @@ -91,7 +91,7 @@ }); Whisper.SettingsView = Whisper.View.extend({ className: 'settings modal expand', - templateName: 'settings', + template: () => $('#settings').html(), initialize() { this.render(); new RadioButtonGroupView({ @@ -271,7 +271,7 @@ }); const SyncView = Whisper.View.extend({ - templateName: 'syncSettings', + template: () => $('#syncSettings').html(), className: 'syncSettings', events: { 'click .sync': 'sync', diff --git a/js/views/standalone_registration_view.js b/js/views/standalone_registration_view.js index 3df07054c2..d5dfe9251f 100644 --- a/js/views/standalone_registration_view.js +++ b/js/views/standalone_registration_view.js @@ -10,7 +10,7 @@ window.Whisper = window.Whisper || {}; Whisper.StandaloneRegistrationView = Whisper.View.extend({ - templateName: 'standalone', + template: () => $('#standalone').html(), className: 'full-screen-flow', initialize() { window.readyForUpdates(); diff --git a/js/views/toast_view.js b/js/views/toast_view.js deleted file mode 100644 index 51e14a2132..0000000000 --- a/js/views/toast_view.js +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright 2015-2020 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -/* global Whisper, Mustache, _ */ - -// eslint-disable-next-line func-names -(function () { - window.Whisper = window.Whisper || {}; - - Whisper.ToastView = Whisper.View.extend({ - className: 'toast', - templateName: 'toast', - initialize() { - this.$el.hide(); - this.timeout = 2000; - }, - - close() { - this.$el.fadeOut(this.remove.bind(this)); - }, - - render() { - this.$el.html( - Mustache.render( - _.result(this, 'template', ''), - _.result(this, 'render_attributes', '') - ) - ); - this.$el.attr('tabIndex', 0); - this.$el.show(); - setTimeout(this.close.bind(this), this.timeout); - }, - }); - - Whisper.ToastView.show = (View, el) => { - const toast = new View(); - toast.$el.appendTo(el); - toast.render(); - }; -})(); diff --git a/js/views/whisper_view.js b/js/views/whisper_view.js deleted file mode 100644 index 4fb4fbaddf..0000000000 --- a/js/views/whisper_view.js +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright 2015-2020 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -/* global Whisper, Backbone, Mustache, _, $ */ - -/* - * Whisper.View - * - * This is the base for most of our views. The Backbone view is extended - * with some conveniences: - * - * 1. Pre-parses all our mustache templates for performance. - * https://github.com/janl/mustache.js#pre-parsing-and-caching-templates - * - * 2. Defines a default definition for render() which allows sub-classes - * to simply specify a templateName and renderAttributes which are plugged - * into Mustache.render - * - * 3. Makes all the templates available for rendering as partials. - * https://github.com/janl/mustache.js#partials - * - * 4. Provides some common functionality, e.g. confirmation dialog - * - */ - -// eslint-disable-next-line func-names -(function () { - window.Whisper = window.Whisper || {}; - - Whisper.View = Backbone.View.extend( - { - constructor(...params) { - Backbone.View.call(this, ...params); - Mustache.parse(_.result(this, 'template')); - }, - render_attributes() { - return _.result(this.model, 'attributes', {}); - }, - render_partials() { - return Whisper.View.Templates; - }, - template() { - if (this.templateName) { - return Whisper.View.Templates[this.templateName]; - } - return ''; - }, - render() { - const attrs = _.result(this, 'render_attributes', {}); - const template = _.result(this, 'template', ''); - const partials = _.result(this, 'render_partials', ''); - this.$el.html(Mustache.render(template, attrs, partials)); - return this; - }, - confirm(message, okText) { - return new Promise((resolve, reject) => { - window.showConfirmationDialog({ - message, - okText, - resolve, - reject, - }); - }); - }, - }, - { - // Class attributes - Templates: (() => { - const templates = {}; - $('script[type="text/x-tmpl-mustache"]').each((i, el) => { - const $el = $(el); - const id = $el.attr('id'); - templates[id] = $el.html(); - }); - return templates; - })(), - } - ); -})(); diff --git a/permissions_popup.html b/permissions_popup.html index da6d2cd1b7..14b6103a71 100644 --- a/permissions_popup.html +++ b/permissions_popup.html @@ -25,7 +25,6 @@ - diff --git a/preload.js b/preload.js index fa1b311118..386fcf9d40 100644 --- a/preload.js +++ b/preload.js @@ -520,6 +520,11 @@ try { require('./ts/models/messages'); require('./ts/models/conversations'); + require('./ts/backbone/views/whisper_view'); + require('./ts/backbone/views/toast_view'); + require('./ts/views/conversation_view'); + require('./ts/background'); + function wrapWithPromise(fn) { return (...args) => Promise.resolve(fn(...args)); } diff --git a/settings.html b/settings.html index 3c37c5e3f1..fde6bcf2e9 100644 --- a/settings.html +++ b/settings.html @@ -168,7 +168,6 @@ - diff --git a/settings_preload.js b/settings_preload.js index ca280c26c1..9a4d21aa62 100644 --- a/settings_preload.js +++ b/settings_preload.js @@ -126,6 +126,7 @@ function makeSetter(name) { }); } -require('./ts/logging/set_up_renderer_logging'); - window.Backbone = require('backbone'); +require('./ts/backbone/views/whisper_view'); +require('./ts/backbone/views/toast_view'); +require('./ts/logging/set_up_renderer_logging'); diff --git a/ts/backbone/views/toast_view.ts b/ts/backbone/views/toast_view.ts new file mode 100644 index 0000000000..1e46c68a83 --- /dev/null +++ b/ts/backbone/views/toast_view.ts @@ -0,0 +1,35 @@ +// Copyright 2015-2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +window.Whisper = window.Whisper || {}; + +window.Whisper.ToastView = window.Whisper.View.extend({ + className: 'toast', + template: () => $('#toast').html(), + initialize() { + this.$el.hide(); + this.timeout = 2000; + }, + + close() { + this.$el.fadeOut(this.remove.bind(this)); + }, + + render() { + this.$el.html( + window.Mustache.render( + window._.result(this, 'template', ''), + window._.result(this, 'render_attributes', '') + ) + ); + this.$el.attr('tabIndex', 0); + this.$el.show(); + setTimeout(this.close.bind(this), this.timeout); + }, +}); + +window.Whisper.ToastView.show = (View, el) => { + const toast = new View(); + toast.$el.appendTo(el); + toast.render(); +}; diff --git a/ts/backbone/views/whisper_view.ts b/ts/backbone/views/whisper_view.ts new file mode 100644 index 0000000000..21888400ab --- /dev/null +++ b/ts/backbone/views/whisper_view.ts @@ -0,0 +1,32 @@ +// Copyright 2015-2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +/* + * Defines a default definition for render() which allows sub-classes + * to simply specify a template property and renderAttributes which are plugged + * into Mustache.render + */ + +// eslint-disable-next-line func-names +(function () { + window.Whisper = window.Whisper || {}; + + window.Whisper.View = Backbone.View.extend({ + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + constructor(...params: Array) { + window.Backbone.View.call(this, ...params); + + // Checks for syntax errors + window.Mustache.parse(_.result(this, 'template')); + }, + render_attributes() { + return _.result(this.model, 'attributes', {}); + }, + render() { + const attrs = window._.result(this, 'render_attributes', {}); + const template = window._.result(this, 'template', ''); + this.$el.html(window.Mustache.render(template, attrs)); + return this; + }, + }); +})(); diff --git a/ts/background.ts b/ts/background.ts index c02714d3af..59d95fe465 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -1,15 +1,10 @@ // Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -// This allows us to pull in types despite the fact that this is not a module. We can't -// use normal import syntax, nor can we use 'import type' syntax, or this will be turned -// into a module, and we'll get the dreaded 'exports is not defined' error. -// see https://github.com/microsoft/TypeScript/issues/41562 -type DataMessageClass = import('./textsecure.d').DataMessageClass; -type WhatIsThis = import('./window.d').WhatIsThis; +import { DataMessageClass } from './textsecure.d'; +import { WhatIsThis } from './window.d'; -// eslint-disable-next-line func-names -(async function () { +export async function startApp(): Promise { const eventHandlerQueue = new window.PQueue({ concurrency: 1, timeout: 1000 * 60 * 2, @@ -23,7 +18,7 @@ type WhatIsThis = import('./window.d').WhatIsThis; wait: 500, maxSize: 500, processBatch: async (items: WhatIsThis) => { - const byConversationId = _.groupBy(items, item => + const byConversationId = window._.groupBy(items, item => window.ConversationController.ensureContactIds({ e164: item.source, uuid: item.sourceUuid, @@ -205,12 +200,12 @@ type WhatIsThis = import('./window.d').WhatIsThis; if (messageReceiver) { return messageReceiver.getStatus(); } - if (_.isNumber(preMessageReceiverStatus)) { + if (window._.isNumber(preMessageReceiverStatus)) { return preMessageReceiverStatus; } return WebSocket.CLOSED; }; - window.Whisper.events = _.clone(window.Backbone.Events); + window.Whisper.events = window._.clone(window.Backbone.Events); let accountManager: typeof window.textsecure.AccountManager; window.getAccountManager = () => { if (!accountManager) { @@ -2632,7 +2627,7 @@ type WhatIsThis = import('./window.d').WhatIsThis; sentTo = data.unidentifiedStatus.map( (item: WhatIsThis) => item.destinationUuid || item.destination ); - const unidentified = _.filter(data.unidentifiedStatus, item => + const unidentified = window._.filter(data.unidentifiedStatus, item => Boolean(item.unidentified) ); // eslint-disable-next-line no-param-reassign @@ -3284,4 +3279,6 @@ type WhatIsThis = import('./window.d').WhatIsThis; // Note: We don't wait for completion here window.Whisper.DeliveryReceipts.onReceipt(receipt); } -})(); +} + +window.startApp = startApp; diff --git a/ts/groups/joinViaLink.ts b/ts/groups/joinViaLink.ts index 1d23c1a24a..1c9347f72a 100644 --- a/ts/groups/joinViaLink.ts +++ b/ts/groups/joinViaLink.ts @@ -61,7 +61,7 @@ export async function joinViaLink(hash: string): Promise { window.reduxActions.conversations.openConversationInternal({ conversationId: existingConversation.id, }); - window.window.Whisper.ToastView.show( + window.Whisper.ToastView.show( window.Whisper.AlreadyGroupMemberToast, document.getElementsByClassName('conversation-stack')[0] ); @@ -333,7 +333,7 @@ export async function joinViaLink(hash: string): Promise { window.log.info(`joinViaLink/${logId}: Showing modal`); - let groupV2InfoDialog = new Whisper.ReactWrapperView({ + let groupV2InfoDialog = new window.Whisper.ReactWrapperView({ className: 'group-v2-join-dialog-wrapper', JSX: window.Signal.State.Roots.createGroupV2JoinModal(window.reduxStore, { join, diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index bffc43a8f2..a378a1c8f2 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -4052,7 +4052,7 @@ export class ConversationModel extends window.Backbone.Model< this.set({ left: true }); window.Signal.Data.updateConversation(this.attributes); - const model = new Whisper.Message(({ + const model = new window.Whisper.Message(({ group_update: { left: 'You' }, conversationId: this.id, type: 'outgoing', @@ -4062,7 +4062,7 @@ export class ConversationModel extends window.Backbone.Model< } as unknown) as MessageAttributesType); const id = await window.Signal.Data.saveMessage(model.attributes, { - Message: Whisper.Message, + Message: window.Whisper.Message, }); model.set({ id }); @@ -5128,13 +5128,13 @@ window.Whisper.ConversationCollection = window.Backbone.Collection.extend({ // We create a new model if it's not already a model if (!item.get) { - hydratedData.push(new Whisper.Conversation(item)); + hydratedData.push(new window.Whisper.Conversation(item)); } else { hydratedData.push(item); } } } else if (!data.get) { - hydratedData = new Whisper.Conversation(data); + hydratedData = new window.Whisper.Conversation(data); } else { hydratedData = data; } diff --git a/ts/shims/showConfirmationDialog.tsx b/ts/shims/showConfirmationDialog.tsx index 71decba039..89a62b0c7d 100644 --- a/ts/shims/showConfirmationDialog.tsx +++ b/ts/shims/showConfirmationDialog.tsx @@ -13,7 +13,7 @@ type ConfirmationDialogViewProps = { confirmStyle?: 'affirmative' | 'negative'; message: string; okText: string; - reject?: () => void; + reject?: (error: Error) => void; resolve: () => void; }; @@ -65,7 +65,7 @@ function showConfirmationDialog(options: ConfirmationDialogViewProps) { onClose={() => { removeConfirmationDialog(); if (options.reject) { - options.reject(); + options.reject(new Error('showConfirmationDialog: onClose called')); } }} title={options.message} diff --git a/ts/test-electron/models/messages_test.ts b/ts/test-electron/models/messages_test.ts index adbf2e2d18..9dafe98961 100644 --- a/ts/test-electron/models/messages_test.ts +++ b/ts/test-electron/models/messages_test.ts @@ -22,7 +22,7 @@ describe('Message', () => { const ourUuid = window.getGuid(); function createMessage(attrs: { [key: string]: unknown }) { - const messages = new Whisper.MessageCollection(); + const messages = new window.Whisper.MessageCollection(); return messages.add(attrs); } @@ -136,13 +136,13 @@ describe('Message', () => { describe('getContact', () => { it('gets outgoing contact', () => { - const messages = new Whisper.MessageCollection(); + const messages = new window.Whisper.MessageCollection(); const message = messages.add(attributes); message.getContact(); }); it('gets incoming contact', () => { - const messages = new Whisper.MessageCollection(); + const messages = new window.Whisper.MessageCollection(); const message = messages.add({ type: 'incoming', source, @@ -153,7 +153,7 @@ describe('Message', () => { describe('isIncoming', () => { it('checks if is incoming message', () => { - const messages = new Whisper.MessageCollection(); + const messages = new window.Whisper.MessageCollection(); let message = messages.add(attributes); assert.notOk(message.isIncoming()); message = messages.add({ type: 'incoming' }); @@ -163,7 +163,7 @@ describe('Message', () => { describe('isOutgoing', () => { it('checks if is outgoing message', () => { - const messages = new Whisper.MessageCollection(); + const messages = new window.Whisper.MessageCollection(); let message = messages.add(attributes); assert.ok(message.isOutgoing()); message = messages.add({ type: 'incoming' }); @@ -173,7 +173,7 @@ describe('Message', () => { describe('isGroupUpdate', () => { it('checks if is group update', () => { - const messages = new Whisper.MessageCollection(); + const messages = new window.Whisper.MessageCollection(); let message = messages.add(attributes); assert.notOk(message.isGroupUpdate()); @@ -584,7 +584,7 @@ describe('Message', () => { describe('isEndSession', () => { it('checks if it is end of the session', () => { - const messages = new Whisper.MessageCollection(); + const messages = new window.Whisper.MessageCollection(); let message = messages.add(attributes); assert.notOk(message.isEndSession()); @@ -596,7 +596,7 @@ describe('Message', () => { describe('MessageCollection', () => { it('should be ordered oldest to newest', () => { - const messages = new Whisper.MessageCollection(); + const messages = new window.Whisper.MessageCollection(); // Timestamps const today = Date.now(); const tomorrow = today + 12345; diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 0255e82074..82953a14f8 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -316,11 +316,65 @@ "updated": "2018-09-19T18:13:29.628Z", "reasonDetail": "Interacting with already-existing DOM nodes" }, + { + "rule": "jQuery-$(", + "path": "js/views/banner_view.js", + "line": " template: () => $('#banner').html(),", + "lineNumber": 12, + "reasonCategory": "usageTrusted", + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, read-only access" + }, + { + "rule": "jQuery-html(", + "path": "js/views/banner_view.js", + "line": " template: () => $('#banner').html(),", + "lineNumber": 12, + "reasonCategory": "usageTrusted", + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, read-only access" + }, + { + "rule": "jQuery-$(", + "path": "js/views/clear_data_view.js", + "line": " template: () => $('#clear-data').html(),", + "lineNumber": 16, + "reasonCategory": "usageTrusted", + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, read-only access" + }, + { + "rule": "jQuery-html(", + "path": "js/views/clear_data_view.js", + "line": " template: () => $('#clear-data').html(),", + "lineNumber": 16, + "reasonCategory": "usageTrusted", + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, read-only access" + }, + { + "rule": "jQuery-$(", + "path": "js/views/contact_list_view.js", + "line": " template: () => $('#contact').html(),", + "lineNumber": 15, + "reasonCategory": "usageTrusted", + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, read-only access" + }, + { + "rule": "jQuery-html(", + "path": "js/views/contact_list_view.js", + "line": " template: () => $('#contact').html(),", + "lineNumber": 15, + "reasonCategory": "usageTrusted", + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, read-only access" + }, { "rule": "jQuery-append(", "path": "js/views/contact_list_view.js", "line": " this.$el.append(this.contactView.el);", - "lineNumber": 45, + "lineNumber": 44, "reasonCategory": "usageTrusted", "updated": "2019-07-31T00:19:18.696Z", "reasonDetail": "Known DOM elements" @@ -328,110 +382,182 @@ { "rule": "jQuery-$(", "path": "js/views/debug_log_view.js", - "line": " this.textarea = this.$('.textarea').get(0);", - "lineNumber": 74, + "line": " template: () => $('#debug-log-link').html(),", + "lineNumber": 33, "reasonCategory": "usageTrusted", - "updated": "2021-01-13T23:20:15.717Z", - "reasonDetail": "Needed to get a reference to a textarea." + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, read-only access" + }, + { + "rule": "jQuery-html(", + "path": "js/views/debug_log_view.js", + "line": " template: () => $('#debug-log-link').html(),", + "lineNumber": 33, + "reasonCategory": "usageTrusted", + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, read-only access" + }, + { + "rule": "jQuery-$(", + "path": "js/views/debug_log_view.js", + "line": " template: () => $('#debug-log').html(),", + "lineNumber": 68, + "reasonCategory": "usageTrusted", + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, read-only access" + }, + { + "rule": "jQuery-html(", + "path": "js/views/debug_log_view.js", + "line": " template: () => $('#debug-log').html(),", + "lineNumber": 68, + "reasonCategory": "usageTrusted", + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, read-only access" + }, + { + "rule": "jQuery-$(", + "path": "js/views/debug_log_view.js", + "line": " this.textarea = this.$('.textarea').get(0);", + "lineNumber": 73, + "reasonCategory": "usageTrusted", + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, read-only access" }, { "rule": "jQuery-$(", "path": "js/views/debug_log_view.js", "line": " this.$('.submit').attr('disabled', 'disabled');", - "lineNumber": 106, + "lineNumber": 105, "reasonCategory": "usageTrusted", - "updated": "2021-01-13T23:20:15.717Z", - "reasonDetail": "Only changes attributes; doesn't write other data to the DOM. Is also trusted input." + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, attribute change" }, { "rule": "jQuery-$(", "path": "js/views/debug_log_view.js", "line": " this.$('.submit').removeAttr('disabled');", - "lineNumber": 120, + "lineNumber": 119, "reasonCategory": "usageTrusted", - "updated": "2021-01-13T23:20:15.717Z", - "reasonDetail": "Only changes attributes; doesn't write other data to the DOM. Is also trusted input." + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, attribute change" }, { "rule": "jQuery-$(", "path": "js/views/debug_log_view.js", "line": " this.$('.buttons, .textarea').remove();", - "lineNumber": 178, + "lineNumber": 177, "reasonCategory": "usageTrusted", - "updated": "2020-05-01T17:11:39.527Z", - "reasonDetail": "Protected from arbitrary input" + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, DOM removal" }, { "rule": "jQuery-$(", "path": "js/views/debug_log_view.js", "line": " this.$('.result').addClass('loading');", - "lineNumber": 179, + "lineNumber": 178, "reasonCategory": "usageTrusted", - "updated": "2020-09-11T17:24:56.124Z", - "reasonDetail": "Static selector argument" + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, addition of a class" }, { "rule": "jQuery-$(", "path": "js/views/debug_log_view.js", "line": " el: this.$('.result'),", - "lineNumber": 188, + "lineNumber": 187, "reasonCategory": "usageTrusted", - "updated": "2020-05-01T17:11:39.527Z", - "reasonDetail": "Protected from arbitrary input" + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Providing a reference to a local DOM node to a trusted view" }, { "rule": "jQuery-$(", "path": "js/views/debug_log_view.js", "line": " this.$('.loading').removeClass('loading');", - "lineNumber": 190, + "lineNumber": 189, "reasonCategory": "usageTrusted", - "updated": "2020-05-01T17:11:39.527Z", - "reasonDetail": "Protected from arbitrary input" + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, removal of a class" }, { "rule": "jQuery-$(", "path": "js/views/debug_log_view.js", "line": " this.$('.link').focus().select();", - "lineNumber": 192, + "lineNumber": 191, "reasonCategory": "usageTrusted", - "updated": "2020-11-18T03:39:29.033Z", - "reasonDetail": "Protected from arbitrary input" + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, setting focus" }, { "rule": "jQuery-$(", "path": "js/views/debug_log_view.js", "line": " this.$('.loading').removeClass('loading');", - "lineNumber": 198, + "lineNumber": 197, "reasonCategory": "usageTrusted", - "updated": "2020-11-18T03:39:29.033Z", - "reasonDetail": "Protected from arbitrary input" + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, removal of a class" }, { "rule": "jQuery-$(", "path": "js/views/debug_log_view.js", "line": " this.$('.result').text(i18n('debugLogError'));", - "lineNumber": 199, + "lineNumber": 198, "reasonCategory": "usageTrusted", - "updated": "2020-11-18T03:39:29.033Z", - "reasonDetail": "Protected from arbitrary input" + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, adding plain text" + }, + { + "rule": "jQuery-$(", + "path": "js/views/group_member_list_view.js", + "line": " template: () => $('#group-member-list').html(),", + "lineNumber": 12, + "reasonCategory": "usageTrusted", + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, read-only access" + }, + { + "rule": "jQuery-html(", + "path": "js/views/group_member_list_view.js", + "line": " template: () => $('#group-member-list').html(),", + "lineNumber": 12, + "reasonCategory": "usageTrusted", + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, read-only access" }, { "rule": "jQuery-$(", "path": "js/views/group_member_list_view.js", "line": " this.$('.container').append(this.member_list_view.el);", - "lineNumber": 29, + "lineNumber": 28, "reasonCategory": "usageTrusted", - "updated": "2018-09-19T21:59:32.770Z", - "reasonDetail": "Protected from arbitrary input" + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, adding sub-view to DOM" }, { "rule": "jQuery-append(", "path": "js/views/group_member_list_view.js", "line": " this.$('.container').append(this.member_list_view.el);", - "lineNumber": 29, + "lineNumber": 28, "reasonCategory": "usageTrusted", - "updated": "2018-09-19T18:13:29.628Z", - "reasonDetail": "Interacting with already-existing DOM nodes" + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, adding sub-view to DOM" + }, + { + "rule": "jQuery-$(", + "path": "js/views/identicon_svg_view.js", + "line": " template: () => $('#identicon-svg').html(),", + "lineNumber": 14, + "reasonCategory": "usageTrusted", + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, read-only access" + }, + { + "rule": "jQuery-html(", + "path": "js/views/identicon_svg_view.js", + "line": " template: () => $('#identicon-svg').html(),", + "lineNumber": 14, + "reasonCategory": "usageTrusted", + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, read-only access" }, { "rule": "jQuery-html(", @@ -446,154 +572,208 @@ "rule": "jQuery-appendTo(", "path": "js/views/inbox_view.js", "line": " view.$el.appendTo(this.el);", - "lineNumber": 34, + "lineNumber": 35, "reasonCategory": "usageTrusted", - "updated": "2019-10-21T22:30:15.622Z", - "reasonDetail": "Known DOM elements" + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, adding sub-view to DOM" + }, + { + "rule": "jQuery-$(", + "path": "js/views/inbox_view.js", + "line": " template: () => $('#app-loading-screen').html(),", + "lineNumber": 64, + "reasonCategory": "usageTrusted", + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, read-only access" + }, + { + "rule": "jQuery-html(", + "path": "js/views/inbox_view.js", + "line": " template: () => $('#app-loading-screen').html(),", + "lineNumber": 64, + "reasonCategory": "usageTrusted", + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, read-only access" }, { "rule": "jQuery-$(", "path": "js/views/inbox_view.js", "line": " this.$('.message').text(message);", - "lineNumber": 68, + "lineNumber": 69, "reasonCategory": "usageTrusted", - "updated": "2019-10-21T22:30:15.622Z", - "reasonDetail": "Hardcoded selector" + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, adding plain text" + }, + { + "rule": "jQuery-$(", + "path": "js/views/inbox_view.js", + "line": " template: () => $('#two-column').html(),", + "lineNumber": 78, + "reasonCategory": "usageTrusted", + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, read-only access" + }, + { + "rule": "jQuery-html(", + "path": "js/views/inbox_view.js", + "line": " template: () => $('#two-column').html(),", + "lineNumber": 78, + "reasonCategory": "usageTrusted", + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, read-only access" }, { "rule": "jQuery-$(", "path": "js/views/inbox_view.js", "line": " el: this.$('.conversation-stack'),", - "lineNumber": 84, + "lineNumber": 85, "reasonCategory": "usageTrusted", - "updated": "2019-10-21T22:30:15.622Z", - "reasonDetail": "Hardcoded selector" + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Providing reference to DOM for sub-view" }, { "rule": "jQuery-prependTo(", "path": "js/views/inbox_view.js", "line": " this.appLoadingScreen.$el.prependTo(this.el);", - "lineNumber": 98, + "lineNumber": 99, "reasonCategory": "usageTrusted", - "updated": "2019-10-21T22:30:15.622Z", - "reasonDetail": "Known DOM elements" + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Adding sub-view to DOM" }, { "rule": "jQuery-appendTo(", "path": "js/views/inbox_view.js", "line": " toast.$el.appendTo(this.$el);", - "lineNumber": 107, + "lineNumber": 108, "reasonCategory": "usageTrusted", - "updated": "2020-05-28T17:42:35.329Z", - "reasonDetail": "Known DOM elements" + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Adding sub-view to DOM" }, { "rule": "jQuery-$(", "path": "js/views/inbox_view.js", "line": " this.$('.call-manager-placeholder').append(this.callManagerView.el);", - "lineNumber": 126, + "lineNumber": 127, "reasonCategory": "usageTrusted", - "updated": "2020-05-28T17:42:35.329Z", - "reasonDetail": "" + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Adding sub-view to DOM" }, { "rule": "jQuery-append(", "path": "js/views/inbox_view.js", "line": " this.$('.call-manager-placeholder').append(this.callManagerView.el);", - "lineNumber": 126, + "lineNumber": 127, "reasonCategory": "usageTrusted", - "updated": "2020-05-28T17:42:35.329Z", - "reasonDetail": "" + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Adding sub-view to DOM" }, { "rule": "jQuery-$(", "path": "js/views/inbox_view.js", "line": " this.$('.left-pane-placeholder').append(this.leftPaneView.el);", - "lineNumber": 137, + "lineNumber": 138, "reasonCategory": "usageTrusted", - "updated": "2020-05-28T17:42:35.329Z", - "reasonDetail": "Known DOM elements" + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Adding sub-view to DOM" }, { "rule": "jQuery-append(", "path": "js/views/inbox_view.js", "line": " this.$('.left-pane-placeholder').append(this.leftPaneView.el);", - "lineNumber": 137, + "lineNumber": 138, "reasonCategory": "usageTrusted", - "updated": "2020-05-28T17:42:35.329Z", - "reasonDetail": "Known DOM elements" + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Adding sub-view to DOM" }, { "rule": "jQuery-$(", "path": "js/views/inbox_view.js", "line": " if (e && this.$(e.target).closest('.placeholder').length) {", - "lineNumber": 190, + "lineNumber": 191, "reasonCategory": "usageTrusted", - "updated": "2020-05-28T17:42:35.329Z", - "reasonDetail": "Known DOM elements" + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, read-only access" }, { "rule": "jQuery-$(", "path": "js/views/inbox_view.js", "line": " this.$('#header, .gutter').addClass('inactive');", - "lineNumber": 194, + "lineNumber": 195, "reasonCategory": "usageTrusted", - "updated": "2020-05-28T17:42:35.329Z", - "reasonDetail": "Hardcoded selector" + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, adding or removing classes" }, { "rule": "jQuery-$(", "path": "js/views/inbox_view.js", "line": " this.$('.conversation-stack').removeClass('inactive');", - "lineNumber": 195, + "lineNumber": 196, "reasonCategory": "usageTrusted", - "updated": "2020-09-11T17:24:56.124Z", - "reasonDetail": "Static selector argument" + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, adding or removing classes" }, { "rule": "jQuery-$(", "path": "js/views/inbox_view.js", "line": " this.$('.conversation-stack').addClass('inactive');", - "lineNumber": 198, + "lineNumber": 199, "reasonCategory": "usageTrusted", - "updated": "2020-05-28T17:42:35.329Z", - "reasonDetail": "Hardcoded selector" + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, adding or removing classes" }, { "rule": "jQuery-$(", "path": "js/views/inbox_view.js", "line": " this.$('#header, .gutter').removeClass('inactive');", - "lineNumber": 199, + "lineNumber": 200, "reasonCategory": "usageTrusted", - "updated": "2020-09-11T17:24:56.124Z", - "reasonDetail": "Static selector argument" + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, adding or removing classes" }, { "rule": "jQuery-$(", "path": "js/views/inbox_view.js", "line": " this.$('.conversation:first .menu').trigger('close');", - "lineNumber": 200, + "lineNumber": 201, "reasonCategory": "usageTrusted", - "updated": "2020-05-28T17:42:35.329Z", - "reasonDetail": "Hardcoded selector" + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, trigging DOM event" }, { "rule": "jQuery-$(", "path": "js/views/inbox_view.js", "line": " if (e && this.$(e.target).closest('.capture-audio').length > 0) {", - "lineNumber": 222, + "lineNumber": 223, "reasonCategory": "usageTrusted", - "updated": "2020-05-29T18:29:18.234Z", - "reasonDetail": "Known DOM elements" + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, read-only access" }, { "rule": "jQuery-$(", "path": "js/views/inbox_view.js", "line": " this.$('.conversation:first .recorder').trigger('close');", - "lineNumber": 225, + "lineNumber": 226, "reasonCategory": "usageTrusted", - "updated": "2020-05-29T18:29:18.234Z", - "reasonDetail": "Hardcoded selector" + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, triggering DOM event" + }, + { + "rule": "jQuery-$(", + "path": "js/views/install_view.js", + "line": " template: () => $('#link-flow-template').html(),", + "lineNumber": 27, + "reasonCategory": "usageTrusted", + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, read-only access" + }, + { + "rule": "jQuery-html(", + "path": "js/views/install_view.js", + "line": " template: () => $('#link-flow-template').html(),", + "lineNumber": 27, + "reasonCategory": "usageTrusted", + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, read-only access" }, { "rule": "jQuery-$(", @@ -733,18 +913,38 @@ { "rule": "jQuery-$(", "path": "js/views/key_verification_view.js", - "line": " this.$('.key-verification-wrapper').append(this.view.el);", - "lineNumber": 29, + "line": " template: () => $('#key-verification').html(),", + "lineNumber": 12, "reasonCategory": "usageTrusted", - "updated": "2020-06-23T06:48:06.829Z" + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, read-only access" + }, + { + "rule": "jQuery-html(", + "path": "js/views/key_verification_view.js", + "line": " template: () => $('#key-verification').html(),", + "lineNumber": 12, + "reasonCategory": "usageTrusted", + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, read-only access" + }, + { + "rule": "jQuery-$(", + "path": "js/views/key_verification_view.js", + "line": " this.$('.key-verification-wrapper').append(this.view.el);", + "lineNumber": 27, + "reasonCategory": "usageTrusted", + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Adding sub-view to DOM" }, { "rule": "jQuery-append(", "path": "js/views/key_verification_view.js", "line": " this.$('.key-verification-wrapper').append(this.view.el);", - "lineNumber": 29, + "lineNumber": 27, "reasonCategory": "usageTrusted", - "updated": "2020-06-23T06:48:06.829Z" + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Adding sub-view to DOM" }, { "rule": "jQuery-append(", @@ -764,6 +964,24 @@ "updated": "2018-09-15T00:38:04.183Z", "reasonDetail": "Hard-coded value" }, + { + "rule": "jQuery-$(", + "path": "js/views/phone-input-view.js", + "line": " template: () => $('#phone-number').html(),", + "lineNumber": 13, + "reasonCategory": "usageTrusted", + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, read-only access" + }, + { + "rule": "jQuery-html(", + "path": "js/views/phone-input-view.js", + "line": " template: () => $('#phone-number').html(),", + "lineNumber": 13, + "reasonCategory": "usageTrusted", + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, read-only access" + }, { "rule": "jQuery-$(", "path": "js/views/phone-input-view.js", @@ -818,6 +1036,24 @@ "updated": "2018-09-19T21:59:32.770Z", "reasonDetail": "Protected from arbitrary input" }, + { + "rule": "jQuery-$(", + "path": "js/views/recorder_view.js", + "line": " template: () => $('#recorder').html(),", + "lineNumber": 14, + "reasonCategory": "usageTrusted", + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, read-only access" + }, + { + "rule": "jQuery-html(", + "path": "js/views/recorder_view.js", + "line": " template: () => $('#recorder').html(),", + "lineNumber": 14, + "reasonCategory": "usageTrusted", + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, read-only access" + }, { "rule": "jQuery-$(", "path": "js/views/recorder_view.js", @@ -845,6 +1081,24 @@ "updated": "2018-10-11T19:22:47.331Z", "reasonDetail": "Operating on already-existing DOM elements" }, + { + "rule": "jQuery-$(", + "path": "js/views/safety_number_change_dialog_view.js", + "line": " template: () => $('#safety-number-change-dialog').html(),", + "lineNumber": 11, + "reasonCategory": "usageTrusted", + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, read-only access" + }, + { + "rule": "jQuery-html(", + "path": "js/views/safety_number_change_dialog_view.js", + "line": " template: () => $('#safety-number-change-dialog').html(),", + "lineNumber": 11, + "reasonCategory": "usageTrusted", + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, read-only access" + }, { "rule": "jQuery-$(", "path": "js/views/safety_number_change_dialog_view.js", @@ -905,6 +1159,24 @@ "updated": "2020-06-02T21:51:34.813Z", "reasonDetail": "Protected from arbitrary input" }, + { + "rule": "jQuery-$(", + "path": "js/views/settings_view.js", + "line": " template: () => $('#settings').html(),", + "lineNumber": 94, + "reasonCategory": "usageTrusted", + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, read-only access" + }, + { + "rule": "jQuery-html(", + "path": "js/views/settings_view.js", + "line": " template: () => $('#settings').html(),", + "lineNumber": 94, + "reasonCategory": "usageTrusted", + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, read-only access" + }, { "rule": "jQuery-$(", "path": "js/views/settings_view.js", @@ -1058,6 +1330,24 @@ "updated": "2020-08-21T11:29:29.636Z", "reasonDetail": "Interacting with already-existing DOM nodes" }, + { + "rule": "jQuery-$(", + "path": "js/views/settings_view.js", + "line": " template: () => $('#syncSettings').html(),", + "lineNumber": 274, + "reasonCategory": "usageTrusted", + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, read-only access" + }, + { + "rule": "jQuery-html(", + "path": "js/views/settings_view.js", + "line": " template: () => $('#syncSettings').html(),", + "lineNumber": 274, + "reasonCategory": "usageTrusted", + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, read-only access" + }, { "rule": "jQuery-$(", "path": "js/views/settings_view.js", @@ -1121,6 +1411,24 @@ "updated": "2020-08-21T11:29:29.636Z", "reasonDetail": "Protected from arbitrary input" }, + { + "rule": "jQuery-$(", + "path": "js/views/standalone_registration_view.js", + "line": " template: () => $('#standalone').html(),", + "lineNumber": 13, + "reasonCategory": "usageTrusted", + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, read-only access" + }, + { + "rule": "jQuery-html(", + "path": "js/views/standalone_registration_view.js", + "line": " template: () => $('#standalone').html(),", + "lineNumber": 13, + "reasonCategory": "usageTrusted", + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, read-only access" + }, { "rule": "jQuery-$(", "path": "js/views/standalone_registration_view.js", @@ -1283,59 +1591,6 @@ "updated": "2020-11-18T03:39:29.033Z", "reasonDetail": "Protected from arbitrary input" }, - { - "rule": "jQuery-html(", - "path": "js/views/toast_view.js", - "line": " this.$el.html(", - "lineNumber": 23, - "reasonCategory": "usageTrusted", - "updated": "2018-09-15T00:38:04.183Z" - }, - { - "rule": "jQuery-appendTo(", - "path": "js/views/toast_view.js", - "line": " toast.$el.appendTo(el);", - "lineNumber": 37, - "reasonCategory": "usageTrusted", - "updated": "2019-11-06T19:56:38.557Z", - "reasonDetail": "Protected from arbitrary input" - }, - { - "rule": "jQuery-html(", - "path": "js/views/whisper_view.js", - "line": " this.$el.html(Mustache.render(template, attrs, partials));", - "lineNumber": 52, - "reasonCategory": "usageTrusted", - "updated": "2018-09-15T00:38:04.183Z", - "reasonDetail": "Value set came directly from Mustache tempating engine" - }, - { - "rule": "jQuery-$(", - "path": "js/views/whisper_view.js", - "line": " $('script[type=\"text/x-tmpl-mustache\"]').each((i, el) => {", - "lineNumber": 70, - "reasonCategory": "usageTrusted", - "updated": "2018-09-19T21:59:32.770Z", - "reasonDetail": "Protected from arbitrary input" - }, - { - "rule": "jQuery-$(", - "path": "js/views/whisper_view.js", - "line": " const $el = $(el);", - "lineNumber": 71, - "reasonCategory": "usageTrusted", - "updated": "2018-09-19T21:59:32.770Z", - "reasonDetail": "Protected from arbitrary input" - }, - { - "rule": "jQuery-html(", - "path": "js/views/whisper_view.js", - "line": " templates[id] = $el.html();", - "lineNumber": 73, - "reasonCategory": "usageTrusted", - "updated": "2018-09-15T00:38:04.183Z", - "reasonDetail": "Getting the value, not setting it" - }, { "rule": "DOM-innerHTML", "path": "node_modules/@electron/get/node_modules/@sindresorhus/is/dist/index.js", @@ -6851,8 +7106,7 @@ "line": " $({ global: true, forced: Constructor != NativeConstructor }, exported);", "lineNumber": 90, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -6860,8 +7114,7 @@ "line": " } else $({ target: NAME, proto: true, forced: BUGGY_SAFARI_ITERATORS || INCORRECT_VALUES_NAME }, methods);", "lineNumber": 86, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -6869,8 +7122,7 @@ "line": " reCopy = new RegExp('^' + re.source + '$(?!\\\\s)', regexpFlags.call(re));", "lineNumber": 31, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -6878,8 +7130,7 @@ "line": " $({ target: 'Object', stat: true, forced: !NATIVE_ARRAY_BUFFER_VIEWS }, {", "lineNumber": 103, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -6887,8 +7138,7 @@ "line": " $({", "lineNumber": 209, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -6896,8 +7146,7 @@ "line": "$({ global: true, forced: NativeArrayBuffer !== ArrayBuffer }, {", "lineNumber": 13, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -6905,8 +7154,7 @@ "line": "$({ target: 'ArrayBuffer', stat: true, forced: !NATIVE_ARRAY_BUFFER_VIEWS }, {", "lineNumber": 8, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -6914,8 +7162,7 @@ "line": "$({ target: 'ArrayBuffer', proto: true, unsafe: true, forced: INCORRECT_SLICE }, {", "lineNumber": 20, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -6923,8 +7170,7 @@ "line": "$({ target: 'Array', proto: true, forced: FORCED }, {", "lineNumber": 36, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -6932,8 +7178,7 @@ "line": "$({ target: 'Array', proto: true }, {", "lineNumber": 7, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -6941,8 +7186,7 @@ "line": "$({ target: 'Array', proto: true, forced: sloppyArrayMethod('every') }, {", "lineNumber": 8, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -6950,8 +7194,7 @@ "line": "$({ target: 'Array', proto: true }, {", "lineNumber": 7, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -6959,8 +7202,7 @@ "line": "$({ target: 'Array', proto: true, forced: !arrayMethodHasSpeciesSupport('filter') }, {", "lineNumber": 9, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -6968,8 +7210,7 @@ "line": "$({ target: 'Array', proto: true, forced: SKIPS_HOLES }, {", "lineNumber": 14, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -6977,8 +7218,7 @@ "line": "$({ target: 'Array', proto: true, forced: SKIPS_HOLES }, {", "lineNumber": 14, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -6986,8 +7226,7 @@ "line": "$({ target: 'Array', proto: true }, {", "lineNumber": 11, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -6995,8 +7234,7 @@ "line": "$({ target: 'Array', proto: true }, {", "lineNumber": 11, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7004,8 +7242,7 @@ "line": "$({ target: 'Array', proto: true, forced: [].forEach != forEach }, {", "lineNumber": 7, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7013,8 +7250,7 @@ "line": "$({ target: 'Array', stat: true, forced: INCORRECT_ITERATION }, {", "lineNumber": 11, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7022,8 +7258,7 @@ "line": "$({ target: 'Array', proto: true }, {", "lineNumber": 8, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7031,8 +7266,7 @@ "line": "$({ target: 'Array', proto: true, forced: NEGATIVE_ZERO || SLOPPY_METHOD }, {", "lineNumber": 13, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7040,8 +7274,7 @@ "line": "$({ target: 'Array', stat: true }, {", "lineNumber": 6, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7049,8 +7282,7 @@ "line": "$({ target: 'Array', proto: true, forced: ES3_STRINGS || SLOPPY_METHOD }, {", "lineNumber": 14, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7058,8 +7290,7 @@ "line": "$({ target: 'Array', proto: true, forced: lastIndexOf !== [].lastIndexOf }, {", "lineNumber": 6, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7067,8 +7298,7 @@ "line": "$({ target: 'Array', proto: true, forced: !arrayMethodHasSpeciesSupport('map') }, {", "lineNumber": 9, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7076,8 +7306,7 @@ "line": "$({ target: 'Array', stat: true, forced: ISNT_GENERIC }, {", "lineNumber": 14, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7085,8 +7314,7 @@ "line": "$({ target: 'Array', proto: true, forced: sloppyArrayMethod('reduceRight') }, {", "lineNumber": 8, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7094,8 +7322,7 @@ "line": "$({ target: 'Array', proto: true, forced: sloppyArrayMethod('reduce') }, {", "lineNumber": 8, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7103,8 +7330,7 @@ "line": "$({ target: 'Array', proto: true, forced: String(test) === String(test.reverse()) }, {", "lineNumber": 12, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7112,8 +7338,7 @@ "line": "$({ target: 'Array', proto: true, forced: !arrayMethodHasSpeciesSupport('slice') }, {", "lineNumber": 19, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7121,8 +7346,7 @@ "line": "$({ target: 'Array', proto: true, forced: sloppyArrayMethod('some') }, {", "lineNumber": 8, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7130,8 +7354,7 @@ "line": "$({ target: 'Array', proto: true, forced: FORCED }, {", "lineNumber": 26, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7139,8 +7362,7 @@ "line": "$({ target: 'Array', proto: true, forced: !arrayMethodHasSpeciesSupport('splice') }, {", "lineNumber": 19, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7148,8 +7370,7 @@ "line": "$({ global: true, forced: !NATIVE_ARRAY_BUFFER }, {", "lineNumber": 7, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7157,8 +7378,7 @@ "line": "$({ target: 'Date', stat: true }, {", "lineNumber": 5, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7166,8 +7386,7 @@ "line": "$({ target: 'Date', proto: true, forced: Date.prototype.toISOString !== toISOString }, {", "lineNumber": 7, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7175,8 +7394,7 @@ "line": "$({ target: 'Date', proto: true, forced: FORCED }, {", "lineNumber": 14, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7184,8 +7402,7 @@ "line": "$({ target: 'Function', proto: true }, {", "lineNumber": 6, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7193,8 +7410,7 @@ "line": "$({ target: 'Math', stat: true, forced: FORCED }, {", "lineNumber": 17, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7202,8 +7418,7 @@ "line": "$({ target: 'Math', stat: true, forced: !(nativeAsinh && 1 / nativeAsinh(0) > 0) }, {", "lineNumber": 14, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7211,8 +7426,7 @@ "line": "$({ target: 'Math', stat: true, forced: !(nativeAtanh && 1 / nativeAtanh(-0) < 0) }, {", "lineNumber": 9, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7220,8 +7434,7 @@ "line": "$({ target: 'Math', stat: true }, {", "lineNumber": 9, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7229,8 +7442,7 @@ "line": "$({ target: 'Math', stat: true }, {", "lineNumber": 9, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7238,8 +7450,7 @@ "line": "$({ target: 'Math', stat: true, forced: !nativeCosh || nativeCosh(710) === Infinity }, {", "lineNumber": 10, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7247,8 +7458,7 @@ "line": "$({ target: 'Math', stat: true, forced: expm1 != Math.expm1 }, { expm1: expm1 });", "lineNumber": 6, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7256,8 +7466,7 @@ "line": "$({ target: 'Math', stat: true }, { fround: fround });", "lineNumber": 6, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7265,8 +7474,7 @@ "line": "$({ target: 'Math', stat: true, forced: BUGGY }, {", "lineNumber": 13, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7274,8 +7482,7 @@ "line": "$({ target: 'Math', stat: true, forced: FORCED }, {", "lineNumber": 13, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7283,8 +7490,7 @@ "line": "$({ target: 'Math', stat: true }, {", "lineNumber": 8, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7292,8 +7498,7 @@ "line": "$({ target: 'Math', stat: true }, { log1p: log1p });", "lineNumber": 6, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7301,8 +7506,7 @@ "line": "$({ target: 'Math', stat: true }, {", "lineNumber": 8, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7310,8 +7514,7 @@ "line": "$({ target: 'Math', stat: true }, {", "lineNumber": 6, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7319,8 +7522,7 @@ "line": "$({ target: 'Math', stat: true, forced: FORCED }, {", "lineNumber": 16, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7328,8 +7530,7 @@ "line": "$({ target: 'Math', stat: true }, {", "lineNumber": 8, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7337,8 +7538,7 @@ "line": "$({ target: 'Math', stat: true }, {", "lineNumber": 8, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7346,8 +7546,7 @@ "line": "$({ target: 'Number', stat: true }, {", "lineNumber": 5, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7355,8 +7554,7 @@ "line": "$({ target: 'Number', stat: true }, { isFinite: numberIsFinite });", "lineNumber": 6, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7364,8 +7562,7 @@ "line": "$({ target: 'Number', stat: true }, {", "lineNumber": 6, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7373,8 +7570,7 @@ "line": "$({ target: 'Number', stat: true }, {", "lineNumber": 5, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7382,8 +7578,7 @@ "line": "$({ target: 'Number', stat: true }, {", "lineNumber": 8, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7391,8 +7586,7 @@ "line": "$({ target: 'Number', stat: true }, {", "lineNumber": 5, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7400,8 +7594,7 @@ "line": "$({ target: 'Number', stat: true }, {", "lineNumber": 5, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7409,8 +7602,7 @@ "line": "$({ target: 'Number', stat: true, forced: Number.parseFloat != parseFloat }, {", "lineNumber": 6, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7418,8 +7610,7 @@ "line": "$({ target: 'Number', stat: true, forced: Number.parseInt != parseInt }, {", "lineNumber": 6, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7427,8 +7618,7 @@ "line": "$({ target: 'Number', proto: true, forced: FORCED }, {", "lineNumber": 40, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7436,8 +7626,7 @@ "line": "$({ target: 'Number', proto: true, forced: FORCED }, {", "lineNumber": 18, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7445,8 +7634,7 @@ "line": "$({ target: 'Object', stat: true, forced: Object.assign !== assign }, {", "lineNumber": 6, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7454,8 +7642,7 @@ "line": "$({ target: 'Object', stat: true, sham: !DESCRIPTORS }, {", "lineNumber": 7, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7463,8 +7650,7 @@ "line": " $({ target: 'Object', proto: true, forced: FORCED }, {", "lineNumber": 12, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7472,8 +7658,7 @@ "line": "$({ target: 'Object', stat: true, forced: !DESCRIPTORS, sham: !DESCRIPTORS }, {", "lineNumber": 7, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7481,8 +7666,7 @@ "line": "$({ target: 'Object', stat: true, forced: !DESCRIPTORS, sham: !DESCRIPTORS }, {", "lineNumber": 7, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7490,8 +7674,7 @@ "line": " $({ target: 'Object', proto: true, forced: FORCED }, {", "lineNumber": 12, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7499,8 +7682,7 @@ "line": "$({ target: 'Object', stat: true }, {", "lineNumber": 6, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7508,8 +7690,7 @@ "line": "$({ target: 'Object', stat: true, forced: FAILS_ON_PRIMITIVES, sham: !FREEZING }, {", "lineNumber": 12, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7517,8 +7698,7 @@ "line": "$({ target: 'Object', stat: true }, {", "lineNumber": 7, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7526,8 +7706,7 @@ "line": "$({ target: 'Object', stat: true, forced: FORCED, sham: !DESCRIPTORS }, {", "lineNumber": 12, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7535,8 +7714,7 @@ "line": "$({ target: 'Object', stat: true, sham: !DESCRIPTORS }, {", "lineNumber": 10, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7544,8 +7722,7 @@ "line": "$({ target: 'Object', stat: true, forced: FAILS_ON_PRIMITIVES }, {", "lineNumber": 9, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7553,8 +7730,7 @@ "line": "$({ target: 'Object', stat: true, forced: FAILS_ON_PRIMITIVES, sham: !CORRECT_PROTOTYPE_GETTER }, {", "lineNumber": 11, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7562,8 +7738,7 @@ "line": "$({ target: 'Object', stat: true, forced: FAILS_ON_PRIMITIVES }, {", "lineNumber": 10, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7571,8 +7746,7 @@ "line": "$({ target: 'Object', stat: true, forced: FAILS_ON_PRIMITIVES }, {", "lineNumber": 10, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7580,8 +7754,7 @@ "line": "$({ target: 'Object', stat: true, forced: FAILS_ON_PRIMITIVES }, {", "lineNumber": 10, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7589,8 +7762,7 @@ "line": "$({ target: 'Object', stat: true }, {", "lineNumber": 6, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7598,8 +7770,7 @@ "line": "$({ target: 'Object', stat: true, forced: FAILS_ON_PRIMITIVES }, {", "lineNumber": 10, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7607,8 +7778,7 @@ "line": " $({ target: 'Object', proto: true, forced: FORCED }, {", "lineNumber": 13, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7616,8 +7786,7 @@ "line": " $({ target: 'Object', proto: true, forced: FORCED }, {", "lineNumber": 13, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7625,8 +7794,7 @@ "line": "$({ target: 'Object', stat: true, forced: FAILS_ON_PRIMITIVES, sham: !FREEZING }, {", "lineNumber": 12, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7634,8 +7802,7 @@ "line": "$({ target: 'Object', stat: true, forced: FAILS_ON_PRIMITIVES, sham: !FREEZING }, {", "lineNumber": 12, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7643,8 +7810,7 @@ "line": "$({ target: 'Object', stat: true }, {", "lineNumber": 6, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7652,8 +7818,7 @@ "line": "$({ target: 'Object', stat: true }, {", "lineNumber": 6, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7661,8 +7826,7 @@ "line": "$({ global: true, forced: parseFloat != parseFloatImplementation }, {", "lineNumber": 6, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7670,8 +7834,7 @@ "line": "$({ global: true, forced: parseInt != parseIntImplementation }, {", "lineNumber": 6, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7679,8 +7842,7 @@ "line": "$({ target: 'Promise', stat: true }, {", "lineNumber": 10, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7688,8 +7850,7 @@ "line": "$({ target: 'Promise', proto: true, real: true }, {", "lineNumber": 12, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7697,8 +7858,7 @@ "line": " if (typeof $fetch == 'function') $({ global: true, enumerable: true, forced: true }, {", "lineNumber": 289, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7706,8 +7866,7 @@ "line": "$({ global: true, wrap: true, forced: FORCED }, {", "lineNumber": 298, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7715,8 +7874,7 @@ "line": "$({ target: PROMISE, stat: true, forced: FORCED }, {", "lineNumber": 308, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7724,8 +7882,7 @@ "line": "$({ target: PROMISE, stat: true, forced: IS_PURE || FORCED }, {", "lineNumber": 318, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7733,8 +7890,7 @@ "line": "$({ target: PROMISE, stat: true, forced: INCORRECT_ITERATION }, {", "lineNumber": 326, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7742,8 +7898,7 @@ "line": "$({ target: 'Reflect', stat: true, forced: OPTIONAL_ARGUMENTS_LIST }, {", "lineNumber": 17, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7751,8 +7906,7 @@ "line": "$({ target: 'Reflect', stat: true, forced: FORCED, sham: FORCED }, {", "lineNumber": 25, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7760,8 +7914,7 @@ "line": "$({ target: 'Reflect', stat: true, forced: ERROR_INSTEAD_OF_FALSE, sham: !DESCRIPTORS }, {", "lineNumber": 16, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7769,8 +7922,7 @@ "line": "$({ target: 'Reflect', stat: true }, {", "lineNumber": 7, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7778,8 +7930,7 @@ "line": "$({ target: 'Reflect', stat: true, sham: !DESCRIPTORS }, {", "lineNumber": 8, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7787,8 +7938,7 @@ "line": "$({ target: 'Reflect', stat: true, sham: !CORRECT_PROTOTYPE_GETTER }, {", "lineNumber": 8, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7796,8 +7946,7 @@ "line": "$({ target: 'Reflect', stat: true }, {", "lineNumber": 22, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7805,8 +7954,7 @@ "line": "$({ target: 'Reflect', stat: true }, {", "lineNumber": 5, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7814,8 +7962,7 @@ "line": "$({ target: 'Reflect', stat: true }, {", "lineNumber": 8, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7823,8 +7970,7 @@ "line": "$({ target: 'Reflect', stat: true }, {", "lineNumber": 6, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7832,8 +7978,7 @@ "line": "$({ target: 'Reflect', stat: true, sham: !FREEZING }, {", "lineNumber": 8, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7841,8 +7986,7 @@ "line": "if (objectSetPrototypeOf) $({ target: 'Reflect', stat: true }, {", "lineNumber": 8, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7850,8 +7994,7 @@ "line": "$({ target: 'Reflect', stat: true }, {", "lineNumber": 34, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7859,8 +8002,7 @@ "line": "$({ target: 'RegExp', proto: true, forced: /./.exec !== exec }, {", "lineNumber": 5, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7868,8 +8010,7 @@ "line": "$({ target: 'String', proto: true, forced: forcedStringHTMLMethod('anchor') }, {", "lineNumber": 8, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7877,8 +8018,7 @@ "line": "$({ target: 'String', proto: true, forced: forcedStringHTMLMethod('big') }, {", "lineNumber": 8, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7886,8 +8026,7 @@ "line": "$({ target: 'String', proto: true, forced: forcedStringHTMLMethod('blink') }, {", "lineNumber": 8, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7895,8 +8034,7 @@ "line": "$({ target: 'String', proto: true, forced: forcedStringHTMLMethod('bold') }, {", "lineNumber": 8, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7904,8 +8042,7 @@ "line": "$({ target: 'String', proto: true }, {", "lineNumber": 7, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7913,8 +8050,7 @@ "line": "$({ target: 'String', proto: true, forced: !correctIsRegExpLogic('endsWith') }, {", "lineNumber": 13, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7922,8 +8058,7 @@ "line": "$({ target: 'String', proto: true, forced: forcedStringHTMLMethod('fixed') }, {", "lineNumber": 8, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7931,8 +8066,7 @@ "line": "$({ target: 'String', proto: true, forced: forcedStringHTMLMethod('fontcolor') }, {", "lineNumber": 8, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7940,8 +8074,7 @@ "line": "$({ target: 'String', proto: true, forced: forcedStringHTMLMethod('fontsize') }, {", "lineNumber": 8, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7949,8 +8082,7 @@ "line": "$({ target: 'String', stat: true, forced: INCORRECT_LENGTH }, {", "lineNumber": 12, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7958,8 +8090,7 @@ "line": "$({ target: 'String', proto: true, forced: !correctIsRegExpLogic('includes') }, {", "lineNumber": 9, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7967,8 +8098,7 @@ "line": "$({ target: 'String', proto: true, forced: forcedStringHTMLMethod('italics') }, {", "lineNumber": 8, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7976,8 +8106,7 @@ "line": "$({ target: 'String', proto: true, forced: forcedStringHTMLMethod('link') }, {", "lineNumber": 8, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7985,8 +8114,7 @@ "line": "$({ target: 'String', proto: true }, {", "lineNumber": 79, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -7994,8 +8122,7 @@ "line": "$({ target: 'String', proto: true, forced: WEBKIT_BUG }, {", "lineNumber": 8, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8003,8 +8130,7 @@ "line": "$({ target: 'String', proto: true, forced: WEBKIT_BUG }, {", "lineNumber": 8, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8012,8 +8138,7 @@ "line": "$({ target: 'String', stat: true }, {", "lineNumber": 7, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8021,8 +8146,7 @@ "line": "$({ target: 'String', proto: true }, {", "lineNumber": 6, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8030,8 +8154,7 @@ "line": "var SUBSTITUTION_SYMBOLS = /\\$([$&'`]|\\d\\d?|<[^>]*>)/g;", "lineNumber": 14, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8039,8 +8162,7 @@ "line": "var SUBSTITUTION_SYMBOLS_NO_NAMED = /\\$([$&'`]|\\d\\d?)/g;", "lineNumber": 15, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8048,8 +8170,7 @@ "line": "$({ target: 'String', proto: true, forced: forcedStringHTMLMethod('small') }, {", "lineNumber": 8, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8057,8 +8178,7 @@ "line": "$({ target: 'String', proto: true, forced: !correctIsRegExpLogic('startsWith') }, {", "lineNumber": 13, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8066,8 +8186,7 @@ "line": "$({ target: 'String', proto: true, forced: forcedStringHTMLMethod('strike') }, {", "lineNumber": 8, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8075,8 +8194,7 @@ "line": "$({ target: 'String', proto: true, forced: forcedStringHTMLMethod('sub') }, {", "lineNumber": 8, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8084,8 +8202,7 @@ "line": "$({ target: 'String', proto: true, forced: forcedStringHTMLMethod('sup') }, {", "lineNumber": 8, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8093,8 +8210,7 @@ "line": "$({ target: 'String', proto: true, forced: FORCED }, {", "lineNumber": 14, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8102,8 +8218,7 @@ "line": "$({ target: 'String', proto: true, forced: FORCED }, {", "lineNumber": 14, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8111,8 +8226,7 @@ "line": "$({ target: 'String', proto: true, forced: forcedStringTrimMethod('trim') }, {", "lineNumber": 8, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8120,8 +8234,7 @@ "line": " $({ global: true, forced: true }, {", "lineNumber": 47, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-wrap(", @@ -8129,8 +8242,7 @@ "line": " return wrap(tag, description);", "lineNumber": 173, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-wrap(", @@ -8138,8 +8250,7 @@ "line": " return wrap(wellKnownSymbol(name), name);", "lineNumber": 200, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8147,8 +8258,7 @@ "line": "$({ global: true, wrap: true, forced: !NATIVE_SYMBOL, sham: !NATIVE_SYMBOL }, {", "lineNumber": 204, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8156,8 +8266,7 @@ "line": "$({ target: SYMBOL, stat: true, forced: !NATIVE_SYMBOL }, {", "lineNumber": 212, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8165,8 +8274,7 @@ "line": "$({ target: 'Object', stat: true, forced: !NATIVE_SYMBOL, sham: !DESCRIPTORS }, {", "lineNumber": 233, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8174,8 +8282,7 @@ "line": "$({ target: 'Object', stat: true, forced: !NATIVE_SYMBOL }, {", "lineNumber": 248, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8183,8 +8290,7 @@ "line": "$({ target: 'Object', stat: true, forced: fails(function () { getOwnPropertySymbolsModule.f(1); }) }, {", "lineNumber": 259, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8192,8 +8298,7 @@ "line": "JSON && $({ target: 'JSON', stat: true, forced: !NATIVE_SYMBOL || fails(function () {", "lineNumber": 267, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8201,8 +8306,7 @@ "line": "$({ global: true }, {", "lineNumber": 27, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8210,8 +8314,7 @@ "line": "$({ target: 'Array', stat: true }, {", "lineNumber": 21, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8219,8 +8322,7 @@ "line": "$({ global: true }, {", "lineNumber": 12, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8228,8 +8330,7 @@ "line": "$({ global: true }, {", "lineNumber": 6, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8237,8 +8338,7 @@ "line": "$({ global: true }, {", "lineNumber": 6, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8246,8 +8346,7 @@ "line": "$({ target: 'Map', proto: true, real: true, forced: IS_PURE }, {", "lineNumber": 8, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8255,8 +8354,7 @@ "line": "$({ target: 'Map', proto: true, real: true, forced: IS_PURE }, {", "lineNumber": 11, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8264,8 +8362,7 @@ "line": "$({ target: 'Map', proto: true, real: true, forced: IS_PURE }, {", "lineNumber": 14, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8273,8 +8370,7 @@ "line": "$({ target: 'Map', proto: true, real: true, forced: IS_PURE }, {", "lineNumber": 11, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8282,8 +8378,7 @@ "line": "$({ target: 'Map', proto: true, real: true, forced: IS_PURE }, {", "lineNumber": 11, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8291,8 +8386,7 @@ "line": "$({ target: 'Map', stat: true }, {", "lineNumber": 6, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8300,8 +8394,7 @@ "line": "$({ target: 'Map', stat: true }, {", "lineNumber": 8, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8309,8 +8402,7 @@ "line": "$({ target: 'Map', proto: true, real: true, forced: IS_PURE }, {", "lineNumber": 11, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8318,8 +8410,7 @@ "line": "$({ target: 'Map', stat: true }, {", "lineNumber": 8, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8327,8 +8418,7 @@ "line": "$({ target: 'Map', proto: true, real: true, forced: IS_PURE }, {", "lineNumber": 10, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8336,8 +8426,7 @@ "line": "$({ target: 'Map', proto: true, real: true, forced: IS_PURE }, {", "lineNumber": 14, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8345,8 +8434,7 @@ "line": "$({ target: 'Map', proto: true, real: true, forced: IS_PURE }, {", "lineNumber": 14, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8354,8 +8442,7 @@ "line": "$({ target: 'Map', proto: true, real: true, forced: IS_PURE }, {", "lineNumber": 10, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8363,8 +8450,7 @@ "line": "$({ target: 'Map', stat: true }, {", "lineNumber": 6, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8372,8 +8458,7 @@ "line": "$({ target: 'Map', proto: true, real: true, forced: IS_PURE }, {", "lineNumber": 11, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8381,8 +8466,7 @@ "line": "$({ target: 'Map', proto: true, real: true, forced: IS_PURE }, {", "lineNumber": 11, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8390,8 +8474,7 @@ "line": "$({ target: 'Map', proto: true, real: true, forced: IS_PURE }, {", "lineNumber": 9, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8399,8 +8482,7 @@ "line": "$({ target: 'Map', proto: true, real: true, forced: IS_PURE }, {", "lineNumber": 9, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8408,8 +8490,7 @@ "line": "$({ target: 'Math', stat: true }, {", "lineNumber": 8, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8417,8 +8498,7 @@ "line": "$({ target: 'Math', stat: true }, {", "lineNumber": 5, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8426,8 +8506,7 @@ "line": "$({ target: 'Math', stat: true }, {", "lineNumber": 7, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8435,8 +8514,7 @@ "line": "$({ target: 'Math', stat: true }, {", "lineNumber": 8, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8444,8 +8522,7 @@ "line": "$({ target: 'Math', stat: true }, {", "lineNumber": 5, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8453,8 +8530,7 @@ "line": "$({ target: 'Math', stat: true }, {", "lineNumber": 5, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8462,8 +8538,7 @@ "line": "$({ target: 'Math', stat: true }, {", "lineNumber": 5, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8471,8 +8546,7 @@ "line": "$({ target: 'Math', stat: true }, {", "lineNumber": 5, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8480,8 +8554,7 @@ "line": "$({ target: 'Math', stat: true }, {", "lineNumber": 7, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8489,8 +8562,7 @@ "line": "$({ target: 'Math', stat: true }, {", "lineNumber": 6, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8498,8 +8570,7 @@ "line": "$({ target: 'Math', stat: true, forced: true }, {", "lineNumber": 27, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8507,8 +8578,7 @@ "line": "$({ target: 'Math', stat: true }, {", "lineNumber": 5, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8516,8 +8586,7 @@ "line": "$({ target: 'Math', stat: true }, {", "lineNumber": 5, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8525,8 +8594,7 @@ "line": "$({ target: 'Number', stat: true }, {", "lineNumber": 12, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8534,8 +8602,7 @@ "line": "$({ global: true }, {", "lineNumber": 203, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8543,8 +8610,7 @@ "line": "$({ target: 'Promise', stat: true }, {", "lineNumber": 13, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8552,8 +8618,7 @@ "line": "$({ target: 'Promise', stat: true }, {", "lineNumber": 8, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8561,8 +8626,7 @@ "line": "$({ target: 'Reflect', stat: true }, {", "lineNumber": 10, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8570,8 +8634,7 @@ "line": "$({ target: 'Reflect', stat: true }, {", "lineNumber": 11, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8579,8 +8642,7 @@ "line": "$({ target: 'Reflect', stat: true }, {", "lineNumber": 28, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8588,8 +8650,7 @@ "line": "$({ target: 'Reflect', stat: true }, {", "lineNumber": 19, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8597,8 +8658,7 @@ "line": "$({ target: 'Reflect', stat: true }, {", "lineNumber": 10, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8606,8 +8666,7 @@ "line": "$({ target: 'Reflect', stat: true }, {", "lineNumber": 10, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8615,8 +8674,7 @@ "line": "$({ target: 'Reflect', stat: true }, {", "lineNumber": 18, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8624,8 +8682,7 @@ "line": "$({ target: 'Reflect', stat: true }, {", "lineNumber": 10, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8633,8 +8690,7 @@ "line": "$({ target: 'Reflect', stat: true }, {", "lineNumber": 10, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8642,8 +8698,7 @@ "line": "$({ target: 'Set', proto: true, real: true, forced: IS_PURE }, {", "lineNumber": 8, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8651,8 +8706,7 @@ "line": "$({ target: 'Set', proto: true, real: true, forced: IS_PURE }, {", "lineNumber": 8, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8660,8 +8714,7 @@ "line": "$({ target: 'Set', proto: true, real: true, forced: IS_PURE }, {", "lineNumber": 12, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8669,8 +8722,7 @@ "line": "$({ target: 'Set', proto: true, real: true, forced: IS_PURE }, {", "lineNumber": 11, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8678,8 +8730,7 @@ "line": "$({ target: 'Set', proto: true, real: true, forced: IS_PURE }, {", "lineNumber": 14, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8687,8 +8738,7 @@ "line": "$({ target: 'Set', proto: true, real: true, forced: IS_PURE }, {", "lineNumber": 11, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8696,8 +8746,7 @@ "line": "$({ target: 'Set', stat: true }, {", "lineNumber": 6, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8705,8 +8754,7 @@ "line": "$({ target: 'Set', proto: true, real: true, forced: IS_PURE }, {", "lineNumber": 12, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8714,8 +8762,7 @@ "line": "$({ target: 'Set', proto: true, real: true, forced: IS_PURE }, {", "lineNumber": 10, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8723,8 +8770,7 @@ "line": "$({ target: 'Set', proto: true, real: true, forced: IS_PURE }, {", "lineNumber": 12, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8732,8 +8778,7 @@ "line": "$({ target: 'Set', proto: true, real: true, forced: IS_PURE }, {", "lineNumber": 10, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8741,8 +8786,7 @@ "line": "$({ target: 'Set', proto: true, real: true, forced: IS_PURE }, {", "lineNumber": 10, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8750,8 +8794,7 @@ "line": "$({ target: 'Set', proto: true, real: true, forced: IS_PURE }, {", "lineNumber": 14, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8759,8 +8802,7 @@ "line": "$({ target: 'Set', stat: true }, {", "lineNumber": 6, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8768,8 +8810,7 @@ "line": "$({ target: 'Set', proto: true, real: true, forced: IS_PURE }, {", "lineNumber": 11, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8777,8 +8818,7 @@ "line": "$({ target: 'Set', proto: true, real: true, forced: IS_PURE }, {", "lineNumber": 11, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8786,8 +8826,7 @@ "line": "$({ target: 'Set', proto: true, real: true, forced: IS_PURE }, {", "lineNumber": 12, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8795,8 +8834,7 @@ "line": "$({ target: 'Set', proto: true, real: true, forced: IS_PURE }, {", "lineNumber": 12, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8804,8 +8842,7 @@ "line": "$({ target: 'String', proto: true }, {", "lineNumber": 7, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8813,8 +8850,7 @@ "line": "$({ target: 'String', proto: true }, {", "lineNumber": 34, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8822,8 +8858,7 @@ "line": "$({ target: 'String', proto: true }, {", "lineNumber": 26, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8831,8 +8866,7 @@ "line": "$({ target: 'WeakMap', proto: true, real: true, forced: IS_PURE }, {", "lineNumber": 8, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8840,8 +8874,7 @@ "line": "$({ target: 'WeakMap', stat: true }, {", "lineNumber": 6, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8849,8 +8882,7 @@ "line": "$({ target: 'WeakMap', stat: true }, {", "lineNumber": 6, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8858,8 +8890,7 @@ "line": "$({ target: 'WeakSet', proto: true, real: true, forced: IS_PURE }, {", "lineNumber": 8, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8867,8 +8898,7 @@ "line": "$({ target: 'WeakSet', proto: true, real: true, forced: IS_PURE }, {", "lineNumber": 8, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8876,8 +8906,7 @@ "line": "$({ target: 'WeakSet', stat: true }, {", "lineNumber": 6, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8885,8 +8914,7 @@ "line": "$({ target: 'WeakSet', stat: true }, {", "lineNumber": 6, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8894,8 +8922,7 @@ "line": "$({ global: true, enumerable: true, noTargetGet: true }, {", "lineNumber": 11, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8903,8 +8930,7 @@ "line": "$({ global: true, bind: true, forced: MSIE }, {", "lineNumber": 21, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-wrap(", @@ -8912,8 +8938,7 @@ "line": " setTimeout: wrap(global.setTimeout),", "lineNumber": 24, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-wrap(", @@ -8921,8 +8946,7 @@ "line": " setInterval: wrap(global.setInterval)", "lineNumber": 27, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-append(", @@ -8930,8 +8954,7 @@ "line": " append: function append(name, value) {", "lineNumber": 159, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8939,8 +8962,7 @@ "line": "$({ global: true, forced: !USE_NATIVE_URL }, {", "lineNumber": 304, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8948,8 +8970,7 @@ "line": "$({ global: true, forced: !USE_NATIVE_URL, sham: !DESCRIPTORS }, {", "lineNumber": 1006, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -8957,8 +8978,7 @@ "line": "$({ target: 'URL', proto: true, enumerable: true }, {", "lineNumber": 6, "reasonCategory": "falseMatch", - "updated": "2020-01-06T20:40:04.973Z", - "reasonDetail": "" + "updated": "2020-01-06T20:40:04.973Z" }, { "rule": "jQuery-$(", @@ -14342,6 +14362,96 @@ "updated": "2018-09-17T20:50:40.689Z", "reasonDetail": "Hard-coded value" }, + { + "rule": "jQuery-$(", + "path": "ts/backbone/views/toast_view.js", + "line": " template: () => $('#toast').html(),", + "lineNumber": 7, + "reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted", + "updated": "2021-02-26T19:21:25.912Z", + "reasonDetail": "" + }, + { + "rule": "jQuery-html(", + "path": "ts/backbone/views/toast_view.js", + "line": " template: () => $('#toast').html(),", + "lineNumber": 7, + "reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted", + "updated": "2021-02-26T19:21:25.912Z", + "reasonDetail": "Static selector, read-only access" + }, + { + "rule": "jQuery-html(", + "path": "ts/backbone/views/toast_view.js", + "line": " this.$el.html(window.Mustache.render(window._.result(this, 'template', ''), window._.result(this, 'render_attributes', '')));", + "lineNumber": 16, + "reasonCategory": "usageTrusted", + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Adding Mustache render to DOM" + }, + { + "rule": "jQuery-appendTo(", + "path": "ts/backbone/views/toast_view.js", + "line": " toast.$el.appendTo(el);", + "lineNumber": 24, + "reasonCategory": "usageTrusted", + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Add sub-view to DOM" + }, + { + "rule": "jQuery-$(", + "path": "ts/backbone/views/toast_view.ts", + "line": " template: () => $('#toast').html(),", + "lineNumber": 8, + "reasonCategory": "usageTrusted", + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Static selector, read-only access" + }, + { + "rule": "jQuery-html(", + "path": "ts/backbone/views/toast_view.ts", + "line": " template: () => $('#toast').html(),", + "lineNumber": 8, + "reasonCategory": "usageTrusted", + "updated": "2021-02-26T19:23:51.217Z", + "reasonDetail": "Static selector, read-only access" + }, + { + "rule": "jQuery-html(", + "path": "ts/backbone/views/toast_view.ts", + "line": " this.$el.html(", + "lineNumber": 19, + "reasonCategory": "usageTrusted", + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Rendering provided template" + }, + { + "rule": "jQuery-appendTo(", + "path": "ts/backbone/views/toast_view.ts", + "line": " toast.$el.appendTo(el);", + "lineNumber": 33, + "reasonCategory": "usageTrusted", + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Adding sub-view to DOM" + }, + { + "rule": "jQuery-html(", + "path": "ts/backbone/views/whisper_view.js", + "line": " this.$el.html(window.Mustache.render(template, attrs));", + "lineNumber": 25, + "reasonCategory": "usageTrusted", + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Rendering provided template" + }, + { + "rule": "jQuery-html(", + "path": "ts/backbone/views/whisper_view.ts", + "line": " this.$el.html(window.Mustache.render(template, attrs));", + "lineNumber": 28, + "reasonCategory": "usageTrusted", + "updated": "2021-02-26T18:44:56.450Z", + "reasonDetail": "Rendered template has been sanitized" + }, { "rule": "React-useRef", "path": "ts/calling/useGetCallingFrameBuffer.js", @@ -15312,4 +15422,4 @@ "updated": "2021-01-08T15:46:32.143Z", "reasonDetail": "Doesn't manipulate the DOM. This is just a function." } -] +] \ No newline at end of file diff --git a/ts/util/longRunningTaskWrapper.ts b/ts/util/longRunningTaskWrapper.ts index 88d916174b..0ef131e51d 100644 --- a/ts/util/longRunningTaskWrapper.ts +++ b/ts/util/longRunningTaskWrapper.ts @@ -16,14 +16,14 @@ export async function longRunningTaskWrapper({ const ONE_SECOND = 1000; const TWO_SECONDS = 2000; - let progressView: typeof Whisper.ReactWrapperView | undefined; + let progressView: typeof window.Whisper.ReactWrapperView | undefined; let spinnerStart; let progressTimeout: NodeJS.Timeout | undefined = setTimeout(() => { window.log.info(`longRunningTaskWrapper/${idLog}: Creating spinner`); // Note: this component uses a portal to render itself into the top-level DOM. No // need to attach it to the DOM here. - progressView = new Whisper.ReactWrapperView({ + progressView = new window.Whisper.ReactWrapperView({ className: 'progress-modal-wrapper', Component: window.Signal.Components.ProgressModal, }); @@ -76,7 +76,7 @@ export async function longRunningTaskWrapper({ // Note: this component uses a portal to render itself into the top-level DOM. No // need to attach it to the DOM here. - const errorView = new Whisper.ReactWrapperView({ + const errorView = new window.Whisper.ReactWrapperView({ className: 'error-modal-wrapper', Component: window.Signal.Components.ErrorModal, props: { diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index df88363009..3b36574e5e 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -3,14 +3,10 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -// This allows us to pull in types despite the fact that this is not a module. We can't -// use normal import syntax, nor can we use 'import type' syntax, or this will be turned -// into a module, and we'll get the dreaded 'exports is not defined' error. -// see https://github.com/microsoft/TypeScript/issues/41562 -type AttachmentType = import('../types/Attachment').AttachmentType; -type GroupV2PendingMemberType = import('../model-types.d').GroupV2PendingMemberType; -type MediaItemType = import('../components/LightboxGallery').MediaItemType; -type MessageType = import('../state/ducks/conversations').MessageType; +import { AttachmentType } from '../types/Attachment'; +import { GroupV2PendingMemberType } from '../model-types.d'; +import { MediaItemType } from '../components/LightboxGallery'; +import { MessageType } from '../state/ducks/conversations'; type GetLinkPreviewImageResult = { data: ArrayBuffer; @@ -250,7 +246,7 @@ Whisper.MessageBodyTooLongToast = Whisper.ToastView.extend({ }); Whisper.FileSizeToast = Whisper.ToastView.extend({ - templateName: 'file-size-modal', + template: () => $('#file-size-modal').html(), render_attributes() { return { 'file-size-warning': window.i18n('fileSizeWarning'), @@ -267,31 +263,31 @@ Whisper.UnableToLoadToast = Whisper.ToastView.extend({ }); Whisper.DangerousFileTypeToast = Whisper.ToastView.extend({ - template: window.i18n('dangerousFileType'), + template: () => window.i18n('dangerousFileType'), }); Whisper.OneNonImageAtATimeToast = Whisper.ToastView.extend({ - template: window.i18n('oneNonImageAtATimeToast'), + template: () => window.i18n('oneNonImageAtATimeToast'), }); Whisper.CannotMixImageAndNonImageAttachmentsToast = Whisper.ToastView.extend({ - template: window.i18n('cannotMixImageAndNonImageAttachments'), + template: () => window.i18n('cannotMixImageAndNonImageAttachments'), }); Whisper.MaxAttachmentsToast = Whisper.ToastView.extend({ - template: window.i18n('maximumAttachments'), + template: () => window.i18n('maximumAttachments'), }); Whisper.AlreadyGroupMemberToast = Whisper.ToastView.extend({ - template: window.i18n('GroupV2--join--already-in-group'), + template: () => window.i18n('GroupV2--join--already-in-group'), }); Whisper.AlreadyRequestedToJoinToast = Whisper.ToastView.extend({ - template: window.i18n('GroupV2--join--already-awaiting-approval'), + template: () => window.i18n('GroupV2--join--already-awaiting-approval'), }); Whisper.ConversationLoadingScreen = Whisper.View.extend({ - templateName: 'conversation-loading-screen', + template: () => $('#conversation-loading-screen').html(), className: 'conversation-loading-screen', }); @@ -302,7 +298,7 @@ Whisper.ConversationView = Whisper.View.extend({ id() { return `conversation-${this.model.cid}`; }, - template: $('#conversation').html(), + template: () => $('#conversation').html(), render_attributes() { return { 'send-message': window.i18n('sendMessage'), @@ -374,31 +370,37 @@ Whisper.ConversationView = Whisper.View.extend({ this.downloadNewVersion ); - this.lazyUpdateVerified = _.debounce( + this.lazyUpdateVerified = window._.debounce( this.model.updateVerified.bind(this.model), 1000 // one second ); this.model.throttledGetProfiles = this.model.throttledGetProfiles || - _.throttle(this.model.getProfiles.bind(this.model), FIVE_MINUTES); + window._.throttle(this.model.getProfiles.bind(this.model), FIVE_MINUTES); this.model.throttledUpdateSharedGroups = this.model.throttledUpdateSharedGroups || - _.throttle(this.model.updateSharedGroups.bind(this.model), FIVE_MINUTES); + window._.throttle( + this.model.updateSharedGroups.bind(this.model), + FIVE_MINUTES + ); this.model.throttledFetchLatestGroupV2Data = this.model.throttledFetchLatestGroupV2Data || - _.throttle( + window._.throttle( this.model.fetchLatestGroupV2Data.bind(this.model), FIVE_MINUTES ); this.model.throttledMaybeMigrateV1Group = this.model.throttledMaybeMigrateV1Group || - _.throttle(this.model.maybeMigrateV1Group.bind(this.model), FIVE_MINUTES); + window._.throttle( + this.model.maybeMigrateV1Group.bind(this.model), + FIVE_MINUTES + ); - this.debouncedMaybeGrabLinkPreview = _.debounce( + this.debouncedMaybeGrabLinkPreview = window._.debounce( this.maybeGrabLinkPreview.bind(this), 200 ); - this.debouncedSaveDraft = _.debounce(this.saveDraft.bind(this), 200); + this.debouncedSaveDraft = window._.debounce(this.saveDraft.bind(this), 200); this.render(); @@ -1506,7 +1508,7 @@ Whisper.ConversationView = Whisper.View.extend({ const draftAttachments = this.model.get('draftAttachments') || []; this.model.set({ - draftAttachments: _.reject( + draftAttachments: window._.reject( draftAttachments, item => item.path === attachment.path ), @@ -1553,7 +1555,7 @@ Whisper.ConversationView = Whisper.View.extend({ } const draftAttachments = this.model.get('draftAttachments') || []; - const files = _.compact( + const files = window._.compact( await Promise.all( draftAttachments.map((attachment: any) => this.getFile(attachment)) ) @@ -1575,7 +1577,7 @@ Whisper.ConversationView = Whisper.View.extend({ } return { - ..._.pick(attachment, [ + ...window._.pick(attachment, [ 'contentType', 'fileName', 'size', @@ -1620,14 +1622,14 @@ Whisper.ConversationView = Whisper.View.extend({ if (toWrite.data) { const path = await writeNewDraftData(toWrite.data); toWrite = { - ..._.omit(toWrite, ['data']), + ...window._.omit(toWrite, ['data']), path, }; } if (toWrite.screenshotData) { const screenshotPath = await writeNewDraftData(toWrite.screenshotData); toWrite = { - ..._.omit(toWrite, ['screenshotData']), + ...window._.omit(toWrite, ['screenshotData']), screenshotPath, }; } @@ -3168,19 +3170,23 @@ Whisper.ConversationView = Whisper.View.extend({ }, async destroyMessages() { - try { - await this.confirm(window.i18n('deleteConversationConfirmation')); - this.longRunningTaskWrapper({ - name: 'destroymessages', - task: async () => { - this.model.trigger('unload', 'delete messages'); - await this.model.destroyMessages(); - this.model.updateLastMessage(); - }, - }); - } catch (error) { - // nothing to see here, user canceled out of dialog - } + window.showConfirmationDialog({ + message: window.i18n('deleteConversationConfirmation'), + okText: window.i18n('delete'), + resolve: () => { + this.longRunningTaskWrapper({ + name: 'destroymessages', + task: async () => { + this.model.trigger('unload', 'delete messages'); + await this.model.destroyMessages(); + this.model.updateLastMessage(); + }, + }); + }, + reject: () => { + window.log.info('destroyMessages: User canceled delete'); + }, + }); }, async isCallSafe() { @@ -3981,7 +3987,7 @@ Whisper.ConversationView = Whisper.View.extend({ // We eliminate the ObjectURL here, unneeded for send or save return { ...item, - image: _.omit(item.image, 'url'), + image: window._.omit(item.image, 'url'), }; } diff --git a/ts/window.d.ts b/ts/window.d.ts index 414fdbea51..5879559d7b 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -107,7 +107,7 @@ type ConfirmationDialogViewProps = { confirmStyle?: 'affirmative' | 'negative'; message: string; okText: string; - reject?: () => void; + reject?: (error: Error) => void; resolve: () => void; }; @@ -115,6 +115,8 @@ declare global { // We want to extend `window`'s properties, so we need an interface. // eslint-disable-next-line no-restricted-syntax interface Window { + startApp: () => void; + _: typeof Underscore; $: typeof jQuery; @@ -127,6 +129,10 @@ declare global { PQueue: typeof PQueue; PQueueType: PQueue; + Mustache: { + render: (template: string, data: any, partials?: any) => string; + parse: (template: string) => void; + }; WhatIsThis: WhatIsThis; @@ -655,7 +661,7 @@ export type WhisperType = { ClearDataView: WhatIsThis; ReactWrapperView: WhatIsThis; activeConfirmationView: WhatIsThis; - ToastView: typeof Whisper.View & { + ToastView: typeof window.Whisper.View & { show: (view: typeof Backbone.View, el: Element) => void; }; ConversationArchivedToast: WhatIsThis; @@ -731,33 +737,35 @@ export type WhisperType = { deliveryReceiptBatcher: BatcherType; RotateSignedPreKeyListener: WhatIsThis; - AlreadyGroupMemberToast: typeof Whisper.ToastView; - AlreadyRequestedToJoinToast: typeof Whisper.ToastView; - BlockedGroupToast: typeof Whisper.ToastView; - BlockedToast: typeof Whisper.ToastView; - CannotMixImageAndNonImageAttachmentsToast: typeof Whisper.ToastView; - DangerousFileTypeToast: typeof Whisper.ToastView; - ExpiredToast: typeof Whisper.ToastView; - FileSavedToast: typeof Whisper.ToastView; + AlreadyGroupMemberToast: typeof window.Whisper.ToastView; + AlreadyRequestedToJoinToast: typeof window.Whisper.ToastView; + BlockedGroupToast: typeof window.Whisper.ToastView; + BlockedToast: typeof window.Whisper.ToastView; + CannotMixImageAndNonImageAttachmentsToast: typeof window.Whisper.ToastView; + DangerousFileTypeToast: typeof window.Whisper.ToastView; + ExpiredToast: typeof window.Whisper.ToastView; + FileSavedToast: typeof window.Whisper.ToastView; FileSizeToast: any; - FoundButNotLoadedToast: typeof Whisper.ToastView; - InvalidConversationToast: typeof Whisper.ToastView; - LeftGroupToast: typeof Whisper.ToastView; - MaxAttachmentsToast: typeof Whisper.ToastView; - MessageBodyTooLongToast: typeof Whisper.ToastView; - OneNonImageAtATimeToast: typeof Whisper.ToastView; - OriginalNoLongerAvailableToast: typeof Whisper.ToastView; - OriginalNotFoundToast: typeof Whisper.ToastView; - PinnedConversationsFullToast: typeof Whisper.ToastView; - ReactionFailedToast: typeof Whisper.ToastView; - TapToViewExpiredIncomingToast: typeof Whisper.ToastView; - TapToViewExpiredOutgoingToast: typeof Whisper.ToastView; - TimerConflictToast: typeof Whisper.ToastView; - UnableToLoadToast: typeof Whisper.ToastView; - VoiceNoteLimit: typeof Whisper.ToastView; - VoiceNoteMustBeOnlyAttachmentToast: typeof Whisper.ToastView; + FoundButNotLoadedToast: typeof window.Whisper.ToastView; + InvalidConversationToast: typeof window.Whisper.ToastView; + LeftGroupToast: typeof window.Whisper.ToastView; + MaxAttachmentsToast: typeof window.Whisper.ToastView; + MessageBodyTooLongToast: typeof window.Whisper.ToastView; + OneNonImageAtATimeToast: typeof window.Whisper.ToastView; + OriginalNoLongerAvailableToast: typeof window.Whisper.ToastView; + OriginalNotFoundToast: typeof window.Whisper.ToastView; + PinnedConversationsFullToast: typeof window.Whisper.ToastView; + ReactionFailedToast: typeof window.Whisper.ToastView; + TapToViewExpiredIncomingToast: typeof window.Whisper.ToastView; + TapToViewExpiredOutgoingToast: typeof window.Whisper.ToastView; + TimerConflictToast: typeof window.Whisper.ToastView; + UnableToLoadToast: typeof window.Whisper.ToastView; + VoiceNoteLimit: typeof window.Whisper.ToastView; + VoiceNoteMustBeOnlyAttachmentToast: typeof window.Whisper.ToastView; - ConversationLoadingScreen: typeof Whisper.View; - ConversationView: typeof Whisper.View; - View: typeof Backbone.View; + ConversationLoadingScreen: typeof window.Whisper.View; + ConversationView: typeof window.Whisper.View; + View: typeof Backbone.View & { + Templates: Record; + }; }; From d7d70da315eecb8bd87603cc0791757f71722e92 Mon Sep 17 00:00:00 2001 From: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com> Date: Mon, 1 Mar 2021 13:55:49 -0600 Subject: [PATCH 021/181] Fix typo in buildGroupProto error message --- ts/groups.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ts/groups.ts b/ts/groups.ts index d5c87caf4d..c7e7d8313c 100644 --- a/ts/groups.ts +++ b/ts/groups.ts @@ -454,7 +454,7 @@ async function buildGroupProto({ const profileKeyCredentialBase64 = conversation.get('profileKeyCredential'); if (!profileKeyCredentialBase64) { throw new Error( - `buildGroupProto/${logId}: member was missing profileKeyCredentia!` + `buildGroupProto/${logId}: member was missing profileKeyCredential!` ); } const presentation = createProfileKeyCredentialPresentation( From dfa5005e7d782389046b1b596e7d8e42b438b144 Mon Sep 17 00:00:00 2001 From: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com> Date: Mon, 1 Mar 2021 14:08:37 -0600 Subject: [PATCH 022/181] Update conversation header design --- stylesheets/_modules.scss | 330 ------------------ .../components/ConversationHeader.scss | 330 ++++++++++++++++++ stylesheets/manifest.scss | 1 + ts/background.ts | 2 +- ts/components/Tooltip.tsx | 23 +- .../ConversationHeader.stories.tsx | 14 + .../conversation/ConversationHeader.tsx | 297 ++++++++-------- ts/util/lint/exceptions.json | 4 +- 8 files changed, 514 insertions(+), 487 deletions(-) create mode 100644 stylesheets/components/ConversationHeader.scss diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index cff75a61be..e9098d8c35 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -2853,336 +2853,6 @@ $timer-icons: '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05', } } -// Module: Conversation Header - -.module-conversation-header { - padding-left: 16px; - padding-right: 16px; - padding-top: var(--title-bar-drag-area-height); - display: flex; - flex-direction: row; - align-items: center; - - height: calc(#{$header-height} + var(--title-bar-drag-area-height)); - - @include light-theme { - color: $color-gray-90; - background-color: $color-white; - } - @include dark-theme { - color: $color-gray-02; - background-color: $color-gray-95; - } -} - -.module-conversation-header__back-icon { - $transition: 250ms ease-out; - - display: inline-block; - margin-left: -10px; - margin-right: -10px; - width: 24px; - height: 24px; - min-width: 24px; - vertical-align: text-bottom; - border: none; - opacity: 0; - transition: margin-right $transition, opacity $transition; - - &:disabled { - cursor: default; - } - - &--show { - opacity: 1; - margin-right: 6px; - } - - @include light-theme { - @include color-svg( - '../images/icons/v2/chevron-left-24.svg', - $color-gray-90 - ); - } - @include dark-theme { - @include color-svg( - '../images/icons/v2/chevron-left-24.svg', - $color-gray-02 - ); - } -} - -.module-conversation-header__title-container { - flex-grow: 1; - flex-shrink: 1; - min-width: 0; - display: block; - - height: $header-height; -} - -.module-conversation-header__title-flex { - margin-left: auto; - margin-right: auto; - display: inline-flex; - flex-direction: row; - align-items: center; - height: $header-height; - max-width: 100%; -} - -.module-conversation-header__title-clickable { - cursor: pointer; - - &:focus { - @include mouse-mode { - outline: none; - } - } -} - -.module-conversation-header__note-to-self { - @include dark-theme { - color: $color-gray-02; - } -} - -.module-conversation-header__avatar { - min-width: 32px; - margin-right: 4px; -} - -.module-conversation-header__title { - margin-left: 6px; - min-width: 0; - - @include font-body-1-bold; - - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - - -webkit-user-select: text; - - @include light-theme { - color: $color-gray-90; - } - @include dark-theme { - color: $color-gray-02; - } -} - -.module-conversation-header__contacts-icon { - display: inline-block; - height: 15px; - width: 15px; - - margin-bottom: 3px; - vertical-align: middle; - - @include light-theme { - @include color-svg( - '../images/icons/v2/profile-circle-outline-24.svg', - $color-gray-60 - ); - } - @include dark-theme { - @include color-svg( - '../images/icons/v2/profile-circle-outline-24.svg', - $color-gray-25 - ); - } - - @include keyboard-mode { - &:focus { - @include color-svg( - '../images/icons/v2/profile-circle-outline-24.svg', - $ultramarine-ui-light - ); - } - } -} - -.module-conversation-header__title__profile-name { - @include font-body-1-bold-italic; -} - -.module-conversation-header__title__verified-icon { - display: inline-block; - width: 1.25em; - height: 1.25em; - vertical-align: text-bottom; - - @include light-theme { - @include color-svg('../images/icons/v2/check-24.svg', $color-gray-90); - } - @include dark-theme { - @include color-svg('../images/icons/v2/check-24.svg', $color-gray-02); - } -} - -.module-conversation-header__expiration { - display: flex; - flex-direction: row; - align-items: center; - padding-left: 8px; - padding-right: 8px; - transition: opacity 250ms ease-out; - - &--hidden { - opacity: 0; - } -} - -.module-conversation-header__expiration__clock-icon { - height: 24px; - width: 24px; - display: inline-block; - - @include light-theme { - @include color-svg('../images/icons/v2/timer-24.svg', $color-gray-60); - } - @include dark-theme { - @include color-svg('../images/icons/v2/timer-24.svg', $color-gray-25); - } -} - -.module-conversation-header__expiration__setting { - margin-left: 5px; - text-align: center; -} - -.module-conversation-header__more-button { - height: 24px; - width: 24px; - margin-left: 12px; - border: none; - opacity: 0; - transition: opacity 250ms ease-out; - - &:disabled { - cursor: default; - } - - &--show { - opacity: 1; - } - - @include light-theme { - @include color-svg( - '../images/icons/v2/chevron-down-24.svg', - $color-gray-75 - ); - } - @include dark-theme { - @include color-svg( - '../images/icons/v2/chevron-down-24.svg', - $color-gray-15 - ); - } -} - -.module-conversation-header__search-button { - height: 24px; - width: 24px; - margin-left: 12px; - border: none; - opacity: 0; - transition: opacity 250ms ease-out; - - &:disabled { - cursor: default; - } - - &--show { - opacity: 1; - } - - @include light-theme { - @include color-svg('../images/icons/v2/search-24.svg', $color-gray-75); - } - @include dark-theme { - @include color-svg('../images/icons/v2/search-24.svg', $color-gray-15); - } -} - -.module-conversation-header__calling-button { - $icon-size: 24px; - - margin-left: 12px; - border: none; - opacity: 0; - transition: opacity 250ms ease-out; - - &:disabled { - cursor: default; - } - - &--show { - opacity: 1; - } - - &--video { - @include light-theme { - @include color-svg( - '../images/icons/v2/video-outline-24.svg', - $color-gray-75 - ); - } - @include dark-theme { - @include color-svg( - '../images/icons/v2/video-solid-24.svg', - $color-gray-15 - ); - } - height: $icon-size; - width: $icon-size; - } - - &--audio { - @include light-theme { - @include color-svg( - '../images/icons/v2/phone-right-outline-24.svg', - $color-gray-75 - ); - } - @include dark-theme { - @include color-svg( - '../images/icons/v2/phone-right-solid-24.svg', - $color-gray-15 - ); - } - height: $icon-size; - width: $icon-size; - } - - &--join { - @include font-body-1; - align-items: center; - background-color: $color-accent-green; - border-radius: 9999px; // This ensures the borders are completely rounded. (A value like 100% would make it an ellipse.) - color: $color-white; - display: flex; - outline: none; - padding: 5px 18px; - - @include keyboard-mode { - &:focus { - box-shadow: 0px 0px 0px 4px $ultramarine-ui-light; - } - } - - &:before { - @include color-svg('../images/icons/v2/video-solid-24.svg', $color-white); - content: ''; - display: block; - height: $icon-size; - margin-right: 5px; - width: $icon-size; - } - } -} - // Module: Conversation Details .conversation-details-panel { diff --git a/stylesheets/components/ConversationHeader.scss b/stylesheets/components/ConversationHeader.scss new file mode 100644 index 0000000000..6bdfba60d1 --- /dev/null +++ b/stylesheets/components/ConversationHeader.scss @@ -0,0 +1,330 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.module-ConversationHeader { + --button-spacing: 24px; + + &.module-ConversationHeader--narrow { + --button-spacing: 16px; + } + + padding-top: var(--title-bar-drag-area-height); + display: flex; + flex-direction: row; + align-items: center; + + height: calc(#{$header-height} + var(--title-bar-drag-area-height)); + + @include light-theme { + color: $color-gray-90; + background-color: $color-white; + } + @include dark-theme { + color: $color-gray-02; + background-color: $color-gray-95; + } + + &__back-icon { + $transition: 250ms ease-out; + + display: inline-block; + width: 24px; + height: 24px; + min-width: 24px; + margin-left: -24px; + vertical-align: text-bottom; + border: none; + opacity: 0; + transition: margin-left $transition, opacity $transition; + + &:disabled { + cursor: default; + } + + &--show { + opacity: 1; + margin-right: 6px; + margin-left: 12px; + } + + @include light-theme { + @include color-svg( + '../images/icons/v2/chevron-left-24.svg', + $color-gray-90 + ); + } + @include dark-theme { + @include color-svg( + '../images/icons/v2/chevron-left-24.svg', + $color-gray-02 + ); + } + } + + &__header { + $padding: 4px 12px; + + align-items: center; + display: flex; + flex-direction: row; + flex-grow: 1; + margin-left: 4px; + margin-right: var(--button-spacing); + padding: $padding; + overflow: hidden; + min-width: 0; + transition: margin-right 200ms ease-out; + + &--clickable { + @include button-reset; + border-radius: 4px; + + // These are clobbered by button-reset: + margin-left: 4px; + margin-right: var(--button-spacing); + padding: $padding; + + @include keyboard-mode { + &:focus { + color: $ultramarine-ui-light; + } + } + @include dark-keyboard-mode { + &:focus { + color: $ultramarine-ui-dark; + } + } + } + + &__avatar { + min-width: 32px; + margin-right: 12px; + padding-top: 4px; + padding-bottom: 4px; + } + + &__info { + display: flex; + flex-direction: column; + min-width: 0; + + &__title { + @include font-body-1-bold; + + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + user-select: none; + + &__in-contacts-icon { + margin-left: 4px; + } + } + + &__subtitle { + display: flex; + @include font-body-2; + + @include light-theme { + color: $color-gray-60; + } + @include dark-theme { + color: $color-gray-25; + } + + @mixin subtitle-element($icon) { + display: flex; + align-items: center; + user-select: none; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + + &::before { + content: ''; + width: 13px; + height: 13px; + display: block; + margin-right: 4px; + + @include light-theme { + @include color-svg($icon, $color-gray-60); + } + @include dark-theme { + @include color-svg($icon, $color-gray-25); + } + } + } + + &__expiration { + @include subtitle-element('../images/icons/v2/timer-24.svg'); + margin-right: 12px; + } + + &__verified { + @include subtitle-element('../images/icons/v2/check-24.svg'); + } + } + } + } + + &__calling-button--video, + &__calling-button--audio, + &__calling-button--join, + &__search-button, + &__more-button { + margin-right: var(--button-spacing); + transition: margin-right 200ms ease-out; + + @include keyboard-mode { + &:focus { + background: $ultramarine-ui-light; + } + } + @include dark-keyboard-mode { + &:focus { + background: $ultramarine-ui-dark; + } + } + } + + &__calling-button { + $icon-size: 24px; + + border: none; + opacity: 0; + transition: opacity 250ms ease-out; + + &:disabled { + cursor: default; + } + + &--show { + opacity: 1; + } + + &--video { + @include light-theme { + @include color-svg( + '../images/icons/v2/video-outline-24.svg', + $color-gray-75 + ); + } + @include dark-theme { + @include color-svg( + '../images/icons/v2/video-solid-24.svg', + $color-gray-15 + ); + } + height: $icon-size; + width: $icon-size; + min-width: $icon-size; + } + + &--audio { + @include light-theme { + @include color-svg( + '../images/icons/v2/phone-right-outline-24.svg', + $color-gray-75 + ); + } + @include dark-theme { + @include color-svg( + '../images/icons/v2/phone-right-solid-24.svg', + $color-gray-15 + ); + } + height: $icon-size; + width: $icon-size; + min-width: $icon-size; + } + + &--join { + @include font-body-1; + align-items: center; + background-color: $color-accent-green; + border-radius: 9999px; // This ensures the borders are completely rounded. (A value like 100% would make it an ellipse.) + color: $color-white; + display: flex; + outline: none; + overflow: hidden; + padding: 5px 18px; + text-overflow: ellipsis; + white-space: nowrap; + user-select: none; + + @include keyboard-mode { + &:focus { + box-shadow: 0px 0px 0px 4px $ultramarine-ui-light; + } + } + + &:before { + @include color-svg( + '../images/icons/v2/video-solid-24.svg', + $color-white + ); + content: ''; + display: block; + height: $icon-size; + margin-right: 5px; + min-width: $icon-size; + width: $icon-size; + } + } + } + + &__search-button { + height: 24px; + width: 24px; + min-width: 24px; + border: none; + opacity: 0; + transition: opacity 250ms ease-out; + + &:disabled { + cursor: default; + } + + &--show { + opacity: 1; + } + + @include light-theme { + @include color-svg('../images/icons/v2/search-24.svg', $color-gray-75); + } + @include dark-theme { + @include color-svg('../images/icons/v2/search-24.svg', $color-gray-15); + } + } + + &__more-button { + height: 24px; + width: 24px; + min-width: 24px; + border: none; + opacity: 0; + transition: opacity 250ms ease-out; + + &:disabled { + cursor: default; + } + + &--show { + opacity: 1; + } + + @include light-theme { + @include color-svg( + '../images/icons/v2/chevron-down-24.svg', + $color-gray-75 + ); + } + @include dark-theme { + @include color-svg( + '../images/icons/v2/chevron-down-24.svg', + $color-gray-15 + ); + } + } +} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index b4d8538986..674be45d11 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -28,3 +28,4 @@ // New style: components @import './components/Button.scss'; +@import './components/ConversationHeader.scss'; diff --git a/ts/background.ts b/ts/background.ts index 59d95fe465..91222b96df 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -1070,7 +1070,7 @@ export async function startApp(): Promise { (key === 'l' || key === 'L') ) { const button = document.querySelector( - '.module-conversation-header__more-button' + '.module-ConversationHeader__more-button' ); if (!button) { return; diff --git a/ts/components/Tooltip.tsx b/ts/components/Tooltip.tsx index 6c3f67c569..f87e6566b2 100644 --- a/ts/components/Tooltip.tsx +++ b/ts/components/Tooltip.tsx @@ -22,6 +22,14 @@ const TooltipEventWrapper = React.forwardRef< >(({ onHoverChanged, children }, ref) => { const wrapperRef = React.useRef(null); + const on = React.useCallback(() => { + onHoverChanged(true); + }, [onHoverChanged]); + + const off = React.useCallback(() => { + onHoverChanged(false); + }, [onHoverChanged]); + React.useEffect(() => { const wrapperEl = wrapperRef.current; @@ -29,28 +37,19 @@ const TooltipEventWrapper = React.forwardRef< return noop; } - const on = () => { - onHoverChanged(true); - }; - const off = () => { - onHoverChanged(false); - }; - - wrapperEl.addEventListener('focus', on); - wrapperEl.addEventListener('blur', off); wrapperEl.addEventListener('mouseenter', on); wrapperEl.addEventListener('mouseleave', off); return () => { - wrapperEl.removeEventListener('focus', on); - wrapperEl.removeEventListener('blur', off); wrapperEl.removeEventListener('mouseenter', on); wrapperEl.removeEventListener('mouseleave', off); }; - }, [onHoverChanged]); + }, [on, off]); return ( { wrapperRef.current = el; diff --git a/ts/components/conversation/ConversationHeader.stories.tsx b/ts/components/conversation/ConversationHeader.stories.tsx index 5f005ac1fe..c7d34124cc 100644 --- a/ts/components/conversation/ConversationHeader.stories.tsx +++ b/ts/components/conversation/ConversationHeader.stories.tsx @@ -159,6 +159,20 @@ const stories: Array = [ acceptedMessageRequest: true, }, }, + { + title: 'Disappearing messages + verified', + props: { + ...commonProps, + color: 'indigo', + title: '(202) 555-0005', + phoneNumber: '(202) 555-0005', + type: 'direct', + id: '5', + expireTimer: 60, + acceptedMessageRequest: true, + isVerified: true, + }, + }, { title: 'Muting Conversation', props: { diff --git a/ts/components/conversation/ConversationHeader.tsx b/ts/components/conversation/ConversationHeader.tsx index c515539d04..28323aca23 100644 --- a/ts/components/conversation/ConversationHeader.tsx +++ b/ts/components/conversation/ConversationHeader.tsx @@ -1,7 +1,8 @@ -// Copyright 2018-2020 Signal Messenger, LLC +// Copyright 2018-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React from 'react'; +import React, { ReactNode } from 'react'; +import Measure from 'react-measure'; import moment from 'moment'; import classNames from 'classnames'; import { @@ -92,27 +93,33 @@ export type PropsType = PropsDataType & PropsActionsType & PropsHousekeepingType; -export class ConversationHeader extends React.Component { - public showMenuBound: (event: React.MouseEvent) => void; +type StateType = { + isNarrow: boolean; +}; + +export class ConversationHeader extends React.Component { + private showMenuBound: (event: React.MouseEvent) => void; // Comes from a third-party dependency // eslint-disable-next-line @typescript-eslint/no-explicit-any - public menuTriggerRef: React.RefObject; + private menuTriggerRef: React.RefObject; public constructor(props: PropsType) { super(props); + this.state = { isNarrow: false }; + this.menuTriggerRef = React.createRef(); this.showMenuBound = this.showMenu.bind(this); } - public showMenu(event: React.MouseEvent): void { + private showMenu(event: React.MouseEvent): void { if (this.menuTriggerRef.current) { this.menuTriggerRef.current.handleContextClick(event); } } - public renderBackButton(): JSX.Element { + private renderBackButton(): ReactNode { const { i18n, onGoBack, showBackButton } = this.props; return ( @@ -120,8 +127,8 @@ export class ConversationHeader extends React.Component { type="button" onClick={onGoBack} className={classNames( - 'module-conversation-header__back-icon', - showBackButton ? 'module-conversation-header__back-icon--show' : null + 'module-ConversationHeader__back-icon', + showBackButton ? 'module-ConversationHeader__back-icon--show' : null )} disabled={!showBackButton} aria-label={i18n('goBack')} @@ -129,51 +136,49 @@ export class ConversationHeader extends React.Component { ); } - public renderTitle(): JSX.Element | null { - const { - name, - phoneNumber, - title, - type, - i18n, - isMe, - profileName, - isVerified, - } = this.props; + private renderHeaderInfoTitle(): ReactNode { + const { name, title, type, i18n, isMe } = this.props; if (isMe) { return ( -
+
{i18n('noteToSelf')}
); } const shouldShowIcon = Boolean(name && type === 'direct'); - const shouldShowNumber = Boolean(phoneNumber && (name || profileName)); return ( -
+
{shouldShowIcon ? ( - - {' '} - - - ) : null} - {shouldShowNumber ? ` · ${phoneNumber}` : null} - {isVerified ? ( - - {' · '} - - {i18n('verified')} - + ) : null}
); } - public renderAvatar(): JSX.Element { + private renderHeaderInfoSubtitle(): ReactNode { + const expirationNode = this.renderExpirationLength(); + const verifiedNode = this.renderVerifiedIcon(); + + if (expirationNode || verifiedNode) { + return ( +
+ {expirationNode} + {verifiedNode} +
+ ); + } + + return null; + } + + private renderAvatar(): ReactNode { const { avatarPath, color, @@ -187,7 +192,7 @@ export class ConversationHeader extends React.Component { } = this.props; return ( - + { ); } - public renderExpirationLength(): JSX.Element | null { - const { i18n, expireTimer, showBackButton } = this.props; + private renderExpirationLength(): ReactNode { + const { i18n, expireTimer } = this.props; const expirationSettingName = expireTimer - ? ExpirationTimerOptions.getName(i18n, expireTimer) + ? ExpirationTimerOptions.getAbbreviated(i18n, expireTimer) : undefined; if (!expirationSettingName) { return null; } return ( -
-
-
- {expirationSettingName} -
+
+ {expirationSettingName}
); } - public renderMoreButton(triggerId: string): JSX.Element { + private renderVerifiedIcon(): ReactNode { + const { i18n, isVerified } = this.props; + + if (!isVerified) { + return null; + } + + return ( +
+ {i18n('verified')} +
+ ); + } + + private renderMoreButton(triggerId: string): ReactNode { const { i18n, showBackButton } = this.props; return ( @@ -240,10 +249,10 @@ export class ConversationHeader extends React.Component { type="button" onClick={this.showMenuBound} className={classNames( - 'module-conversation-header__more-button', + 'module-ConversationHeader__more-button', showBackButton ? null - : 'module-conversation-header__more-button--show' + : 'module-ConversationHeader__more-button--show' )} disabled={showBackButton} aria-label={i18n('moreInfo')} @@ -252,7 +261,7 @@ export class ConversationHeader extends React.Component { ); } - public renderSearchButton(): JSX.Element { + private renderSearchButton(): ReactNode { const { i18n, onSearchInConversation, showBackButton } = this.props; return ( @@ -260,10 +269,10 @@ export class ConversationHeader extends React.Component { type="button" onClick={onSearchInConversation} className={classNames( - 'module-conversation-header__search-button', + 'module-ConversationHeader__search-button', showBackButton ? null - : 'module-conversation-header__search-button--show' + : 'module-ConversationHeader__search-button--show' )} disabled={showBackButton} aria-label={i18n('search')} @@ -271,7 +280,7 @@ export class ConversationHeader extends React.Component { ); } - private renderOutgoingCallButtons(): JSX.Element | null { + private renderOutgoingCallButtons(): ReactNode { const { i18n, onOutgoingAudioCallInConversation, @@ -279,17 +288,18 @@ export class ConversationHeader extends React.Component { outgoingCallButtonStyle, showBackButton, } = this.props; + const { isNarrow } = this.state; const videoButton = ( ); default: @@ -342,7 +353,7 @@ export class ConversationHeader extends React.Component { } } - public renderMenu(triggerId: string): JSX.Element { + private renderMenu(triggerId: string): ReactNode { const { i18n, acceptedMessageRequest, @@ -484,7 +495,7 @@ export class ConversationHeader extends React.Component { ); } - private renderHeader(): JSX.Element { + private renderHeader(): ReactNode { const { conversationTitle, groupVersion, @@ -497,92 +508,94 @@ export class ConversationHeader extends React.Component { if (conversationTitle !== undefined) { return ( -
-
- {conversationTitle} +
+
+
+ {conversationTitle} +
); } - const hasGV2AdminEnabled = - groupVersion === 2 && - window.Signal.RemoteConfig.isEnabled('desktop.gv2Admin'); - - if (type === 'group' && hasGV2AdminEnabled) { - const onHeaderClick = () => onShowConversationDetails(); - const onKeyDown = (e: React.KeyboardEvent): void => { - if (e.key === 'Enter' || e.key === ' ') { - e.stopPropagation(); - e.preventDefault(); - - onShowConversationDetails(); - } - }; - - return ( -
- {this.renderAvatar()} - {this.renderTitle()} -
- ); - } - - if (type === 'group' || isMe) { - return ( -
- {this.renderAvatar()} - {this.renderTitle()} -
- ); - } - - const onContactClick = () => onShowContactModal(id); - const onKeyDown = (e: React.KeyboardEvent): void => { - if (e.key === 'Enter' || e.key === ' ') { - e.stopPropagation(); - e.preventDefault(); - - onShowContactModal(id); + let onClick: undefined | (() => void); + switch (type) { + case 'direct': + onClick = isMe + ? undefined + : () => { + onShowContactModal(id); + }; + break; + case 'group': { + const hasGV2AdminEnabled = + groupVersion === 2 && + window.Signal.RemoteConfig.isEnabled('desktop.gv2Admin'); + onClick = hasGV2AdminEnabled + ? () => { + onShowConversationDetails(); + } + : undefined; + break; } - }; + default: + throw missingCaseError(type); + } - return ( -
+ const contents = ( + <> {this.renderAvatar()} - {this.renderTitle()} -
+
+ {this.renderHeaderInfoTitle()} + {this.renderHeaderInfoSubtitle()} +
+ ); + + if (onClick) { + return ( + + ); + } + + return
{contents}
; } - public render(): JSX.Element { + public render(): ReactNode { const { id } = this.props; + const { isNarrow } = this.state; const triggerId = `conversation-${id}`; return ( -
- {this.renderBackButton()} -
- {this.renderHeader()} -
- {this.renderExpirationLength()} - {this.renderOutgoingCallButtons()} - {this.renderSearchButton()} - {this.renderMoreButton(triggerId)} - {this.renderMenu(triggerId)} -
+ { + const width = (bounds && bounds.width) || 0; + this.setState({ isNarrow: width < 500 }); + }} + > + {({ measureRef }) => ( +
+ {this.renderBackButton()} + {this.renderHeader()} + {this.renderOutgoingCallButtons()} + {this.renderSearchButton()} + {this.renderMoreButton(triggerId)} + {this.renderMenu(triggerId)} +
+ )} +
); } } diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 82953a14f8..aaef610e4a 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -14820,7 +14820,7 @@ "rule": "React-createRef", "path": "ts/components/conversation/ConversationHeader.js", "line": " this.menuTriggerRef = react_1.default.createRef();", - "lineNumber": 30, + "lineNumber": 32, "reasonCategory": "usageTrusted", "updated": "2020-08-28T16:12:19.904Z", "reasonDetail": "Used to reference popup menu" @@ -14829,7 +14829,7 @@ "rule": "React-createRef", "path": "ts/components/conversation/ConversationHeader.tsx", "line": " this.menuTriggerRef = React.createRef();", - "lineNumber": 105, + "lineNumber": 112, "reasonCategory": "usageTrusted", "updated": "2020-05-20T20:10:43.540Z", "reasonDetail": "Used to reference popup menu" From 12bba24dbdae460a459f4e1ca388590e80418da1 Mon Sep 17 00:00:00 2001 From: Josh Perez <60019601+josh-signal@users.noreply.github.com> Date: Tue, 2 Mar 2021 11:27:11 -0500 Subject: [PATCH 023/181] Improvements to Group Settings screen --- _locales/en/messages.json | 4 + .../ConversationDetails.stories.tsx | 6 +- .../ConversationDetails.tsx | 2 +- ...versationDetailsMembershipList.stories.tsx | 6 +- .../ConversationDetailsMembershipList.tsx | 48 ++++++++- .../GroupLinkManagement.stories.tsx | 38 ++++--- .../GroupLinkManagement.tsx | 100 ++++++++++-------- ts/models/conversations.ts | 14 +-- ts/state/smart/ConversationDetails.tsx | 3 +- ts/state/smart/GroupLinkManagement.tsx | 2 + ts/util/getAccessControlOptions.ts | 2 +- 11 files changed, 146 insertions(+), 79 deletions(-) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 16b1373fe5..e5b9432d7f 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -3390,6 +3390,10 @@ "message": "Admin", "description": "Label for a group administrator" }, + "GroupV2--only-admins": { + "message": "Only Admins", + "description": "Label for group administrators -- used in drop-downs to select permissions that apply to admins" + }, "GroupV2--all-members": { "message": "All members", "description": "Label for describing the general non-privileged members of a group" diff --git a/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx b/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx index 3b6b8fe261..99a816eb1f 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx @@ -23,9 +23,11 @@ const conversation: ConversationType = { id: '', lastUpdated: 0, markedUnread: false, - memberships: Array.from(Array(32)).map(() => ({ + memberships: Array.from(Array(32)).map((_, i) => ({ isAdmin: false, - member: getDefaultConversation({}), + member: getDefaultConversation({ + isMe: i === 2, + }), metadata: { conversationId: '', joinedAtVersion: 0, diff --git a/ts/components/conversation/conversation-details/ConversationDetails.tsx b/ts/components/conversation/conversation-details/ConversationDetails.tsx index b6338c2c59..7a5897b23a 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.tsx @@ -119,7 +119,7 @@ export const ConversationDetails: React.ComponentType = ({ /> - {isAdmin ? ( + {isAdmin || hasGroupLink ? ( ): Props => ({ i18n, - showContactModal: action('showContactModal'), memberships: overrideProps.memberships || [], + showContactModal: action('showContactModal'), }); story.add('Few', () => { diff --git a/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.tsx b/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.tsx index 01c191040e..647e5df3d3 100644 --- a/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.tsx @@ -26,26 +26,66 @@ export type Props = { const MAX_MEMBER_COUNT = 5; +const collator = new Intl.Collator(undefined, { sensitivity: 'base' }); +function sortConversationTitles( + left: GroupV2Membership, + right: GroupV2Membership +) { + const leftTitle = left.member.title; + const rightTitle = right.member.title; + return collator.compare(leftTitle, rightTitle); +} + +function sortMemberships( + memberships: ReadonlyArray +): Array { + let you: undefined | GroupV2Membership; + const admins: Array = []; + const nonAdmins: Array = []; + memberships.forEach(membershipInfo => { + const { isAdmin, member } = membershipInfo; + if (member.isMe) { + you = membershipInfo; + } else if (isAdmin) { + admins.push(membershipInfo); + } else { + nonAdmins.push(membershipInfo); + } + }); + admins.sort(sortConversationTitles); + nonAdmins.sort(sortConversationTitles); + + const sortedMemberships = []; + if (you) { + sortedMemberships.push(you); + } + sortedMemberships.push(...admins); + sortedMemberships.push(...nonAdmins); + + return sortedMemberships; +} + export const ConversationDetailsMembershipList: React.ComponentType = ({ memberships, showContactModal, i18n, }) => { const [showAllMembers, setShowAllMembers] = React.useState(false); + const sortedMemberships = sortMemberships(memberships); - const shouldHideRestMembers = memberships.length - MAX_MEMBER_COUNT > 1; + const shouldHideRestMembers = sortedMemberships.length - MAX_MEMBER_COUNT > 1; const membersToShow = shouldHideRestMembers && !showAllMembers ? MAX_MEMBER_COUNT - : memberships.length; + : sortedMemberships.length; return ( - {memberships.slice(0, membersToShow).map(({ isAdmin, member }) => ( + {sortedMemberships.slice(0, membersToShow).map(({ isAdmin, member }) => ( showContactModal(member.id)} diff --git a/ts/components/conversation/conversation-details/GroupLinkManagement.stories.tsx b/ts/components/conversation/conversation-details/GroupLinkManagement.stories.tsx index 5a75f63dad..31647fdcdf 100644 --- a/ts/components/conversation/conversation-details/GroupLinkManagement.stories.tsx +++ b/ts/components/conversation/conversation-details/GroupLinkManagement.stories.tsx @@ -51,36 +51,50 @@ function getConversation( }; } -const createProps = (conversation?: ConversationType): PropsType => ({ +const createProps = ( + conversation?: ConversationType, + isAdmin = false +): PropsType => ({ accessEnum: AccessEnum, changeHasGroupLink: action('changeHasGroupLink'), conversation: conversation || getConversation(), copyGroupLink: action('copyGroupLink'), generateNewGroupLink: action('generateNewGroupLink'), i18n, + isAdmin, setAccessControlAddFromInviteLinkSetting: action( 'setAccessControlAddFromInviteLinkSetting' ), }); -story.add('Off', () => { - const props = createProps(); +story.add('Off (Admin)', () => { + const props = createProps(undefined, true); return ; }); -story.add('On', () => { +story.add('On (Admin)', () => { + const props = createProps( + getConversation('https://signal.group/1', AccessEnum.ANY), + true + ); + + return ; +}); + +story.add('On (Admin + Admin Approval Needed)', () => { + const props = createProps( + getConversation('https://signal.group/1', AccessEnum.ADMINISTRATOR), + true + ); + + return ; +}); + +story.add('On (Non-admin)', () => { const props = createProps( getConversation('https://signal.group/1', AccessEnum.ANY) ); return ; }); - -story.add('On (Admin Approval Needed)', () => { - const props = createProps( - getConversation('https://signal.group/1', AccessEnum.ADMINISTRATOR) - ); - - return ; -}); diff --git a/ts/components/conversation/conversation-details/GroupLinkManagement.tsx b/ts/components/conversation/conversation-details/GroupLinkManagement.tsx index a3927689d8..de1e65188b 100644 --- a/ts/components/conversation/conversation-details/GroupLinkManagement.tsx +++ b/ts/components/conversation/conversation-details/GroupLinkManagement.tsx @@ -17,6 +17,7 @@ export type PropsType = { copyGroupLink: (groupLink: string) => void; generateNewGroupLink: () => void; i18n: LocalizerType; + isAdmin: boolean; setAccessControlAddFromInviteLinkSetting: (value: boolean) => void; }; @@ -27,6 +28,7 @@ export const GroupLinkManagement: React.ComponentType = ({ copyGroupLink, generateNewGroupLink, i18n, + isAdmin, setAccessControlAddFromInviteLinkSetting, }) => { if (conversation === undefined) { @@ -54,19 +56,21 @@ export const GroupLinkManagement: React.ComponentType = ({ info={groupLinkInfo} label={i18n('ConversationDetails--group-link')} right={ -
- -
+ isAdmin ? ( +
+ +
+ ) : null } />
@@ -88,41 +92,45 @@ export const GroupLinkManagement: React.ComponentType = ({ } }} /> - - } - label={i18n('GroupLinkManagement--reset')} - onClick={generateNewGroupLink} - /> + {isAdmin ? ( + + } + label={i18n('GroupLinkManagement--reset')} + onClick={generateNewGroupLink} + /> + ) : null}
- - - -
- } - /> - + {isAdmin ? ( + + + +
+ } + /> + + ) : null} ) : null} diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index a378a1c8f2..14037650c7 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -135,7 +135,7 @@ export class ConversationModel extends window.Backbone.Model< verifiedEnum?: typeof window.textsecure.storage.protocol.VerifiedStatus; - intlCollator = new Intl.Collator(); + intlCollator = new Intl.Collator(undefined, { sensitivity: 'base' }); private cachedLatestGroupCallEraId?: string; @@ -183,14 +183,12 @@ export class ConversationModel extends window.Backbone.Model< // eslint-disable-next-line class-methods-use-this getContactCollection(): Backbone.Collection { const collection = new window.Backbone.Collection(); - const collator = new Intl.Collator(); + const collator = new Intl.Collator(undefined, { sensitivity: 'base' }); collection.comparator = ( left: ConversationModel, right: ConversationModel ) => { - const leftLower = left.getTitle().toLowerCase(); - const rightLower = right.getTitle().toLowerCase(); - return collator.compare(leftLower, rightLower); + return collator.compare(left.getTitle(), right.getTitle()); }; return collection; } @@ -5229,9 +5227,7 @@ const sortConversationTitles = ( right: SortableByTitle, collator: Intl.Collator ) => { - const leftLower = left.getTitle().toLowerCase(); - const rightLower = right.getTitle().toLowerCase(); - return collator.compare(leftLower, rightLower); + return collator.compare(left.getTitle(), right.getTitle()); }; // We need a custom collection here to get the sorting we need @@ -5239,7 +5235,7 @@ window.Whisper.GroupConversationCollection = window.Backbone.Collection.extend({ model: window.Whisper.GroupMemberConversation, initialize() { - this.collator = new Intl.Collator(); + this.collator = new Intl.Collator(undefined, { sensitivity: 'base' }); }, comparator(left: WhatIsThis, right: WhatIsThis) { diff --git a/ts/state/smart/ConversationDetails.tsx b/ts/state/smart/ConversationDetails.tsx index cfd632ad58..12d6012c0a 100644 --- a/ts/state/smart/ConversationDetails.tsx +++ b/ts/state/smart/ConversationDetails.tsx @@ -39,8 +39,7 @@ const mapStateToProps = ( conversation && conversation.canEditGroupInfo ? conversation.canEditGroupInfo : false; - const isAdmin = - conversation && conversation.areWeAdmin ? conversation.areWeAdmin : false; + const isAdmin = Boolean(conversation?.areWeAdmin); return { ...props, diff --git a/ts/state/smart/GroupLinkManagement.tsx b/ts/state/smart/GroupLinkManagement.tsx index d8d6e7ad0c..b32bd1cac8 100644 --- a/ts/state/smart/GroupLinkManagement.tsx +++ b/ts/state/smart/GroupLinkManagement.tsx @@ -26,11 +26,13 @@ const mapStateToProps = ( props: SmartGroupLinkManagementProps ): PropsType => { const conversation = getConversationSelector(state)(props.conversationId); + const isAdmin = Boolean(conversation?.areWeAdmin); return { ...props, conversation, i18n: getIntl(state), + isAdmin, }; }; diff --git a/ts/util/getAccessControlOptions.ts b/ts/util/getAccessControlOptions.ts index 97fe8cb31e..ac7c3d0686 100644 --- a/ts/util/getAccessControlOptions.ts +++ b/ts/util/getAccessControlOptions.ts @@ -19,7 +19,7 @@ export function getAccessControlOptions( value: accessEnum.MEMBER, }, { - name: i18n('GroupV2--admin'), + name: i18n('GroupV2--only-admins'), value: accessEnum.ADMINISTRATOR, }, ]; From 5447d0ad69277513b98682eeebbf4b7291eb0698 Mon Sep 17 00:00:00 2001 From: Jim Gustafson Date: Tue, 2 Mar 2021 08:28:01 -0800 Subject: [PATCH 024/181] Update to RingRTC v2.9.4 --- package.json | 2 +- yarn.lock | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 9f87c11ed4..a0691c6ea4 100644 --- a/package.json +++ b/package.json @@ -134,7 +134,7 @@ "redux-ts-utils": "3.2.2", "reselect": "4.0.0", "rimraf": "2.6.2", - "ringrtc": "https://github.com/signalapp/signal-ringrtc-node.git#ecc8dbdfe9b99030de5f0687db8afcc798771a8e", + "ringrtc": "https://github.com/signalapp/signal-ringrtc-node.git#b43c6b728d62b6d386d95705e128f32f44edb650", "sanitize-filename": "1.6.3", "sanitize.css": "11.0.0", "semver": "5.4.1", diff --git a/yarn.lock b/yarn.lock index 0bf7904af1..8c4fe4ad66 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14368,9 +14368,9 @@ rimraf@~2.4.0: dependencies: glob "^6.0.1" -"ringrtc@https://github.com/signalapp/signal-ringrtc-node.git#ecc8dbdfe9b99030de5f0687db8afcc798771a8e": - version "2.9.3" - resolved "https://github.com/signalapp/signal-ringrtc-node.git#ecc8dbdfe9b99030de5f0687db8afcc798771a8e" +"ringrtc@https://github.com/signalapp/signal-ringrtc-node.git#b43c6b728d62b6d386d95705e128f32f44edb650": + version "2.9.4" + resolved "https://github.com/signalapp/signal-ringrtc-node.git#b43c6b728d62b6d386d95705e128f32f44edb650" ripemd160@^2.0.0, ripemd160@^2.0.1: version "2.0.1" From a2a0d5627009f6417c1c33a0d1ef11233875b1b8 Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Tue, 2 Mar 2021 17:14:00 -0800 Subject: [PATCH 025/181] Ensure a theme setting value is saved on first startup --- ts/background.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ts/background.ts b/ts/background.ts index 91222b96df..a3c2f8a3eb 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -1900,7 +1900,10 @@ export async function startApp(): Promise { !hasThemeSetting && window.textsecure.storage.get('userAgent') === 'OWI' ) { - window.storage.put('theme-setting', 'ios'); + window.storage.put( + 'theme-setting', + await window.Events.getThemeSetting() + ); onChangeTheme(); } const syncRequest = new window.textsecure.SyncRequest( From 8c951602b7e5b00de0adb5c3da19d8a798222655 Mon Sep 17 00:00:00 2001 From: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com> Date: Tue, 2 Mar 2021 19:16:04 -0600 Subject: [PATCH 026/181] Add conversation header button hover states --- .../components/ConversationHeader.scss | 209 ++++++++---------- .../conversation/ConversationHeader.tsx | 42 ++-- 2 files changed, 113 insertions(+), 138 deletions(-) diff --git a/stylesheets/components/ConversationHeader.scss b/stylesheets/components/ConversationHeader.scss index 6bdfba60d1..bdb17adfe1 100644 --- a/stylesheets/components/ConversationHeader.scss +++ b/stylesheets/components/ConversationHeader.scss @@ -168,32 +168,22 @@ } } - &__calling-button--video, - &__calling-button--audio, - &__calling-button--join, - &__search-button, - &__more-button { + &__button { + $icon-size: 32px; + + @include button-reset; + align-items: stretch; + border-radius: 4px; + border: 2px solid transparent; + display: flex; + height: $icon-size; margin-right: var(--button-spacing); - transition: margin-right 200ms ease-out; - - @include keyboard-mode { - &:focus { - background: $ultramarine-ui-light; - } - } - @include dark-keyboard-mode { - &:focus { - background: $ultramarine-ui-dark; - } - } - } - - &__calling-button { - $icon-size: 24px; - - border: none; + min-width: $icon-size; opacity: 0; - transition: opacity 250ms ease-out; + padding: 2px; + transition: margin-right 200ms ease-out, opacity 200ms ease-out, + background 100ms ease-out; + width: $icon-size; &:disabled { cursor: default; @@ -203,43 +193,89 @@ opacity: 1; } + @include light-theme { + &:hover, + &:focus { + background: $color-gray-02; + } + &:active { + background: $color-gray-05; + } + } + @include dark-theme { + &:hover, + &:focus { + background: $color-gray-80; + } + &:active { + background: $color-gray-75; + } + } + + @include keyboard-mode { + &:focus { + border-color: $ultramarine-ui-light; + } + } + @include dark-keyboard-mode { + &:focus { + border-color: $ultramarine-ui-dark; + } + } + + @mixin normal-button($light-icon, $dark-icon) { + &::before { + content: ''; + display: block; + flex-grow: 1; + @include light-theme { + @include color-svg($light-icon, $color-gray-75); + &:hover, + &:active, + &:focus { + @include color-svg($light-icon, $color-gray-90); + } + } + @include dark-theme { + @include color-svg($dark-icon, $color-gray-15); + &:hover, + &:active, + &:focus { + @include color-svg($dark-icon, $color-gray-02); + } + } + } + } + &--video { - @include light-theme { - @include color-svg( - '../images/icons/v2/video-outline-24.svg', - $color-gray-75 - ); - } - @include dark-theme { - @include color-svg( - '../images/icons/v2/video-solid-24.svg', - $color-gray-15 - ); - } - height: $icon-size; - width: $icon-size; - min-width: $icon-size; + @include normal-button( + '../images/icons/v2/video-outline-24.svg', + '../images/icons/v2/video-solid-24.svg' + ); } &--audio { - @include light-theme { - @include color-svg( - '../images/icons/v2/phone-right-outline-24.svg', - $color-gray-75 - ); - } - @include dark-theme { - @include color-svg( - '../images/icons/v2/phone-right-solid-24.svg', - $color-gray-15 - ); - } - height: $icon-size; - width: $icon-size; - min-width: $icon-size; + @include normal-button( + '../images/icons/v2/phone-right-outline-24.svg', + '../images/icons/v2/phone-right-solid-24.svg' + ); } - &--join { + &--search { + @include normal-button( + '../images/icons/v2/search-24.svg', + '../images/icons/v2/search-24.svg' + ); + } + + &--more { + @include normal-button( + '../images/icons/v2/chevron-down-24.svg', + '../images/icons/v2/chevron-down-24.svg' + ); + } + + &--join-call { @include font-body-1; align-items: center; background-color: $color-accent-green; @@ -252,14 +288,11 @@ text-overflow: ellipsis; white-space: nowrap; user-select: none; - - @include keyboard-mode { - &:focus { - box-shadow: 0px 0px 0px 4px $ultramarine-ui-light; - } - } + width: auto; &:before { + $icon-size: 24px; + @include color-svg( '../images/icons/v2/video-solid-24.svg', $color-white @@ -273,58 +306,4 @@ } } } - - &__search-button { - height: 24px; - width: 24px; - min-width: 24px; - border: none; - opacity: 0; - transition: opacity 250ms ease-out; - - &:disabled { - cursor: default; - } - - &--show { - opacity: 1; - } - - @include light-theme { - @include color-svg('../images/icons/v2/search-24.svg', $color-gray-75); - } - @include dark-theme { - @include color-svg('../images/icons/v2/search-24.svg', $color-gray-15); - } - } - - &__more-button { - height: 24px; - width: 24px; - min-width: 24px; - border: none; - opacity: 0; - transition: opacity 250ms ease-out; - - &:disabled { - cursor: default; - } - - &--show { - opacity: 1; - } - - @include light-theme { - @include color-svg( - '../images/icons/v2/chevron-down-24.svg', - $color-gray-75 - ); - } - @include dark-theme { - @include color-svg( - '../images/icons/v2/chevron-down-24.svg', - $color-gray-15 - ); - } - } } diff --git a/ts/components/conversation/ConversationHeader.tsx b/ts/components/conversation/ConversationHeader.tsx index 28323aca23..10b11e53df 100644 --- a/ts/components/conversation/ConversationHeader.tsx +++ b/ts/components/conversation/ConversationHeader.tsx @@ -249,10 +249,9 @@ export class ConversationHeader extends React.Component { type="button" onClick={this.showMenuBound} className={classNames( - 'module-ConversationHeader__more-button', - showBackButton - ? null - : 'module-ConversationHeader__more-button--show' + 'module-ConversationHeader__button', + 'module-ConversationHeader__button--more', + showBackButton ? null : 'module-ConversationHeader__button--show' )} disabled={showBackButton} aria-label={i18n('moreInfo')} @@ -269,10 +268,9 @@ export class ConversationHeader extends React.Component { type="button" onClick={onSearchInConversation} className={classNames( - 'module-ConversationHeader__search-button', - showBackButton - ? null - : 'module-ConversationHeader__search-button--show' + 'module-ConversationHeader__button', + 'module-ConversationHeader__button--search', + showBackButton ? null : 'module-ConversationHeader__button--show' )} disabled={showBackButton} aria-label={i18n('search')} @@ -295,11 +293,9 @@ export class ConversationHeader extends React.Component { type="button" onClick={onOutgoingVideoCallInConversation} className={classNames( - 'module-ConversationHeader__calling-button', - 'module-ConversationHeader__calling-button--video', - showBackButton - ? null - : 'module-ConversationHeader__calling-button--show' + 'module-ConversationHeader__button', + 'module-ConversationHeader__button--video', + showBackButton ? null : 'module-ConversationHeader__button--show' )} disabled={showBackButton} aria-label={i18n('makeOutgoingVideoCall')} @@ -319,11 +315,11 @@ export class ConversationHeader extends React.Component { type="button" onClick={onOutgoingAudioCallInConversation} className={classNames( - 'module-ConversationHeader__calling-button', - 'module-ConversationHeader__calling-button--audio', + 'module-ConversationHeader__button', + 'module-ConversationHeader__button--audio', showBackButton ? null - : 'module-ConversationHeader__calling-button--show' + : 'module-ConversationHeader__button--show' )} disabled={showBackButton} aria-label={i18n('makeOutgoingCall')} @@ -337,11 +333,9 @@ export class ConversationHeader extends React.Component { type="button" onClick={onOutgoingVideoCallInConversation} className={classNames( - 'module-ConversationHeader__calling-button', - 'module-ConversationHeader__calling-button--join', - showBackButton - ? null - : 'module-ConversationHeader__calling-button--show' + 'module-ConversationHeader__button', + 'module-ConversationHeader__button--join-call', + showBackButton ? null : 'module-ConversationHeader__button--show' )} disabled={showBackButton} > @@ -576,8 +570,10 @@ export class ConversationHeader extends React.Component { { - const width = (bounds && bounds.width) || 0; - this.setState({ isNarrow: width < 500 }); + if (!bounds || !bounds.width) { + return; + } + this.setState({ isNarrow: bounds.width < 500 }); }} > {({ measureRef }) => ( From 99bcad0bd92a21e6ed6c0dd54620cc2443bb9102 Mon Sep 17 00:00:00 2001 From: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com> Date: Wed, 3 Mar 2021 11:23:00 -0600 Subject: [PATCH 027/181] Use missingCaseError in --- ts/components/CallingPipRemoteVideo.tsx | 70 ++++++++++++------------- 1 file changed, 34 insertions(+), 36 deletions(-) diff --git a/ts/components/CallingPipRemoteVideo.tsx b/ts/components/CallingPipRemoteVideo.tsx index cd1e819207..495954a2b9 100644 --- a/ts/components/CallingPipRemoteVideo.tsx +++ b/ts/components/CallingPipRemoteVideo.tsx @@ -18,6 +18,7 @@ import { import { SetRendererCanvasType } from '../state/ducks/calling'; import { useGetCallingFrameBuffer } from '../calling/useGetCallingFrameBuffer'; import { usePageVisibility } from '../util/hooks'; +import { missingCaseError } from '../util/missingCaseError'; import { nonRenderedRemoteParticipant } from '../util/ringrtc/nonRenderedRemoteParticipant'; // This value should be kept in sync with the hard-coded CSS height. @@ -131,42 +132,39 @@ export const CallingPipRemoteVideo = ({ setGroupCallVideoRequest, ]); - if (activeCall.callMode === CallMode.Direct) { - const { hasRemoteVideo } = activeCall.remoteParticipants[0]; - - if (!hasRemoteVideo) { - return ; + switch (activeCall.callMode) { + case CallMode.Direct: { + const { hasRemoteVideo } = activeCall.remoteParticipants[0]; + if (!hasRemoteVideo) { + return ; + } + return ( +
+ +
+ ); } - - return ( -
- -
- ); + case CallMode.Group: + if (!activeGroupCallSpeaker) { + return ; + } + return ( +
+ +
+ ); + default: + throw missingCaseError(activeCall); } - - if (activeCall.callMode === CallMode.Group) { - if (!activeGroupCallSpeaker) { - return ; - } - - return ( -
- -
- ); - } - - throw new Error('CallingRemoteVideo: Unknown Call Mode'); }; From c4dc3f3bcb99be17766b1c6af0b6d6265916067c Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Wed, 3 Mar 2021 11:02:00 -0800 Subject: [PATCH 028/181] Make mention blot non-contenteditable --- ts/quill/mentions/blot.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ts/quill/mentions/blot.tsx b/ts/quill/mentions/blot.tsx index fffda12c5f..d389f6ea8e 100644 --- a/ts/quill/mentions/blot.tsx +++ b/ts/quill/mentions/blot.tsx @@ -67,6 +67,6 @@ export class MentionBlot extends Embed { constructor(node: Node) { super(node); - this.contentNode.removeAttribute('contenteditable'); + this.contentNode.setAttribute('contenteditable', 'false'); } } From 18fb2b806e91c770c320742fc94ad0002f9e8718 Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Wed, 3 Mar 2021 11:03:11 -0800 Subject: [PATCH 029/181] Remove notification on reaction remove/change --- js/notifications.js | 43 ++++++++++++++++++++++++++++++++++--------- ts/models/messages.ts | 26 ++++++++++++++++++++++++-- 2 files changed, 58 insertions(+), 11 deletions(-) diff --git a/js/notifications.js b/js/notifications.js index 0b2796925b..13685bb594 100644 --- a/js/notifications.js +++ b/js/notifications.js @@ -47,6 +47,7 @@ // isExpiringMessage: boolean; // reaction: { // emoji: string; + // fromId: string; // }; // } notificationData: null, @@ -56,16 +57,40 @@ this.update(); }, - removeBy({ conversationId, messageId }) { - const shouldClear = - Boolean(this.notificationData) && - ((conversationId && - this.notificationData.conversationId === conversationId) || - (messageId && this.notificationData.messageId === messageId)); - if (shouldClear) { - this.clear(); - this.update(); + // Remove the last notification if both conditions hold: + // + // 1. Either `conversationId` or `messageId` matches (if present) + // 2. `reactionFromId` matches (if present) + removeBy({ conversationId, messageId, reactionFromId }) { + if (!this.notificationData) { + return; } + + let shouldClear = false; + if ( + conversationId && + this.notificationData.conversationId === conversationId + ) { + shouldClear = true; + } + if (messageId && this.notificationData.messageId === messageId) { + shouldClear = true; + } + + if (!shouldClear) { + return; + } + + if ( + reactionFromId && + this.notificationData.reaction && + this.notificationData.reaction.fromId !== reactionFromId + ) { + return; + } + + this.clear(); + this.update(); }, fastUpdate() { diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 8f340a1f41..a7aa2e13b0 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -3642,6 +3642,12 @@ export class MessageModel extends window.Backbone.Model { const messageId = this.idForLogging(); const count = reactions.length; + const conversation = window.ConversationController.get( + this.get('conversationId') + ); + + let staleReactionFromId: string | undefined; + if (reaction.get('remove')) { window.log.info('Removing reaction for message', messageId); const newReactions = reactions.filter( @@ -3650,6 +3656,8 @@ export class MessageModel extends window.Backbone.Model { re.fromId !== reaction.get('fromId') ); this.set({ reactions: newReactions }); + + staleReactionFromId = reaction.get('fromId'); } else { window.log.info('Adding reaction for message', messageId); const newReactions = reactions.filter( @@ -3658,9 +3666,12 @@ export class MessageModel extends window.Backbone.Model { newReactions.push(reaction.toJSON()); this.set({ reactions: newReactions }); - const conversation = window.ConversationController.get( - this.get('conversationId') + const oldReaction = reactions.find( + re => re.fromId === reaction.get('fromId') ); + if (oldReaction) { + staleReactionFromId = oldReaction.fromId; + } // Only notify for reactions to our own messages if (conversation && this.isOutgoing() && !reaction.get('fromSync')) { @@ -3668,6 +3679,10 @@ export class MessageModel extends window.Backbone.Model { } } + if (staleReactionFromId) { + this.clearNotifications(reaction.get('fromId')); + } + const newCount = this.get('reactions').length; window.log.info( `Done processing reaction for message ${messageId}. Went from ${count} to ${newCount} reactions.` @@ -3704,6 +3719,13 @@ export class MessageModel extends window.Backbone.Model { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion this.getConversation()!.updateLastMessage(); } + + clearNotifications(reactionFromId?: string): void { + window.Whisper.Notifications.removeBy({ + messageId: this.id, + reactionFromId, + }); + } } window.Whisper.Message = MessageModel as typeof window.WhatIsThis; From 1934120e463a9ffc9574ec904d25dee5a30ae1ef Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Wed, 3 Mar 2021 11:13:35 -0800 Subject: [PATCH 030/181] Disable Dev Tools in production releases `--enable-dev-tools` will bring it back. --- app/menu.js | 19 ++++++++++++------- main.js | 15 +++++++++++++++ test/app/menu_test.js | 1 + 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/app/menu.js b/app/menu.js index 23979d5e39..10e2a71372 100644 --- a/app/menu.js +++ b/app/menu.js @@ -10,6 +10,7 @@ exports.createTemplate = (options, messages) => { const { isBeta, + devTools, includeSetup, openContactUs, openForums, @@ -118,13 +119,17 @@ exports.createTemplate = (options, messages) => { label: messages.debugLog.message, click: showDebugLog, }, - { - type: 'separator', - }, - { - role: 'toggledevtools', - label: messages.viewMenuToggleDevTools.message, - }, + ...(devTools + ? [ + { + type: 'separator', + }, + { + role: 'toggledevtools', + label: messages.viewMenuToggleDevTools.message, + }, + ] + : []), ], }, { diff --git a/main.js b/main.js index ecafd86190..7126fec468 100644 --- a/main.js +++ b/main.js @@ -112,9 +112,16 @@ const { getTitleBarVisibility, TitleBarVisibility, } = require('./ts/types/Settings'); +const { Environment } = require('./ts/environment'); let appStartInitialSpellcheckSetting = true; +const defaultWebPrefs = { + devTools: + process.argv.some(arg => arg === '--enable-dev-tools') || + config.environment !== Environment.Production, +}; + async function getSpellCheckSetting() { const json = await sql.getItemById('spell-check'); @@ -313,6 +320,7 @@ async function createWindow() { ? '#ffffff' // Tests should always be rendered on a white background : '#3a76f0', webPreferences: { + ...defaultWebPrefs, nodeIntegration: false, nodeIntegrationInWorker: false, contextIsolation: false, @@ -667,6 +675,7 @@ function showAbout() { backgroundColor: '#3a76f0', show: false, webPreferences: { + ...defaultWebPrefs, nodeIntegration: false, nodeIntegrationInWorker: false, contextIsolation: false, @@ -721,6 +730,7 @@ function showSettingsWindow() { show: false, modal: true, webPreferences: { + ...defaultWebPrefs, nodeIntegration: false, nodeIntegrationInWorker: false, contextIsolation: false, @@ -790,6 +800,7 @@ async function showStickerCreator() { backgroundColor: '#3a76f0', show: false, webPreferences: { + ...defaultWebPrefs, nodeIntegration: false, nodeIntegrationInWorker: false, contextIsolation: false, @@ -844,6 +855,7 @@ async function showDebugLogWindow() { show: false, modal: true, webPreferences: { + ...defaultWebPrefs, nodeIntegration: false, nodeIntegrationInWorker: false, contextIsolation: false, @@ -894,6 +906,7 @@ function showPermissionsPopupWindow(forCalling, forCamera) { show: false, modal: true, webPreferences: { + ...defaultWebPrefs, nodeIntegration: false, nodeIntegrationInWorker: false, contextIsolation: false, @@ -1027,6 +1040,7 @@ app.on('ready', async () => { frame: false, backgroundColor: '#3a76f0', webPreferences: { + ...defaultWebPrefs, nodeIntegration: false, preload: path.join(__dirname, 'loading_preload.js'), }, @@ -1134,6 +1148,7 @@ function setupMenu(options) { showAbout, showSettings: showSettingsWindow, showStickerCreator, + showDevTools: defaultWebPrefs.devTools, openContactUs, openJoinTheBeta, openReleaseNotes, diff --git a/test/app/menu_test.js b/test/app/menu_test.js index a9aa783193..4e3feb7a32 100644 --- a/test/app/menu_test.js +++ b/test/app/menu_test.js @@ -50,6 +50,7 @@ describe('SignalMenu', () => { }; const options = { isBeta: false, + devTools: true, openContactUs: null, openForums: null, openJoinTheBeta: null, From 5de4babc0d8826646885b1f04c8f0b2efe69ab3b Mon Sep 17 00:00:00 2001 From: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com> Date: Wed, 3 Mar 2021 14:09:58 -0600 Subject: [PATCH 031/181] Support for creating New Groups --- _locales/en/messages.json | 158 +++ images/icons/v2/camera-outline-24.svg | 1 + stylesheets/_modules.scss | 311 ++--- stylesheets/components/Alert.scss | 39 + stylesheets/components/AvatarInput.scss | 77 ++ stylesheets/components/ContactPill.scss | 72 ++ stylesheets/components/ContactPills.scss | 20 + stylesheets/components/GroupDialog.scss | 121 ++ stylesheets/manifest.scss | 5 + ts/RemoteConfig.ts | 2 +- ts/components/Alert.tsx | 32 + ts/components/AvatarInput.stories.tsx | 69 ++ ts/components/AvatarInput.tsx | 213 ++++ ts/components/ContactPill.tsx | 74 ++ ts/components/ContactPills.stories.tsx | 87 ++ ts/components/ContactPills.tsx | 38 + ts/components/ConversationList.stories.tsx | 62 + ts/components/ConversationList.tsx | 65 +- ts/components/GroupDialog.tsx | 120 ++ ts/components/GroupV1MigrationDialog.tsx | 191 +-- ts/components/LeftPane.stories.tsx | 13 + ts/components/LeftPane.tsx | 112 +- ...atedGroupInvitedContactsDialog.stories.tsx | 54 + ...NewlyCreatedGroupInvitedContactsDialog.tsx | 80 ++ ts/components/conversation/ContactName.tsx | 20 +- .../conversation/GroupV1Migration.tsx | 25 +- .../conversation/Timeline.stories.tsx | 26 +- ts/components/conversation/Timeline.tsx | 121 +- .../BaseConversationListItem.tsx | 107 +- .../conversationList/ContactCheckbox.tsx | 97 ++ .../conversationList/ContactListItem.tsx | 8 +- .../conversationList/CreateNewGroupButton.tsx | 32 + .../LeftPaneChooseGroupMembersHelper.tsx | 304 +++++ .../leftPane/LeftPaneComposeHelper.tsx | 87 +- ts/components/leftPane/LeftPaneHelper.tsx | 20 + .../LeftPaneSetGroupMetadataHelper.tsx | 218 ++++ ts/groups.ts | 376 +++++- ts/groups/limits.ts | 29 + ts/models/conversations.ts | 3 + ts/services/storage.ts | 17 +- ts/state/ducks/conversations.ts | 554 ++++++++- ts/state/selectors/conversations.ts | 174 ++- ts/state/smart/LeftPane.tsx | 91 +- ts/state/smart/Timeline.tsx | 4 + ts/storage/isFeatureEnabled.ts | 12 + ts/test-both/groups/limits_test.ts | 67 + .../state/selectors/conversations_test.ts | 531 +++++++- ts/test-both/util/parseIntOrThrow_test.ts | 71 ++ .../util/parseIntWithFallback_test.ts | 71 ++ .../state/ducks/conversations_test.ts | 1076 ++++++++++++++++- .../LeftPaneChooseGroupMembersHelper_test.ts | 196 +++ .../leftPane/LeftPaneComposeHelper_test.ts | 230 +++- .../LeftPaneSetGroupMetadataHelper_test.ts | 85 ++ ts/util/lint/exceptions.json | 44 +- ts/util/parseIntOrThrow.ts | 24 + ts/util/parseIntWithFallback.ts | 12 + 56 files changed, 6222 insertions(+), 526 deletions(-) create mode 100644 images/icons/v2/camera-outline-24.svg create mode 100644 stylesheets/components/Alert.scss create mode 100644 stylesheets/components/AvatarInput.scss create mode 100644 stylesheets/components/ContactPill.scss create mode 100644 stylesheets/components/ContactPills.scss create mode 100644 stylesheets/components/GroupDialog.scss create mode 100644 ts/components/Alert.tsx create mode 100644 ts/components/AvatarInput.stories.tsx create mode 100644 ts/components/AvatarInput.tsx create mode 100644 ts/components/ContactPill.tsx create mode 100644 ts/components/ContactPills.stories.tsx create mode 100644 ts/components/ContactPills.tsx create mode 100644 ts/components/GroupDialog.tsx create mode 100644 ts/components/NewlyCreatedGroupInvitedContactsDialog.stories.tsx create mode 100644 ts/components/NewlyCreatedGroupInvitedContactsDialog.tsx create mode 100644 ts/components/conversationList/ContactCheckbox.tsx create mode 100644 ts/components/conversationList/CreateNewGroupButton.tsx create mode 100644 ts/components/leftPane/LeftPaneChooseGroupMembersHelper.tsx create mode 100644 ts/components/leftPane/LeftPaneSetGroupMetadataHelper.tsx create mode 100644 ts/groups/limits.ts create mode 100644 ts/storage/isFeatureEnabled.ts create mode 100644 ts/test-both/groups/limits_test.ts create mode 100644 ts/test-both/util/parseIntOrThrow_test.ts create mode 100644 ts/test-both/util/parseIntWithFallback_test.ts create mode 100644 ts/test-node/components/leftPane/LeftPaneChooseGroupMembersHelper_test.ts create mode 100644 ts/test-node/components/leftPane/LeftPaneSetGroupMetadataHelper_test.ts create mode 100644 ts/util/parseIntOrThrow.ts create mode 100644 ts/util/parseIntWithFallback.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index e5b9432d7f..7783dfb361 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1905,6 +1905,88 @@ "message": "No contacts found", "description": "Label shown when there are no contacts to compose to" }, + "chooseGroupMembers__title": { + "message": "Choose members", + "description": "The title for the 'choose group members' left pane screen" + }, + "chooseGroupMembers__back-button": { + "message": "Back", + "description": "Used as alt-text of the back button on the 'choose group members' left pane screen" + }, + "chooseGroupMembers__skip": { + "message": "Skip", + "description": "The 'skip' button text in the 'choose group members' left pane screen" + }, + "chooseGroupMembers__next": { + "message": "Next", + "description": "The 'next' button text in the 'choose group members' left pane screen" + }, + "chooseGroupMembers__maximum-group-size__title": { + "message": "Maximum group size reached", + "description": "Shown in the alert when you add the maximum number of group members" + }, + "chooseGroupMembers__maximum-group-size__body": { + "message": "Signal groups can have a maximum of $max$ members.", + "description": "Shown in the alert when you add the maximum number of group members", + "placeholders": { + "max": { + "content": "$1", + "example": "1000" + } + } + }, + "chooseGroupMembers__maximum-recommended-group-size__title": { + "message": "Recommended member limit reached", + "description": "Shown in the alert when you add the maximum recommended number of group members" + }, + "chooseGroupMembers__maximum-recommended-group-size__body": { + "message": "Signal groups perform best with $max$ members or less. Adding more members will cause delays sending and receiving messages.", + "description": "Shown in the alert when you add the maximum recommended number of group members", + "placeholders": { + "max": { + "content": "$1", + "example": "150" + } + } + }, + "chooseGroupMembers__cant-add-member__title": { + "message": "Can’t add member", + "description": "Shown in the alert when you try to add someone who can't be added to a group" + }, + "chooseGroupMembers__cant-add-member__body": { + "message": "“$name$” can’t be added to the group because they’re using an old version of Signal. You can add them to the group after they’ve updated Signal.", + "description": "Shown in the alert when you try to add someone who can't be added to a group", + "placeholders": { + "max": { + "content": "$1", + "example": "Jane Doe" + } + } + }, + "setGroupMetadata__title": { + "message": "Name this group", + "description": "The title for the 'set group metadata' left pane screen" + }, + "setGroupMetadata__back-button": { + "message": "Back to member selection", + "description": "Used as alt-text of the back button on the 'set group metadata' left pane screen" + }, + "setGroupMetadata__group-name-placeholder": { + "message": "Group name (required)", + "description": "The placeholder for the group name placeholder" + }, + "setGroupMetadata__create-group": { + "message": "Create", + "description": "The 'create group' button text in the 'set group metadata' left pane screen" + }, + "setGroupMetadata__members-header": { + "message": "Members", + "description": "The header for the members list in the 'set group metadata' left pane screen" + }, + "setGroupMetadata__error-message": { + "message": "This group couldn’t be created. Check your connection and try again.", + "description": "Shown in the modal when we can't create a group" + }, "notSupportedSMS": { "message": "SMS/MMS messages are not supported.", "description": "Label underneath number informing user that SMS is not supported on desktop" @@ -4876,5 +4958,81 @@ "PendingInvites--info": { "message": "Details about people invited to this group aren’t shown until they join. Invitees will only see messages after they join the group.", "description": "Information shown below the invite list" + }, + "AvatarInput--no-photo-label--group": { + "message": "Add a group photo", + "description": "The label for the avatar uploader when no group photo is selected" + }, + "AvatarInput--change-photo-label": { + "message": "Change photo", + "description": "The label for the avatar uploader when a photo is selected" + }, + "AvatarInput--upload-photo-choice": { + "message": "Upload photo", + "description": "The button text when you click on an uploaded avatar and want to upload a new one" + }, + "AvatarInput--remove-photo-choice": { + "message": "Remove photo", + "description": "The button text when you click on an uploaded avatar and want to remove it" + }, + "ContactPill--remove": { + "message": "Remove contact", + "description": "The label for the 'remove' button on the contact pill" + }, + "ComposeErrorDialog--close": { + "message": "Okay", + "description": "The text on the button when there's an error in the composer" + }, + "NewlyCreatedGroupInvitedContactsDialog--title--one": { + "message": "Invitation sent", + "description": "When creating a new group and inviting users, this is shown in the dialog" + }, + "NewlyCreatedGroupInvitedContactsDialog--title--many": { + "message": "$count$ invitations sent", + "description": "When creating a new group and inviting users, this is shown in the dialog", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "NewlyCreatedGroupInvitedContactsDialog--body--user-paragraph--one": { + "message": "$name$ can’t be automatically added to this group by you.", + "description": "When creating a new group and inviting users, this is shown in the dialog", + "placeholders": { + "name": { + "content": "$1", + "example": "Jane Doe" + } + } + }, + "NewlyCreatedGroupInvitedContactsDialog--body--user-paragraph--many": { + "message": "These users can’t be automatically added to this group by you.", + "description": "When creating a new group and inviting users, this is shown in the dialog" + }, + "NewlyCreatedGroupInvitedContactsDialog--body--info-paragraph": { + "message": "They’ve been invited to join, and won’t see any group messages until they accept.", + "description": "When creating a new group and inviting users, this is shown in the dialog" + }, + "NewlyCreatedGroupInvitedContactsDialog--body--learn-more": { + "message": "Learn more", + "description": "When creating a new group and inviting users, this is shown in the dialog" + }, + "createNewGroupButton": { + "message": "New group", + "description": "The text of the button to create new groups" + }, + "selectContact": { + "message": "Select contact", + "description": "The label for contact checkboxes that are non-selected (clicking them should select the contact)" + }, + "deselectContact": { + "message": "De-select contact", + "description": "The label for contact checkboxes that are selected (clicking them should de-select the contact)" + }, + "cannotSelectContact": { + "message": "Cannot select contact", + "description": "The label for contact checkboxes that are disabled" } } diff --git a/images/icons/v2/camera-outline-24.svg b/images/icons/v2/camera-outline-24.svg new file mode 100644 index 0000000000..329aaa8e09 --- /dev/null +++ b/images/icons/v2/camera-outline-24.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index e9098d8c35..b3d14b8bd8 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -3977,6 +3977,7 @@ button.module-conversation-details__action-button { } .module-avatar--28 { + min-width: 28px; height: 28px; width: 28px; @@ -4024,6 +4025,7 @@ button.module-conversation-details__action-button { .module-avatar--32 { height: 32px; width: 32px; + min-width: 32px; img { height: 32px; @@ -4069,6 +4071,7 @@ button.module-conversation-details__action-button { .module-avatar--52 { height: 52px; width: 52px; + min-width: 52px; img { height: 52px; @@ -4095,6 +4098,7 @@ button.module-conversation-details__action-button { .module-avatar--80 { height: 80px; width: 80px; + min-width: 80px; img { height: 80px; @@ -4121,6 +4125,7 @@ button.module-conversation-details__action-button { .module-avatar--96 { height: 96px; width: 96px; + min-width: 96px; img { height: 96px; @@ -4142,6 +4147,7 @@ button.module-conversation-details__action-button { .module-avatar--112 { height: 112px; width: 112px; + min-width: 112px; img { height: 112px; @@ -6854,21 +6860,49 @@ button.module-image__border-overlay:focus { &--contact-or-conversation { @include button-reset; - width: 100%; - + align-items: center; + cursor: inherit; display: flex; flex-direction: row; - padding-right: 16px; padding-left: 16px; - align-items: center; + padding-right: 16px; + user-select: none; + width: 100%; - &:hover, - &:focus { - @include light-theme { - background-color: $color-gray-05; + &--is-button { + cursor: pointer; + + &:disabled { + cursor: inherit; } - @include dark-theme { - background-color: $color-gray-75; + + &:hover:not(:disabled), + &:focus:not(:disabled) { + @include light-theme { + background-color: $color-gray-05; + } + @include dark-theme { + background-color: $color-gray-75; + } + } + } + + &--is-checkbox { + cursor: pointer; + + &--disabled { + cursor: not-allowed; + } + + $disabled-selector: '#{&}--disabled'; + &:hover:not(#{$disabled-selector}), + &:focus:not(#{$disabled-selector}) { + @include light-theme { + background-color: $color-gray-05; + } + @include dark-theme { + background-color: $color-gray-75; + } } } @@ -6931,12 +6965,14 @@ button.module-image__border-overlay:focus { &__content { flex-grow: 1; margin-left: 12px; - // parent - 52px (for avatar) - 12p (margin to avatar) - max-width: calc(100% - 64px); - display: flex; flex-direction: column; align-items: stretch; + overflow: hidden; + + &--disabled { + opacity: 0.5; + } &__header { display: flex; @@ -7153,6 +7189,68 @@ button.module-image__border-overlay:focus { } } } + + &__checkbox { + -webkit-appearance: none; + background: $color-white; + border-radius: 100%; + height: 20px; + margin-left: 16px; + margin-right: 16px; + width: 20px; + min-width: 20px; + pointer-events: none; + + @include light-theme { + border: 1px solid $color-gray-15; + } + @include dark-theme { + border: 1px solid $color-gray-80; + } + + &:focus { + outline: none; + } + + @include keyboard-mode { + &:focus { + border-width: 2px; + border-color: $ultramarine-ui-light; + &:checked { + box-shadow: inset 0 0 0px 1px $color-white; + } + } + } + @include dark-keyboard-mode { + &:focus { + border-width: 2px; + border-color: $ultramarine-ui-dark; + + &:checked { + box-shadow: inset 0 0 0px 1px $color-black; + } + } + } + + &:disabled { + opacity: 0.5; + } + + &:checked { + background: $ultramarine-ui-light; + display: flex; + align-items: center; + justify-content: center; + + &::before { + content: ''; + display: block; + @include color-svg('../images/icons/v2/check-24.svg', $color-white); + width: 13px; + height: 13px; + } + } + } } &--header { @@ -7191,6 +7289,7 @@ button.module-image__border-overlay:focus { width: $left-pane-width; height: 100%; + position: relative; } .module-left-pane__header { @@ -7215,6 +7314,10 @@ button.module-image__border-overlay:focus { width: 24px; height: 24px; + &:disabled { + cursor: not-allowed; + } + @include light-theme { @include color-svg( '../images/icons/v2/chevron-left-24.svg', @@ -7257,6 +7360,11 @@ button.module-image__border-overlay:focus { } } } + + &__form { + display: flex; + flex-direction: column; + } } .module-left-pane__archive-helper-text { @@ -7325,6 +7433,27 @@ button.module-image__border-overlay:focus { } } +.module-left-pane__compose-input { + margin: 16px; + @include font-body-1; + padding: 8px 12px; + border-radius: 6px; + border: 2px solid $color-gray-15; + background: $color-white; + color: $color-black; + + &:focus { + outline: none; + + @include light-theme { + border-color: $ultramarine-ui-light; + } + @include dark-theme { + border-color: $ultramarine-ui-dark; + } + } +} + .module-left-pane__list--measure { flex-grow: 1; flex-shrink: 1; @@ -7340,6 +7469,25 @@ button.module-image__border-overlay:focus { outline: none; } +.module-left-pane__footer { + bottom: 0; + display: flex; + flex-direction: row; + justify-content: flex-end; + left: 0; + padding: 12px; + position: absolute; + width: 100%; + + @include light-theme { + background: linear-gradient(transparent, $color-gray-02); + } + + @include dark-theme { + background: linear-gradient(transparent, $color-gray-80); + } +} + // Module: Timeline Loading Row .module-timeline-loading-row { @@ -10344,143 +10492,6 @@ button.module-image__border-overlay:focus { padding: 20px; } -// Module: GV1 Migration Dialog - -.module-group-v2-migration-dialog { - @include font-body-1; - border-radius: 8px; - width: 360px; - margin-left: auto; - margin-right: auto; - padding: 20px; - - max-height: 100%; - - display: flex; - flex-direction: column; - - position: relative; - - @include light-theme { - background-color: $color-white; - } - @include dark-theme { - background-color: $color-gray-95; - } -} -.module-group-v2-migration-dialog__close-button { - @include button-reset; - - position: absolute; - right: 12px; - top: 12px; - - height: 24px; - width: 24px; - - @include light-theme { - @include color-svg('../images/icons/v2/x-24.svg', $color-gray-75); - } - - @include dark-theme { - @include color-svg('../images/icons/v2/x-24.svg', $color-gray-15); - } - - &:focus { - @include keyboard-mode { - background-color: $ultramarine-ui-light; - } - @include dark-keyboard-mode { - background-color: $ultramarine-ui-dark; - } - } -} -.module-group-v2-migration-dialog__title { - @include font-title-2; - text-align: center; - margin-bottom: 20px; - - flex-grow: 0; - flex-shrink: 0; -} -.module-group-v2-migration-dialog__scrollable { - overflow-x: scroll; - flex-grow: 1; - flex-shrink: 1; -} -.module-group-v2-migration-dialog__item { - display: flex; - flex-direction: row; - align-items: start; - - &:not(:last-of-type) { - margin-bottom: 16px; - } -} -.module-group-v2-migration-dialog__item__bullet { - width: 4px; - height: 11px; - flex-grow: 0; - flex-shrink: 0; - - margin-top: 5px; - - @include light-theme { - background-color: $color-gray-15; - } - @include dark-theme { - background-color: $color-gray-65; - } -} -.module-group-v2-migration-dialog__item__content { - margin-left: 16px; -} -.module-group-v2-migration-dialog__member { - margin-top: 16px; -} -.module-group-v2-migration-dialog__member__name { - margin-left: 6px; -} - -.module-group-v2-migration-dialog__buttons { - margin-top: 16px; - - text-align: center; - flex-grow: 0; - flex-shrink: 0; - - display: flex; -} -.module-group-v2-migration-dialog__buttons--narrow { - margin-left: auto; - margin-right: auto; - width: 152px; -} -.module-group-v2-migration-dialog__button { - @include button-reset; - @include font-body-1-bold; - - // Start flex basis at zero so text width doesn't affect layout. We want the buttons - // evenly distributed. - flex: 1 1 0px; - - border-radius: 4px; - - padding: 8px; - padding-left: 30px; - padding-right: 30px; - - @include button-primary; - - &:not(:first-of-type) { - margin-left: 16px; - } -} - -.module-group-v2-migration-dialog__button--secondary { - @include button-secondary; -} - // Module: GroupV2 Join Dialog .module-group-v2-join-dialog { diff --git a/stylesheets/components/Alert.scss b/stylesheets/components/Alert.scss new file mode 100644 index 0000000000..ca7437bab5 --- /dev/null +++ b/stylesheets/components/Alert.scss @@ -0,0 +1,39 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.module-Alert { + @include popper-shadow(); + border-radius: 8px; + margin: 0 auto; + max-width: 360px; + padding: 16px; + width: 95%; + + @include light-theme() { + background: $color-white; + color: $color-gray-90; + } + + @include dark-theme() { + background: $color-gray-95; + color: $color-gray-05; + } + + &__title { + @include font-body-1-bold; + margin: 0; + padding: 0; + } + + &__body { + @include font-body-1; + margin: 0; + padding: 0; + } + + &__button-container { + display: flex; + justify-content: flex-end; + margin-top: 16px; + } +} diff --git a/stylesheets/components/AvatarInput.scss b/stylesheets/components/AvatarInput.scss new file mode 100644 index 0000000000..df781fcf0b --- /dev/null +++ b/stylesheets/components/AvatarInput.scss @@ -0,0 +1,77 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.module-AvatarInput { + @include button-reset; + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + background: none; + + &__avatar { + @include button-reset; + + margin-top: 4px; + display: flex; + border-radius: 100%; + height: 80px; + width: 80px; + transition: background-color 100ms ease-out; + + &--nothing { + align-items: stretch; + background: $color-white; + + &::before { + flex-grow: 1; + content: ''; + display: block; + @include color-svg( + '../images/icons/v2/camera-outline-24.svg', + $ultramarine-ui-light, + false + ); + -webkit-mask-size: 24px 24px; + } + } + + &--loading { + align-items: center; + background: $color-black; + } + + &--has-image { + background-size: cover; + background-position: center center; + } + } + + &__label { + @include button-reset; + @include font-body-1; + + padding-bottom: 4px; + padding-top: 4px; + + @include light-theme { + color: $ultramarine-ui-light; + } + + @include dark-theme { + color: $ultramarine-ui-dark; + } + } + + @include keyboard-mode { + &:focus { + .module-AvatarInput__avatar { + box-shadow: inset 0 0 0 2px $ultramarine-ui-light; + } + + .module-AvatarInput__label { + @include font-body-1-bold; + } + } + } +} diff --git a/stylesheets/components/ContactPill.scss b/stylesheets/components/ContactPill.scss new file mode 100644 index 0000000000..796ab2561f --- /dev/null +++ b/stylesheets/components/ContactPill.scss @@ -0,0 +1,72 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.module-ContactPill { + align-items: center; + border-radius: 9999px; // This ensures the borders are completely rounded. (A value like 100% would make it an ellipse.) + display: inline-flex; + user-select: none; + overflow: hidden; + + @include light-theme { + color: $color-gray-90; + background: $color-gray-05; + } + @include dark-theme { + color: $color-gray-02; + background: $color-gray-75; + } + + &__contact-name { + @include font-body-2; + padding: 0 6px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + + &__remove { + $icon: '../images/icons/v2/x-24.svg'; + + @include button-reset; + height: 100%; + display: flex; + width: 28px; + justify-content: center; + align-items: center; + padding: 0 6px 0 4px; + + &::before { + content: ''; + width: 12px; + height: 12px; + display: block; + + @include light-theme { + @include color-svg($icon, $color-gray-60); + } + @include dark-theme { + @include color-svg($icon, $color-gray-25); + } + } + + @include keyboard-mode { + &:focus { + background: $color-gray-15; + + &::before { + @include color-svg($icon, $ultramarine-ui-light); + } + } + } + @include dark-keyboard-mode { + &:focus { + background: $color-gray-65; + + &::before { + @include color-svg($icon, $ultramarine-ui-dark); + } + } + } + } +} diff --git a/stylesheets/components/ContactPills.scss b/stylesheets/components/ContactPills.scss new file mode 100644 index 0000000000..c0ccc04b4a --- /dev/null +++ b/stylesheets/components/ContactPills.scss @@ -0,0 +1,20 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.module-ContactPills { + display: flex; + flex-wrap: wrap; + margin-bottom: 10px; + max-height: 88px; + overflow-x: hidden; + overflow-y: scroll; + padding-left: 12px; + scroll-behavior: smooth; + + .module-ContactPill { + margin: 4px 6px; + max-width: calc( + 100% - 15px + ); // 6px for the right margin and 9px for the scrollbar + } +} diff --git a/stylesheets/components/GroupDialog.scss b/stylesheets/components/GroupDialog.scss new file mode 100644 index 0000000000..5ec114a9e6 --- /dev/null +++ b/stylesheets/components/GroupDialog.scss @@ -0,0 +1,121 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.module-GroupDialog { + @include popper-shadow(); + border-radius: 8px; + margin: 0 auto; + max-height: 100%; + max-width: 360px; + padding: 16px; + position: relative; + width: 95%; + display: flex; + flex-direction: column; + + @include light-theme() { + background: $color-white; + color: $color-gray-90; + } + + @include dark-theme() { + background: $color-gray-95; + color: $color-gray-05; + } + + &__close-button { + @include button-reset; + + position: absolute; + right: 12px; + top: 12px; + + height: 24px; + width: 24px; + + @include light-theme { + @include color-svg('../images/icons/v2/x-24.svg', $color-gray-75); + } + + @include dark-theme { + @include color-svg('../images/icons/v2/x-24.svg', $color-gray-15); + } + + &:focus { + @include keyboard-mode { + background-color: $ultramarine-ui-light; + } + @include dark-keyboard-mode { + background-color: $ultramarine-ui-dark; + } + } + } + + &__title { + @include font-title-2; + text-align: center; + margin-bottom: 20px; + + flex-grow: 0; + flex-shrink: 0; + } + + &__body { + overflow-x: scroll; + flex-grow: 1; + flex-shrink: 1; + } + + &__paragraph, + &__contacts { + margin: 0 0 16px 0; + padding: 0 16px 0 28px; + position: relative; + + &::before { + content: ''; + display: block; + height: 11px; + left: 4px; + position: absolute; + top: 4px; + width: 4px; + + @include light-theme { + background-color: $color-gray-15; + } + @include dark-theme { + background-color: $color-gray-65; + } + } + } + + &__contacts { + list-style-type: none; + + &__contact { + margin-top: 16px; + } + + &__contact__name { + margin-left: 8px; + } + } + + &__button-container { + display: flex; + justify-content: center; + margin-top: 16px; + flex-grow: 0; + flex-shrink: 0; + + .module-Button { + flex-grow: 1; + max-width: 152px; + + &:not(:first-child) { + margin-left: 16px; + } + } + } +} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 674be45d11..5d54d61ac2 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -27,5 +27,10 @@ @import 'options'; // New style: components +@import './components/Alert.scss'; +@import './components/AvatarInput.scss'; @import './components/Button.scss'; +@import './components/ContactPill.scss'; +@import './components/ContactPills.scss'; +@import './components/GroupDialog.scss'; @import './components/ConversationHeader.scss'; diff --git a/ts/RemoteConfig.ts b/ts/RemoteConfig.ts index a3132c6d3c..1fc29b3beb 100644 --- a/ts/RemoteConfig.ts +++ b/ts/RemoteConfig.ts @@ -4,7 +4,7 @@ import { get, throttle } from 'lodash'; import { WebAPIType } from './textsecure/WebAPI'; -type ConfigKeyType = +export type ConfigKeyType = | 'desktop.cds' | 'desktop.clientExpiration' | 'desktop.disableGV1' diff --git a/ts/components/Alert.tsx b/ts/components/Alert.tsx new file mode 100644 index 0000000000..4c3c45731e --- /dev/null +++ b/ts/components/Alert.tsx @@ -0,0 +1,32 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { FunctionComponent } from 'react'; + +import { LocalizerType } from '../types/Util'; +import { Button } from './Button'; +import { ModalHost } from './ModalHost'; + +type PropsType = { + title?: string; + body: string; + i18n: LocalizerType; + onClose: () => void; +}; + +export const Alert: FunctionComponent = ({ + body, + i18n, + onClose, + title, +}) => ( + +
+ {title &&

{title}

} +

{body}

+
+ +
+
+
+); diff --git a/ts/components/AvatarInput.stories.tsx b/ts/components/AvatarInput.stories.tsx new file mode 100644 index 0000000000..0087dc871e --- /dev/null +++ b/ts/components/AvatarInput.stories.tsx @@ -0,0 +1,69 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { useState, useEffect } from 'react'; +import { v4 as uuid } from 'uuid'; +import { chunk, noop } from 'lodash'; + +import { storiesOf } from '@storybook/react'; +import { setup as setupI18n } from '../../js/modules/i18n'; +import enMessages from '../../_locales/en/messages.json'; + +import { AvatarInput } from './AvatarInput'; + +const i18n = setupI18n('en', enMessages); + +const story = storiesOf('Components/AvatarInput', module); + +const TEST_IMAGE = new Uint8Array( + chunk( + '89504e470d0a1a0a0000000d4948445200000008000000080103000000fec12cc800000006504c5445ff00ff00ff000c82e9800000001849444154085b633061a8638863a867f8c720c760c12000001a4302f4d81dd9870000000049454e44ae426082', + 2 + ).map(bytePair => parseInt(bytePair.join(''), 16)) +).buffer; + +const Wrapper = ({ startValue }: { startValue: undefined | ArrayBuffer }) => { + const [value, setValue] = useState(startValue); + const [objectUrl, setObjectUrl] = useState(); + + useEffect(() => { + if (!value) { + setObjectUrl(undefined); + return noop; + } + const url = URL.createObjectURL(new Blob([value])); + setObjectUrl(url); + return () => { + URL.revokeObjectURL(url); + }; + }, [value]); + + return ( + <> +
+ +
+
+
Processed image (if it exists)
+ {objectUrl && } +
+ + ); +}; + +story.add('No start state', () => { + return ; +}); + +story.add('Starting with a value', () => { + return ; +}); diff --git a/ts/components/AvatarInput.tsx b/ts/components/AvatarInput.tsx new file mode 100644 index 0000000000..3de1399792 --- /dev/null +++ b/ts/components/AvatarInput.tsx @@ -0,0 +1,213 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { + useRef, + useState, + useEffect, + ChangeEventHandler, + MouseEventHandler, + FunctionComponent, +} from 'react'; +import { ContextMenu, MenuItem, ContextMenuTrigger } from 'react-contextmenu'; +import loadImage, { LoadImageOptions } from 'blueimp-load-image'; +import { noop } from 'lodash'; + +import { LocalizerType } from '../types/Util'; +import { Spinner } from './Spinner'; + +type PropsType = { + // This ID needs to be globally unique across the app. + contextMenuId: string; + disabled?: boolean; + i18n: LocalizerType; + onChange: (value: undefined | ArrayBuffer) => unknown; + value: undefined | ArrayBuffer; +}; + +enum ImageStatus { + Nothing = 'nothing', + Loading = 'loading', + HasImage = 'has-image', +} + +export const AvatarInput: FunctionComponent = ({ + contextMenuId, + disabled, + i18n, + onChange, + value, +}) => { + const fileInputRef = useRef(null); + // Comes from a third-party dependency + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const menuTriggerRef = useRef(null); + + const [objectUrl, setObjectUrl] = useState(); + useEffect(() => { + if (!value) { + setObjectUrl(undefined); + return noop; + } + const url = URL.createObjectURL(new Blob([value])); + setObjectUrl(url); + return () => { + URL.revokeObjectURL(url); + }; + }, [value]); + + const [processingFile, setProcessingFile] = useState( + undefined + ); + useEffect(() => { + if (!processingFile) { + return noop; + } + + let shouldCancel = false; + + (async () => { + let newValue: ArrayBuffer; + try { + newValue = await processFile(processingFile); + } catch (err) { + // Processing errors should be rare; if they do, we silently fail. In an ideal + // world, we may want to show a toast instead. + return; + } + if (shouldCancel) { + return; + } + setProcessingFile(undefined); + onChange(newValue); + })(); + + return () => { + shouldCancel = true; + }; + }, [processingFile, onChange]); + + const buttonLabel = value + ? i18n('AvatarInput--change-photo-label') + : i18n('AvatarInput--no-photo-label--group'); + + const startUpload = () => { + const fileInput = fileInputRef.current; + if (fileInput) { + fileInput.click(); + } + }; + + const clear = () => { + onChange(undefined); + }; + + const onClick: MouseEventHandler = value + ? event => { + const menuTrigger = menuTriggerRef.current; + if (!menuTrigger) { + return; + } + menuTrigger.handleContextClick(event); + } + : startUpload; + + const onInputChange: ChangeEventHandler = event => { + const file = event.target.files && event.target.files[0]; + if (file) { + setProcessingFile(file); + } + }; + + let imageStatus: ImageStatus; + if (processingFile || (value && !objectUrl)) { + imageStatus = ImageStatus.Loading; + } else if (objectUrl) { + imageStatus = ImageStatus.HasImage; + } else { + imageStatus = ImageStatus.Nothing; + } + + const isLoading = imageStatus === ImageStatus.Loading; + + return ( + <> + + + + + + {i18n('AvatarInput--upload-photo-choice')} + + + {i18n('AvatarInput--remove-photo-choice')} + + + + + ); +}; + +async function processFile(file: File): Promise { + const { image } = await loadImage(file, { + canvas: true, + cover: true, + crop: true, + imageSmoothingQuality: 'medium', + maxHeight: 512, + maxWidth: 512, + minHeight: 2, + minWidth: 2, + // `imageSmoothingQuality` is not present in `loadImage`'s types, but it is + // documented and supported. Updating DefinitelyTyped is the long-term solution + // here. + } as LoadImageOptions); + + // NOTE: The types for `loadImage` say this can never be a canvas, but it will be if + // `canvas: true`, at least in our case. Again, updating DefinitelyTyped should + // address this. + if (!(image instanceof HTMLCanvasElement)) { + throw new Error('Loaded image was not a canvas'); + } + + return (await canvasToBlob(image)).arrayBuffer(); +} + +function canvasToBlob(canvas: HTMLCanvasElement): Promise { + return new Promise((resolve, reject) => { + canvas.toBlob(blob => { + if (blob) { + resolve(blob); + } else { + reject(new Error("Couldn't convert the canvas to a Blob")); + } + }, 'image/webp'); + }); +} diff --git a/ts/components/ContactPill.tsx b/ts/components/ContactPill.tsx new file mode 100644 index 0000000000..beb53d9599 --- /dev/null +++ b/ts/components/ContactPill.tsx @@ -0,0 +1,74 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { FunctionComponent } from 'react'; + +import { ColorType } from '../types/Colors'; +import { LocalizerType } from '../types/Util'; +import { ContactName } from './conversation/ContactName'; +import { Avatar, AvatarSize } from './Avatar'; + +export type PropsType = { + avatarPath?: string; + color?: ColorType; + firstName?: string; + i18n: LocalizerType; + id: string; + isMe?: boolean; + name?: string; + onClickRemove: (id: string) => void; + phoneNumber?: string; + profileName?: string; + title: string; +}; + +export const ContactPill: FunctionComponent = ({ + avatarPath, + color, + firstName, + i18n, + id, + name, + phoneNumber, + profileName, + title, + onClickRemove, +}) => { + const removeLabel = i18n('ContactPill--remove'); + + return ( +
+ + +
+ ); +}; diff --git a/ts/components/ContactPills.stories.tsx b/ts/components/ContactPills.stories.tsx new file mode 100644 index 0000000000..c486ef8f03 --- /dev/null +++ b/ts/components/ContactPills.stories.tsx @@ -0,0 +1,87 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import { times } from 'lodash'; + +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; + +import { setup as setupI18n } from '../../js/modules/i18n'; +import enMessages from '../../_locales/en/messages.json'; +import { ContactPills } from './ContactPills'; +import { ContactPill, PropsType as ContactPillPropsType } from './ContactPill'; +import { gifUrl } from '../storybook/Fixtures'; + +const i18n = setupI18n('en', enMessages); + +const story = storiesOf('Components/Contact Pills', module); + +type ContactType = Omit; + +const contacts: Array = times(50, index => ({ + color: 'red', + id: `contact-${index}`, + isMe: false, + name: `Contact ${index}`, + phoneNumber: '(202) 555-0001', + profileName: `C${index}`, + title: `Contact ${index}`, +})); + +const contactPillProps = ( + overrideProps?: ContactType +): ContactPillPropsType => ({ + ...(overrideProps || { + avatarPath: gifUrl, + color: 'red', + firstName: 'John', + id: 'abc123', + isMe: false, + name: 'John Bon Bon Jovi', + phoneNumber: '(202) 555-0001', + profileName: 'JohnB', + title: 'John Bon Bon Jovi', + }), + i18n, + onClickRemove: action('onClickRemove'), +}); + +story.add('Empty list', () => ); + +story.add('One contact', () => ( + + + +)); + +story.add('Three contacts', () => ( + + + + + +)); + +story.add('Four contacts, one with a long name', () => ( + + + + + + +)); + +story.add('Fifty contacts', () => ( + + {contacts.map(contact => ( + + ))} + +)); diff --git a/ts/components/ContactPills.tsx b/ts/components/ContactPills.tsx new file mode 100644 index 0000000000..eda64626eb --- /dev/null +++ b/ts/components/ContactPills.tsx @@ -0,0 +1,38 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { + useRef, + useEffect, + Children, + FunctionComponent, + ReactNode, +} from 'react'; + +type PropsType = { + children?: ReactNode; +}; + +export const ContactPills: FunctionComponent = ({ children }) => { + const elRef = useRef(null); + + const childCount = Children.count(children); + const previousChildCountRef = useRef(childCount); + const previousChildCount = previousChildCountRef.current; + previousChildCountRef.current = childCount; + + useEffect(() => { + const hasAddedNewChild = childCount > previousChildCount; + const el = elRef.current; + if (!hasAddedNewChild || !el) { + return; + } + el.scrollTop = el.scrollHeight; + }, [childCount, previousChildCount]); + + return ( +
+ {children} +
+ ); +}; diff --git a/ts/components/ConversationList.stories.tsx b/ts/components/ConversationList.stories.tsx index 41de5daed1..6f6d99ec40 100644 --- a/ts/components/ConversationList.stories.tsx +++ b/ts/components/ConversationList.stories.tsx @@ -14,6 +14,7 @@ import { PropsData as ConversationListItemPropsType, MessageStatuses, } from './conversationList/ConversationListItem'; +import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox'; import { setup as setupI18n } from '../../js/modules/i18n'; import enMessages from '../../_locales/en/messages.json'; @@ -39,6 +40,15 @@ const defaultConversations: Array = [ title: 'Marc Barraca', type: 'direct', }, + { + id: 'long-name-convo', + isSelected: false, + lastUpdated: Date.now(), + markedUnread: false, + title: + 'Pablo Diego José Francisco de Paula Juan Nepomuceno María de los Remedios Cipriano de la Santísima Trinidad Ruiz y Picasso', + type: 'direct', + }, ]; const createProps = (rows: ReadonlyArray): PropsType => ({ @@ -52,6 +62,7 @@ const createProps = (rows: ReadonlyArray): PropsType => ({ i18n, onSelectConversation: action('onSelectConversation'), onClickArchiveButton: action('onClickArchiveButton'), + onClickContactCheckbox: action('onClickContactCheckbox'), renderMessageSearchResult: (id: string, style: React.CSSProperties) => ( ): PropsType => ({ to={defaultConversations[1]} /> ), + showChooseGroupMembers: action('showChooseGroupMembers'), startNewConversationFromPhoneNumber: action( 'startNewConversationFromPhoneNumber' ), @@ -144,6 +156,56 @@ story.add('Contact: group', () => ( /> )); +story.add('Contact checkboxes', () => ( + +)); + +story.add('Contact checkboxes: disabled', () => ( + +)); + { const createConversation = ( overrideProps: Partial = {} diff --git a/ts/components/ConversationList.tsx b/ts/components/ConversationList.tsx index 147257ef8f..1031864431 100644 --- a/ts/components/ConversationList.tsx +++ b/ts/components/ConversationList.tsx @@ -16,13 +16,21 @@ import { ContactListItem, PropsDataType as ContactListItemPropsType, } from './conversationList/ContactListItem'; +import { + ContactCheckbox as ContactCheckboxComponent, + ContactCheckboxDisabledReason, +} from './conversationList/ContactCheckbox'; +import { CreateNewGroupButton } from './conversationList/CreateNewGroupButton'; import { Spinner as SpinnerComponent } from './Spinner'; import { StartNewConversation as StartNewConversationComponent } from './conversationList/StartNewConversation'; export enum RowType { ArchiveButton, + Blank, Contact, + ContactCheckbox, Conversation, + CreateNewGroup, Header, MessageSearchResult, Spinner, @@ -34,9 +42,19 @@ type ArchiveButtonRowType = { archivedConversationsCount: number; }; +type BlankRowType = { type: RowType.Blank }; + type ContactRowType = { type: RowType.Contact; contact: ContactListItemPropsType; + isClickable?: boolean; +}; + +type ContactCheckboxRowType = { + type: RowType.ContactCheckbox; + contact: ContactListItemPropsType; + isChecked: boolean; + disabledReason?: ContactCheckboxDisabledReason; }; type ConversationRowType = { @@ -44,6 +62,10 @@ type ConversationRowType = { conversation: ConversationListItemPropsType; }; +type CreateNewGroupRowType = { + type: RowType.CreateNewGroup; +}; + type MessageRowType = { type: RowType.MessageSearchResult; messageId: string; @@ -63,8 +85,11 @@ type StartNewConversationRowType = { export type Row = | ArchiveButtonRowType + | BlankRowType | ContactRowType + | ContactCheckboxRowType | ConversationRowType + | CreateNewGroupRowType | MessageRowType | HeaderRowType | SpinnerRowType @@ -85,9 +110,14 @@ export type PropsType = { i18n: LocalizerType; - onSelectConversation: (conversationId: string, messageId?: string) => void; onClickArchiveButton: () => void; + onClickContactCheckbox: ( + conversationId: string, + disabledReason: undefined | ContactCheckboxDisabledReason + ) => void; + onSelectConversation: (conversationId: string, messageId?: string) => void; renderMessageSearchResult: (id: string, style: CSSProperties) => JSX.Element; + showChooseGroupMembers: () => void; startNewConversationFromPhoneNumber: (e164: string) => void; }; @@ -96,11 +126,13 @@ export const ConversationList: React.FC = ({ getRow, i18n, onClickArchiveButton, + onClickContactCheckbox, onSelectConversation, renderMessageSearchResult, rowCount, scrollToRowIndex, shouldRecomputeRowHeights, + showChooseGroupMembers, startNewConversationFromPhoneNumber, }) => { const listRef = useRef(null); @@ -148,13 +180,29 @@ export const ConversationList: React.FC = ({ ); - case RowType.Contact: + case RowType.Blank: + return
; + case RowType.Contact: { + const { isClickable = true } = row; return ( + ); + } + case RowType.ContactCheckbox: + return ( + ); @@ -168,6 +216,15 @@ export const ConversationList: React.FC = ({ i18n={i18n} /> ); + case RowType.CreateNewGroup: + return ( + + ); case RowType.Header: return (
= ({ getRow, i18n, onClickArchiveButton, + onClickContactCheckbox, onSelectConversation, renderMessageSearchResult, + showChooseGroupMembers, startNewConversationFromPhoneNumber, ] ); diff --git a/ts/components/GroupDialog.tsx b/ts/components/GroupDialog.tsx new file mode 100644 index 0000000000..f31e92adec --- /dev/null +++ b/ts/components/GroupDialog.tsx @@ -0,0 +1,120 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { ReactChild, ReactNode } from 'react'; + +import { LocalizerType } from '../types/Util'; +import { ConversationType } from '../state/ducks/conversations'; +import { ModalHost } from './ModalHost'; +import { Button, ButtonVariant } from './Button'; +import { Avatar, AvatarSize } from './Avatar'; +import { ContactName } from './conversation/ContactName'; + +type PropsType = { + children: ReactNode; + i18n: LocalizerType; + onClickPrimaryButton: () => void; + onClose: () => void; + primaryButtonText: string; + title: string; +} & ( + | // We use this empty type for an "all or nothing" setup. + // eslint-disable-next-line @typescript-eslint/ban-types + {} + | { + onClickSecondaryButton: () => void; + secondaryButtonText: string; + } +); + +export function GroupDialog(props: Readonly): JSX.Element { + const { + children, + i18n, + onClickPrimaryButton, + onClose, + primaryButtonText, + title, + } = props; + + let secondaryButton: undefined | ReactChild; + if ('secondaryButtonText' in props) { + const { onClickSecondaryButton, secondaryButtonText } = props; + secondaryButton = ( + + ); + } + + return ( + +
+ +
+
+ + ); +} + +type ParagraphPropsType = { + children: ReactNode; +}; + +GroupDialog.Paragraph = ({ + children, +}: Readonly): JSX.Element => ( +

{children}

+); + +type ContactsPropsType = { + contacts: Array; + i18n: LocalizerType; +}; + +GroupDialog.Contacts = ({ contacts, i18n }: Readonly) => ( +
    + {contacts.map(contact => ( +
  • + + +
  • + ))} +
+); + +function focusRef(el: HTMLElement | null) { + if (el) { + el.focus(); + } +} diff --git a/ts/components/GroupV1MigrationDialog.tsx b/ts/components/GroupV1MigrationDialog.tsx index 6fd3aed1da..68f43336fd 100644 --- a/ts/components/GroupV1MigrationDialog.tsx +++ b/ts/components/GroupV1MigrationDialog.tsx @@ -2,10 +2,9 @@ // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; -import classNames from 'classnames'; import { LocalizerType } from '../types/Util'; import { ConversationType } from '../state/ducks/conversations'; -import { Avatar } from './Avatar'; +import { GroupDialog } from './GroupDialog'; import { sortByTitle } from '../util/sortByTitle'; type CallbackType = () => unknown; @@ -25,61 +24,64 @@ export type HousekeepingPropsType = { export type PropsType = DataPropsType & HousekeepingPropsType; -function focusRef(el: HTMLElement | null) { - if (el) { - el.focus(); - } -} +export const GroupV1MigrationDialog: React.FunctionComponent = React.memo( + (props: PropsType) => { + const { + areWeInvited, + droppedMembers, + hasMigrated, + i18n, + invitedMembers, + migrate, + onClose, + } = props; -export const GroupV1MigrationDialog = React.memo((props: PropsType) => { - const { - areWeInvited, - droppedMembers, - hasMigrated, - i18n, - invitedMembers, - migrate, - onClose, - } = props; + const title = hasMigrated + ? i18n('GroupV1--Migration--info--title') + : i18n('GroupV1--Migration--migrate--title'); + const keepHistory = hasMigrated + ? i18n('GroupV1--Migration--info--keep-history') + : i18n('GroupV1--Migration--migrate--keep-history'); + const migrationKey = hasMigrated ? 'after' : 'before'; + const droppedMembersKey = `GroupV1--Migration--info--removed--${migrationKey}`; - const title = hasMigrated - ? i18n('GroupV1--Migration--info--title') - : i18n('GroupV1--Migration--migrate--title'); - const keepHistory = hasMigrated - ? i18n('GroupV1--Migration--info--keep-history') - : i18n('GroupV1--Migration--migrate--keep-history'); - const migrationKey = hasMigrated ? 'after' : 'before'; - const droppedMembersKey = `GroupV1--Migration--info--removed--${migrationKey}`; + let primaryButtonText: string; + let onClickPrimaryButton: () => void; + let secondaryButtonProps: + | undefined + | { + secondaryButtonText: string; + onClickSecondaryButton: () => void; + }; + if (hasMigrated) { + primaryButtonText = i18n('Confirmation--confirm'); + onClickPrimaryButton = onClose; + } else { + primaryButtonText = i18n('GroupV1--Migration--migrate'); + onClickPrimaryButton = migrate; + secondaryButtonProps = { + secondaryButtonText: i18n('cancel'), + onClickSecondaryButton: onClose, + }; + } - return ( -
- -
+ ); } - - return ( -
- - -
- ); -} +); function renderMembers( members: Array, prefix: string, i18n: LocalizerType -): React.ReactElement | null { +): React.ReactNode { if (!members.length) { return null; } @@ -159,27 +110,9 @@ function renderMembers( const key = `${prefix}${postfix}`; return ( -
-
-
-
{i18n(key)}
- {sortByTitle(members).map(member => ( -
- {' '} - - {member.title} - -
- ))} -
-
+ <> + {i18n(key)} + + ); } diff --git a/ts/components/LeftPane.stories.tsx b/ts/components/LeftPane.stories.tsx index 6c5a60996f..6ffb106780 100644 --- a/ts/components/LeftPane.stories.tsx +++ b/ts/components/LeftPane.stories.tsx @@ -77,6 +77,12 @@ const defaultModeSpecificProps = { const emptySearchResultsGroup = { isLoading: false, results: [] }; const createProps = (overrideProps: Partial = {}): PropsType => ({ + cantAddContactToGroup: action('cantAddContactToGroup'), + clearGroupCreationError: action('clearGroupCreationError'), + closeCantAddContactToGroupModal: action('closeCantAddContactToGroupModal'), + closeMaximumGroupSizeModal: action('closeMaximumGroupSizeModal'), + closeRecommendedGroupSizeModal: action('closeRecommendedGroupSizeModal'), + createGroup: action('createGroup'), i18n, modeSpecificProps: defaultModeSpecificProps, openConversationInternal: action('openConversationInternal'), @@ -102,12 +108,19 @@ const createProps = (overrideProps: Partial = {}): PropsType => ({ selectedConversationId: undefined, selectedMessageId: undefined, setComposeSearchTerm: action('setComposeSearchTerm'), + setComposeGroupAvatar: action('setComposeGroupAvatar'), + setComposeGroupName: action('setComposeGroupName'), showArchivedConversations: action('showArchivedConversations'), showInbox: action('showInbox'), startComposing: action('startComposing'), + showChooseGroupMembers: action('showChooseGroupMembers'), startNewConversationFromPhoneNumber: action( 'startNewConversationFromPhoneNumber' ), + startSettingGroupMetadata: action('startSettingGroupMetadata'), + toggleConversationInChooseMembers: action( + 'toggleConversationInChooseMembers' + ), ...overrideProps, }); diff --git a/ts/components/LeftPane.tsx b/ts/components/LeftPane.tsx index 5c3a319f3f..b23b4b27ca 100644 --- a/ts/components/LeftPane.tsx +++ b/ts/components/LeftPane.tsx @@ -26,18 +26,29 @@ import { LeftPaneComposeHelper, LeftPaneComposePropsType, } from './leftPane/LeftPaneComposeHelper'; +import { + LeftPaneChooseGroupMembersHelper, + LeftPaneChooseGroupMembersPropsType, +} from './leftPane/LeftPaneChooseGroupMembersHelper'; +import { + LeftPaneSetGroupMetadataHelper, + LeftPaneSetGroupMetadataPropsType, +} from './leftPane/LeftPaneSetGroupMetadataHelper'; import * as OS from '../OS'; import { LocalizerType } from '../types/Util'; import { missingCaseError } from '../util/missingCaseError'; import { ConversationList } from './ConversationList'; +import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox'; export enum LeftPaneMode { Inbox, Search, Archive, Compose, + ChooseGroupMembers, + SetGroupMetadata, } export type PropsType = { @@ -56,23 +67,40 @@ export type PropsType = { } & LeftPaneArchivePropsType) | ({ mode: LeftPaneMode.Compose; - } & LeftPaneComposePropsType); + } & LeftPaneComposePropsType) + | ({ + mode: LeftPaneMode.ChooseGroupMembers; + } & LeftPaneChooseGroupMembersPropsType) + | ({ + mode: LeftPaneMode.SetGroupMetadata; + } & LeftPaneSetGroupMetadataPropsType); i18n: LocalizerType; selectedConversationId: undefined | string; selectedMessageId: undefined | string; regionCode: string; // Action Creators + cantAddContactToGroup: (conversationId: string) => void; + clearGroupCreationError: () => void; + closeCantAddContactToGroupModal: () => void; + closeMaximumGroupSizeModal: () => void; + closeRecommendedGroupSizeModal: () => void; + createGroup: () => void; startNewConversationFromPhoneNumber: (e164: string) => void; openConversationInternal: (_: { conversationId: string; messageId?: string; switchToAssociatedView?: boolean; }) => void; + setComposeSearchTerm: (composeSearchTerm: string) => void; + setComposeGroupAvatar: (_: undefined | ArrayBuffer) => void; + setComposeGroupName: (_: string) => void; showArchivedConversations: () => void; showInbox: () => void; startComposing: () => void; - setComposeSearchTerm: (composeSearchTerm: string) => void; + showChooseGroupMembers: () => void; + startSettingGroupMetadata: () => void; + toggleConversationInChooseMembers: (conversationId: string) => void; // Render Props renderExpiredBuildDialog: () => JSX.Element; @@ -84,6 +112,12 @@ export type PropsType = { }; export const LeftPane: React.FC = ({ + cantAddContactToGroup, + clearGroupCreationError, + closeCantAddContactToGroupModal, + closeMaximumGroupSizeModal, + closeRecommendedGroupSizeModal, + createGroup, i18n, modeSpecificProps, openConversationInternal, @@ -96,10 +130,15 @@ export const LeftPane: React.FC = ({ selectedConversationId, selectedMessageId, setComposeSearchTerm, + setComposeGroupAvatar, + setComposeGroupName, showArchivedConversations, showInbox, startComposing, + showChooseGroupMembers, startNewConversationFromPhoneNumber, + startSettingGroupMetadata, + toggleConversationInChooseMembers, }) => { const previousModeSpecificPropsRef = useRef(modeSpecificProps); const previousModeSpecificProps = previousModeSpecificPropsRef.current; @@ -162,6 +201,32 @@ export const LeftPane: React.FC = ({ helper = composeHelper; break; } + case LeftPaneMode.ChooseGroupMembers: { + const chooseGroupMembersHelper = new LeftPaneChooseGroupMembersHelper( + modeSpecificProps + ); + shouldRecomputeRowHeights = + previousModeSpecificProps.mode === modeSpecificProps.mode + ? chooseGroupMembersHelper.shouldRecomputeRowHeights( + previousModeSpecificProps + ) + : true; + helper = chooseGroupMembersHelper; + break; + } + case LeftPaneMode.SetGroupMetadata: { + const setGroupMetadataHelper = new LeftPaneSetGroupMetadataHelper( + modeSpecificProps + ); + shouldRecomputeRowHeights = + previousModeSpecificProps.mode === modeSpecificProps.mode + ? setGroupMetadataHelper.shouldRecomputeRowHeights( + previousModeSpecificProps + ) + : true; + helper = setGroupMetadataHelper; + break; + } default: throw missingCaseError(modeSpecificProps); } @@ -245,11 +310,25 @@ export const LeftPane: React.FC = ({ ]); const preRowsNode = helper.getPreRowsNode({ + clearGroupCreationError, + closeCantAddContactToGroupModal, + closeMaximumGroupSizeModal, + closeRecommendedGroupSizeModal, + createGroup, i18n, + setComposeGroupAvatar, + setComposeGroupName, onChangeComposeSearchTerm: event => { setComposeSearchTerm(event.target.value); }, + removeSelectedContact: toggleConversationInChooseMembers, }); + const footerContents = helper.getFooterContents({ + createGroup, + i18n, + startSettingGroupMetadata, + }); + const getRow = useMemo(() => helper.getRow.bind(helper), [helper]); // We ensure that the listKey differs between some modes (e.g. inbox/archived), ensuring @@ -261,7 +340,12 @@ export const LeftPane: React.FC = ({ return (
- {helper.getHeaderContents({ i18n, showInbox }) || renderMainHeader()} + {helper.getHeaderContents({ + i18n, + showInbox, + startComposing, + showChooseGroupMembers, + }) || renderMainHeader()}
{renderExpiredBuildDialog()} {renderRelinkDialog()} @@ -288,6 +372,24 @@ export const LeftPane: React.FC = ({ getRow={getRow} i18n={i18n} onClickArchiveButton={showArchivedConversations} + onClickContactCheckbox={( + conversationId: string, + disabledReason: undefined | ContactCheckboxDisabledReason + ) => { + switch (disabledReason) { + case undefined: + toggleConversationInChooseMembers(conversationId); + break; + case ContactCheckboxDisabledReason.MaximumContactsSelected: + // This is a no-op. + break; + case ContactCheckboxDisabledReason.NotCapable: + cantAddContactToGroup(conversationId); + break; + default: + throw missingCaseError(disabledReason); + } + }} onSelectConversation={( conversationId: string, messageId?: string @@ -304,6 +406,7 @@ export const LeftPane: React.FC = ({ selectedConversationId )} shouldRecomputeRowHeights={shouldRecomputeRowHeights} + showChooseGroupMembers={showChooseGroupMembers} startNewConversationFromPhoneNumber={ startNewConversationFromPhoneNumber } @@ -313,6 +416,9 @@ export const LeftPane: React.FC = ({
)} + {footerContents && ( +
{footerContents}
+ )}
); }; diff --git a/ts/components/NewlyCreatedGroupInvitedContactsDialog.stories.tsx b/ts/components/NewlyCreatedGroupInvitedContactsDialog.stories.tsx new file mode 100644 index 0000000000..69815f8241 --- /dev/null +++ b/ts/components/NewlyCreatedGroupInvitedContactsDialog.stories.tsx @@ -0,0 +1,54 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; + +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; + +import { NewlyCreatedGroupInvitedContactsDialog } from './NewlyCreatedGroupInvitedContactsDialog'; +import { setup as setupI18n } from '../../js/modules/i18n'; +import enMessages from '../../_locales/en/messages.json'; +import { ConversationType } from '../state/ducks/conversations'; + +const i18n = setupI18n('en', enMessages); + +const conversations: Array = [ + { + id: 'fred-convo', + isSelected: false, + lastUpdated: Date.now(), + markedUnread: false, + title: 'Fred Willard', + type: 'direct', + }, + { + id: 'marc-convo', + isSelected: true, + lastUpdated: Date.now(), + markedUnread: false, + title: 'Marc Barraca', + type: 'direct', + }, +]; + +const story = storiesOf( + 'Components/NewlyCreatedGroupInvitedContactsDialog', + module +); + +story.add('One contact', () => ( + +)); + +story.add('Two contacts', () => ( + +)); diff --git a/ts/components/NewlyCreatedGroupInvitedContactsDialog.tsx b/ts/components/NewlyCreatedGroupInvitedContactsDialog.tsx new file mode 100644 index 0000000000..1a9800ae0a --- /dev/null +++ b/ts/components/NewlyCreatedGroupInvitedContactsDialog.tsx @@ -0,0 +1,80 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { FunctionComponent, ReactNode } from 'react'; + +import { LocalizerType } from '../types/Util'; +import { ConversationType } from '../state/ducks/conversations'; +import { Intl } from './Intl'; +import { ContactName } from './conversation/ContactName'; +import { GroupDialog } from './GroupDialog'; + +type PropsType = { + contacts: Array; + i18n: LocalizerType; + onClose: () => void; +}; + +export const NewlyCreatedGroupInvitedContactsDialog: FunctionComponent = ({ + contacts, + i18n, + onClose, +}) => { + let title: string; + let body: ReactNode; + if (contacts.length === 1) { + const contact = contacts[0]; + + title = i18n('NewlyCreatedGroupInvitedContactsDialog--title--one'); + body = ( + <> + + ]} + /> + + + {i18n('NewlyCreatedGroupInvitedContactsDialog--body--info-paragraph')} + + + ); + } else { + title = i18n('NewlyCreatedGroupInvitedContactsDialog--title--many', [ + contacts.length.toString(), + ]); + body = ( + <> + + {i18n( + 'NewlyCreatedGroupInvitedContactsDialog--body--user-paragraph--many' + )} + + + {i18n('NewlyCreatedGroupInvitedContactsDialog--body--info-paragraph')} + + + + ); + } + + return ( + { + window.location.href = + 'https://support.signal.org/hc/articles/360007319331-Group-chats'; + }} + onClose={onClose} + title={title} + > + {body} + + ); +}; diff --git a/ts/components/conversation/ContactName.tsx b/ts/components/conversation/ContactName.tsx index 5ae4b80c0f..e4b132769f 100644 --- a/ts/components/conversation/ContactName.tsx +++ b/ts/components/conversation/ContactName.tsx @@ -7,20 +7,34 @@ import { LocalizerType } from '../../types/Util'; import { Emojify } from './Emojify'; export type PropsType = { + firstName?: string; i18n: LocalizerType; - title: string; module?: string; name?: string; phoneNumber?: string; + preferFirstName?: boolean; profileName?: string; + title: string; }; -export const ContactName = ({ module, title }: PropsType): JSX.Element => { +export const ContactName = ({ + firstName, + module, + preferFirstName, + title, +}: PropsType): JSX.Element => { const prefix = module || 'module-contact-name'; + let text: string; + if (preferFirstName) { + text = firstName || title || ''; + } else { + text = title || ''; + } + return ( - + ); }; diff --git a/ts/components/conversation/GroupV1Migration.tsx b/ts/components/conversation/GroupV1Migration.tsx index 3bd068253f..08c0c4ddd2 100644 --- a/ts/components/conversation/GroupV1Migration.tsx +++ b/ts/components/conversation/GroupV1Migration.tsx @@ -7,7 +7,6 @@ import { LocalizerType } from '../../types/Util'; import { ConversationType } from '../../state/ducks/conversations'; import { Intl } from '../Intl'; import { ContactName } from './ContactName'; -import { ModalHost } from '../ModalHost'; import { GroupV1MigrationDialog } from '../GroupV1MigrationDialog'; export type PropsDataType = { @@ -58,19 +57,17 @@ export function GroupV1Migration(props: PropsType): React.ReactElement { {i18n('GroupV1--Migration--learn-more')} {showingDialog ? ( - - - window.log.warn('GroupV1Migration: Modal called migrate()') - } - onClose={dismissDialog} - /> - + + window.log.warn('GroupV1Migration: Modal called migrate()') + } + onClose={dismissDialog} + /> ) : null}
); diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index f0eb3fa827..9f6344bc01 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -1,4 +1,4 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; @@ -211,6 +211,9 @@ const items: Record = { const actions = () => ({ clearChangedMessages: action('clearChangedMessages'), + clearInvitedConversationsForNewlyCreatedGroup: action( + 'clearInvitedConversationsForNewlyCreatedGroup' + ), setLoadCountdownStart: action('setLoadCountdownStart'), setIsNearBottom: action('setIsNearBottom'), loadAndScroll: action('loadAndScroll'), @@ -299,6 +302,8 @@ const createProps = (overrideProps: Partial = {}): PropsType => ({ oldestUnreadIndex: number('oldestUnreadIndex', overrideProps.oldestUnreadIndex || 0) || undefined, + invitedContactsForNewlyCreatedGroup: + overrideProps.invitedContactsForNewlyCreatedGroup || [], id: '', renderItem, @@ -361,3 +366,22 @@ story.add('Without Oldest Message', () => { return ; }); + +story.add('With invited contacts for a newly-created group', () => { + const props = createProps({ + invitedContactsForNewlyCreatedGroup: [ + { + id: 'abc123', + title: 'John Bon Bon Jovi', + type: 'direct', + }, + { + id: 'def456', + title: 'Bon John Bon Jovi', + type: 'direct', + }, + ], + }); + + return ; +}); diff --git a/ts/components/conversation/Timeline.tsx b/ts/components/conversation/Timeline.tsx index 19ed5de093..88d87f5e99 100644 --- a/ts/components/conversation/Timeline.tsx +++ b/ts/components/conversation/Timeline.tsx @@ -15,9 +15,11 @@ import { import { ScrollDownButton } from './ScrollDownButton'; import { LocalizerType } from '../../types/Util'; +import { ConversationType } from '../../state/ducks/conversations'; import { PropsActions as MessageActionsType } from './Message'; import { PropsActions as SafetyNumberActionsType } from './SafetyNumberNotification'; +import { NewlyCreatedGroupInvitedContactsDialog } from '../NewlyCreatedGroupInvitedContactsDialog'; const AT_BOTTOM_THRESHOLD = 15; const NEAR_BOTTOM_THRESHOLD = 15; @@ -48,6 +50,7 @@ type PropsHousekeepingType = { isGroupV1AndDisabled?: boolean; selectedMessageId?: string; + invitedContactsForNewlyCreatedGroup: Array; i18n: LocalizerType; @@ -68,6 +71,7 @@ type PropsHousekeepingType = { type PropsActionsType = { clearChangedMessages: (conversationId: string) => unknown; + clearInvitedConversationsForNewlyCreatedGroup: () => void; setLoadCountdownStart: ( conversationId: string, loadCountdownStart?: number @@ -1063,7 +1067,14 @@ export class Timeline extends React.PureComponent { }; public render(): JSX.Element | null { - const { i18n, id, items, isGroupV1AndDisabled } = this.props; + const { + clearInvitedConversationsForNewlyCreatedGroup, + i18n, + id, + items, + isGroupV1AndDisabled, + invitedContactsForNewlyCreatedGroup, + } = this.props; const { shouldShowScrollDownButton, areUnreadBelowCurrentPosition, @@ -1077,60 +1088,70 @@ export class Timeline extends React.PureComponent { } return ( -
- - {({ height, width }) => { - if (this.mostRecentWidth && this.mostRecentWidth !== width) { - this.resizeFlag = true; + <> +
+ + {({ height, width }) => { + if (this.mostRecentWidth && this.mostRecentWidth !== width) { + this.resizeFlag = true; - setTimeout(this.resize, 0); - } else if ( - this.mostRecentHeight && - this.mostRecentHeight !== height - ) { - setTimeout(this.onHeightOnlyChange, 0); - } + setTimeout(this.resize, 0); + } else if ( + this.mostRecentHeight && + this.mostRecentHeight !== height + ) { + setTimeout(this.onHeightOnlyChange, 0); + } - this.mostRecentWidth = width; - this.mostRecentHeight = height; + this.mostRecentWidth = width; + this.mostRecentHeight = height; - return ( - - ); - }} - - {shouldShowScrollDownButton ? ( - + ); + }} + + {shouldShowScrollDownButton ? ( + + ) : null} +
+ + {Boolean(invitedContactsForNewlyCreatedGroup.length) && ( + - ) : null} -
+ )} + ); } } diff --git a/ts/components/conversationList/BaseConversationListItem.tsx b/ts/components/conversationList/BaseConversationListItem.tsx index 7307eb8f28..6f65f1e3a0 100644 --- a/ts/components/conversationList/BaseConversationListItem.tsx +++ b/ts/components/conversationList/BaseConversationListItem.tsx @@ -20,11 +20,14 @@ export const DATE_CLASS_NAME = `${HEADER_CLASS_NAME}__date`; const TIMESTAMP_CLASS_NAME = `${DATE_CLASS_NAME}__timestamp`; export const MESSAGE_CLASS_NAME = `${CONTENT_CLASS_NAME}__message`; export const MESSAGE_TEXT_CLASS_NAME = `${MESSAGE_CLASS_NAME}__text`; +const CHECKBOX_CLASS_NAME = `${BASE_CLASS_NAME}__checkbox`; type PropsType = { avatarPath?: string; + checked?: boolean; color?: ColorType; conversationType: 'group' | 'direct'; + disabled?: boolean; headerDate?: number; headerName: ReactNode; i18n: LocalizerType; @@ -37,7 +40,7 @@ type PropsType = { messageStatusIcon?: ReactNode; messageText?: ReactNode; name?: string; - onClick: () => void; + onClick?: () => void; phoneNumber?: string; profileName?: string; style: CSSProperties; @@ -48,8 +51,10 @@ type PropsType = { export const BaseConversationListItem: FunctionComponent = React.memo( ({ avatarPath, + checked, color, conversationType, + disabled, headerDate, headerName, i18n, @@ -74,17 +79,32 @@ export const BaseConversationListItem: FunctionComponent = React.memo ? isNoteToSelf : Boolean(isMe); - return ( -
-
+
{headerName}
{isNumber(headerDate) && ( @@ -137,7 +162,61 @@ export const BaseConversationListItem: FunctionComponent = React.memo
) : null}
- + {checkboxNode} + + ); + + const commonClassNames = classNames(BASE_CLASS_NAME, { + [`${BASE_CLASS_NAME}--has-unread`]: isUnread, + [`${BASE_CLASS_NAME}--is-selected`]: isSelected, + }); + + if (isCheckbox) { + return ( + + ); + } + + if (onClick) { + return ( + + ); + } + + return ( +
+ {contents} +
); } ); diff --git a/ts/components/conversationList/ContactCheckbox.tsx b/ts/components/conversationList/ContactCheckbox.tsx new file mode 100644 index 0000000000..e28ea7bfc7 --- /dev/null +++ b/ts/components/conversationList/ContactCheckbox.tsx @@ -0,0 +1,97 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { CSSProperties, FunctionComponent } from 'react'; + +import { BaseConversationListItem } from './BaseConversationListItem'; +import { ColorType } from '../../types/Colors'; +import { LocalizerType } from '../../types/Util'; +import { ContactName } from '../conversation/ContactName'; +import { About } from '../conversation/About'; + +export enum ContactCheckboxDisabledReason { + // We start the enum at 1 because the default starting value of 0 is falsy. + MaximumContactsSelected = 1, + NotCapable, +} + +export type PropsDataType = { + about?: string; + avatarPath?: string; + color?: ColorType; + disabledReason?: ContactCheckboxDisabledReason; + id: string; + isChecked: boolean; + name?: string; + phoneNumber?: string; + profileName?: string; + title: string; +}; + +type PropsHousekeepingType = { + i18n: LocalizerType; + style: CSSProperties; + onClick: ( + id: string, + disabledReason: undefined | ContactCheckboxDisabledReason + ) => void; +}; + +type PropsType = PropsDataType & PropsHousekeepingType; + +export const ContactCheckbox: FunctionComponent = React.memo( + ({ + about, + avatarPath, + color, + disabledReason, + i18n, + id, + isChecked, + name, + onClick, + phoneNumber, + profileName, + style, + title, + }) => { + const disabled = Boolean(disabledReason); + + const headerName = ( + + ); + + const messageText = about ? : null; + + const onClickItem = () => { + onClick(id, disabledReason); + }; + + return ( + + ); + } +); diff --git a/ts/components/conversationList/ContactListItem.tsx b/ts/components/conversationList/ContactListItem.tsx index 7632ad6f65..5f031a3c94 100644 --- a/ts/components/conversationList/ContactListItem.tsx +++ b/ts/components/conversationList/ContactListItem.tsx @@ -1,7 +1,7 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { useCallback, CSSProperties, FunctionComponent } from 'react'; +import React, { CSSProperties, FunctionComponent } from 'react'; import { BaseConversationListItem } from './BaseConversationListItem'; import { ColorType } from '../../types/Colors'; @@ -25,7 +25,7 @@ export type PropsDataType = { type PropsHousekeepingType = { i18n: LocalizerType; style: CSSProperties; - onClick: (id: string) => void; + onClick?: (id: string) => void; }; type PropsType = PropsDataType & PropsHousekeepingType; @@ -61,8 +61,6 @@ export const ContactListItem: FunctionComponent = React.memo( const messageText = about && !isMe ? : null; - const onClickItem = useCallback(() => onClick(id), [onClick, id]); - return ( = React.memo( isSelected={false} messageText={messageText} name={name} - onClick={onClickItem} + onClick={onClick ? () => onClick(id) : undefined} phoneNumber={phoneNumber} profileName={profileName} style={style} diff --git a/ts/components/conversationList/CreateNewGroupButton.tsx b/ts/components/conversationList/CreateNewGroupButton.tsx new file mode 100644 index 0000000000..865e5858fe --- /dev/null +++ b/ts/components/conversationList/CreateNewGroupButton.tsx @@ -0,0 +1,32 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { CSSProperties, FunctionComponent } from 'react'; + +import { BaseConversationListItem } from './BaseConversationListItem'; +import { LocalizerType } from '../../types/Util'; + +type PropsType = { + i18n: LocalizerType; + onClick: () => void; + style: CSSProperties; +}; + +export const CreateNewGroupButton: FunctionComponent = React.memo( + ({ i18n, onClick, style }) => { + const title = i18n('createNewGroupButton'); + + return ( + + ); + } +); diff --git a/ts/components/leftPane/LeftPaneChooseGroupMembersHelper.tsx b/ts/components/leftPane/LeftPaneChooseGroupMembersHelper.tsx new file mode 100644 index 0000000000..43e8dadef2 --- /dev/null +++ b/ts/components/leftPane/LeftPaneChooseGroupMembersHelper.tsx @@ -0,0 +1,304 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { ReactChild, ChangeEvent } from 'react'; + +import { LeftPaneHelper } from './LeftPaneHelper'; +import { Row, RowType } from '../ConversationList'; +import { ConversationType } from '../../state/ducks/conversations'; +import { ContactCheckboxDisabledReason } from '../conversationList/ContactCheckbox'; +import { ContactPills } from '../ContactPills'; +import { ContactPill } from '../ContactPill'; +import { Alert } from '../Alert'; +import { Button } from '../Button'; +import { LocalizerType } from '../../types/Util'; +import { + getGroupSizeRecommendedLimit, + getGroupSizeHardLimit, +} from '../../groups/limits'; + +export type LeftPaneChooseGroupMembersPropsType = { + candidateContacts: ReadonlyArray; + cantAddContactForModal: undefined | ConversationType; + isShowingRecommendedGroupSizeModal: boolean; + isShowingMaximumGroupSizeModal: boolean; + searchTerm: string; + selectedContacts: Array; +}; + +/* eslint-disable class-methods-use-this */ + +export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper< + LeftPaneChooseGroupMembersPropsType +> { + private readonly candidateContacts: ReadonlyArray; + + private readonly cantAddContactForModal: + | undefined + | Readonly<{ title: string }>; + + private readonly isShowingMaximumGroupSizeModal: boolean; + + private readonly isShowingRecommendedGroupSizeModal: boolean; + + private readonly searchTerm: string; + + private readonly selectedContacts: Array; + + private readonly selectedConversationIdsSet: Set; + + constructor({ + candidateContacts, + cantAddContactForModal, + isShowingMaximumGroupSizeModal, + isShowingRecommendedGroupSizeModal, + searchTerm, + selectedContacts, + }: Readonly) { + super(); + + this.candidateContacts = candidateContacts; + this.cantAddContactForModal = cantAddContactForModal; + this.isShowingMaximumGroupSizeModal = isShowingMaximumGroupSizeModal; + this.isShowingRecommendedGroupSizeModal = isShowingRecommendedGroupSizeModal; + this.searchTerm = searchTerm; + this.selectedContacts = selectedContacts; + + this.selectedConversationIdsSet = new Set( + selectedContacts.map(contact => contact.id) + ); + } + + getHeaderContents({ + i18n, + startComposing, + }: Readonly<{ + i18n: LocalizerType; + startComposing: () => void; + }>): ReactChild { + const backButtonLabel = i18n('chooseGroupMembers__back-button'); + + return ( +
+
+ ); + } + + getPreRowsNode({ + closeCantAddContactToGroupModal, + closeMaximumGroupSizeModal, + closeRecommendedGroupSizeModal, + i18n, + onChangeComposeSearchTerm, + removeSelectedContact, + }: Readonly<{ + closeCantAddContactToGroupModal: () => unknown; + closeMaximumGroupSizeModal: () => unknown; + closeRecommendedGroupSizeModal: () => unknown; + i18n: LocalizerType; + onChangeComposeSearchTerm: ( + event: ChangeEvent + ) => unknown; + removeSelectedContact: (conversationId: string) => unknown; + }>): ReactChild { + let modalDetails: + | undefined + | { title: string; body: string; onClose: () => void }; + if (this.isShowingMaximumGroupSizeModal) { + modalDetails = { + title: i18n('chooseGroupMembers__maximum-group-size__title'), + body: i18n('chooseGroupMembers__maximum-group-size__body', [ + this.getMaximumNumberOfContacts().toString(), + ]), + onClose: closeMaximumGroupSizeModal, + }; + } else if (this.isShowingRecommendedGroupSizeModal) { + modalDetails = { + title: i18n( + 'chooseGroupMembers__maximum-recommended-group-size__title' + ), + body: i18n('chooseGroupMembers__maximum-recommended-group-size__body', [ + this.getRecommendedMaximumNumberOfContacts().toString(), + ]), + onClose: closeRecommendedGroupSizeModal, + }; + } else if (this.cantAddContactForModal) { + modalDetails = { + title: i18n('chooseGroupMembers__cant-add-member__title'), + body: i18n('chooseGroupMembers__cant-add-member__body', [ + this.cantAddContactForModal.title, + ]), + onClose: closeCantAddContactToGroupModal, + }; + } + + return ( + <> +
+ +
+ + {Boolean(this.selectedContacts.length) && ( + + {this.selectedContacts.map(contact => ( + + ))} + + )} + + {this.getRowCount() ? null : ( +
+ {i18n('newConversationNoContacts')} +
+ )} + + {modalDetails && ( + + )} + + ); + } + + getFooterContents({ + i18n, + startSettingGroupMetadata, + }: Readonly<{ + i18n: LocalizerType; + startSettingGroupMetadata: () => void; + }>): ReactChild { + return ( + + ); + } + + getRowCount(): number { + if (!this.candidateContacts.length) { + return 0; + } + return this.candidateContacts.length + 2; + } + + getRow(rowIndex: number): undefined | Row { + if (!this.candidateContacts.length) { + return undefined; + } + + if (rowIndex === 0) { + return { + type: RowType.Header, + i18nKey: 'contactsHeader', + }; + } + + // This puts a blank row for the footer. + if (rowIndex === this.candidateContacts.length + 1) { + return { type: RowType.Blank }; + } + + const contact = this.candidateContacts[rowIndex - 1]; + if (!contact) { + return undefined; + } + + const isChecked = this.selectedConversationIdsSet.has(contact.id); + + let disabledReason: undefined | ContactCheckboxDisabledReason; + if (!isChecked) { + if (this.hasSelectedMaximumNumberOfContacts()) { + disabledReason = ContactCheckboxDisabledReason.MaximumContactsSelected; + } else if (!contact.isGroupV2Capable) { + disabledReason = ContactCheckboxDisabledReason.NotCapable; + } + } + + return { + type: RowType.ContactCheckbox, + contact, + isChecked, + disabledReason, + }; + } + + // This is deliberately unimplemented because these keyboard shortcuts shouldn't work in + // the composer. The same is true for the "in direction" function below. + getConversationAndMessageAtIndex( + ..._args: ReadonlyArray + ): undefined { + return undefined; + } + + getConversationAndMessageInDirection( + ..._args: ReadonlyArray + ): undefined { + return undefined; + } + + shouldRecomputeRowHeights(_old: unknown): boolean { + return false; + } + + private hasSelectedMaximumNumberOfContacts(): boolean { + return this.selectedContacts.length >= this.getMaximumNumberOfContacts(); + } + + private hasExceededMaximumNumberOfContacts(): boolean { + // It should be impossible to reach this state. This is here as a failsafe. + return this.selectedContacts.length > this.getMaximumNumberOfContacts(); + } + + private getRecommendedMaximumNumberOfContacts(): number { + return getGroupSizeRecommendedLimit(151) - 1; + } + + private getMaximumNumberOfContacts(): number { + return getGroupSizeHardLimit(1001) - 1; + } +} + +function focusRef(el: HTMLElement | null) { + if (el) { + el.focus(); + } +} diff --git a/ts/components/leftPane/LeftPaneComposeHelper.tsx b/ts/components/leftPane/LeftPaneComposeHelper.tsx index 5800c8101d..f232d65e08 100644 --- a/ts/components/leftPane/LeftPaneComposeHelper.tsx +++ b/ts/components/leftPane/LeftPaneComposeHelper.tsx @@ -12,6 +12,9 @@ import { instance as phoneNumberInstance, PhoneNumberFormat, } from '../../util/libphonenumberInstance'; +import { assert } from '../../util/assert'; +import { missingCaseError } from '../../util/missingCaseError'; +import { isStorageWriteFeatureEnabled } from '../../storage/isFeatureEnabled'; export type LeftPaneComposePropsType = { composeContacts: ReadonlyArray; @@ -19,6 +22,12 @@ export type LeftPaneComposePropsType = { searchTerm: string; }; +enum TopButton { + None, + CreateNewGroup, + StartNewConversation, +} + /* eslint-disable class-methods-use-this */ export class LeftPaneComposeHelper extends LeftPaneHelper< @@ -98,24 +107,53 @@ export class LeftPaneComposeHelper extends LeftPaneHelper< } getRowCount(): number { - return this.composeContacts.length + (this.phoneNumber ? 1 : 0); + let result = this.composeContacts.length; + if (this.hasTopButton()) { + result += 1; + } + if (this.hasContactsHeader()) { + result += 1; + } + return result; } getRow(rowIndex: number): undefined | Row { - let contactIndex = rowIndex; - - if (this.phoneNumber) { - if (rowIndex === 0) { - return { - type: RowType.StartNewConversation, - phoneNumber: phoneNumberInstance.format( + if (rowIndex === 0) { + const topButton = this.getTopButton(); + switch (topButton) { + case TopButton.None: + break; + case TopButton.StartNewConversation: + assert( this.phoneNumber, - PhoneNumberFormat.E164 - ), - }; + 'LeftPaneComposeHelper: we should have a phone number if the top button is "Start new conversation"' + ); + return { + type: RowType.StartNewConversation, + phoneNumber: phoneNumberInstance.format( + this.phoneNumber, + PhoneNumberFormat.E164 + ), + }; + case TopButton.CreateNewGroup: + return { type: RowType.CreateNewGroup }; + default: + throw missingCaseError(topButton); } + } - contactIndex -= 1; + if (rowIndex === 1 && this.hasContactsHeader()) { + return { + type: RowType.Header, + i18nKey: 'contactsHeader', + }; + } + + let contactIndex: number; + if (this.hasTopButton()) { + contactIndex = rowIndex - 2; + } else { + contactIndex = rowIndex; } const contact = this.composeContacts[contactIndex]; @@ -141,8 +179,29 @@ export class LeftPaneComposeHelper extends LeftPaneHelper< return undefined; } - shouldRecomputeRowHeights(_old: unknown): boolean { - return false; + shouldRecomputeRowHeights(old: Readonly): boolean { + return ( + this.hasContactsHeader() !== + new LeftPaneComposeHelper(old).hasContactsHeader() + ); + } + + private getTopButton(): TopButton { + if (this.phoneNumber) { + return TopButton.StartNewConversation; + } + if (this.searchTerm || !isStorageWriteFeatureEnabled()) { + return TopButton.None; + } + return TopButton.CreateNewGroup; + } + + private hasTopButton(): boolean { + return this.getTopButton() !== TopButton.None; + } + + private hasContactsHeader(): boolean { + return this.hasTopButton() && Boolean(this.composeContacts.length); } } diff --git a/ts/components/leftPane/LeftPaneHelper.tsx b/ts/components/leftPane/LeftPaneHelper.tsx index b51e6ade96..fa438abc6b 100644 --- a/ts/components/leftPane/LeftPaneHelper.tsx +++ b/ts/components/leftPane/LeftPaneHelper.tsx @@ -23,6 +23,8 @@ export abstract class LeftPaneHelper { _: Readonly<{ i18n: LocalizerType; showInbox: () => void; + startComposing: () => void; + showChooseGroupMembers: () => void; }> ): null | ReactChild { return null; @@ -34,10 +36,28 @@ export abstract class LeftPaneHelper { getPreRowsNode( _: Readonly<{ + clearGroupCreationError: () => void; + closeCantAddContactToGroupModal: () => unknown; + closeMaximumGroupSizeModal: () => unknown; + closeRecommendedGroupSizeModal: () => unknown; + createGroup: () => unknown; i18n: LocalizerType; + setComposeGroupAvatar: (_: undefined | ArrayBuffer) => unknown; + setComposeGroupName: (_: string) => unknown; onChangeComposeSearchTerm: ( event: ChangeEvent ) => unknown; + removeSelectedContact: (_: string) => unknown; + }> + ): null | ReactChild { + return null; + } + + getFooterContents( + _: Readonly<{ + i18n: LocalizerType; + startSettingGroupMetadata: () => void; + createGroup: () => unknown; }> ): null | ReactChild { return null; diff --git a/ts/components/leftPane/LeftPaneSetGroupMetadataHelper.tsx b/ts/components/leftPane/LeftPaneSetGroupMetadataHelper.tsx new file mode 100644 index 0000000000..9b3740e508 --- /dev/null +++ b/ts/components/leftPane/LeftPaneSetGroupMetadataHelper.tsx @@ -0,0 +1,218 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { ReactChild } from 'react'; + +import { LeftPaneHelper } from './LeftPaneHelper'; +import { Row, RowType } from '../ConversationList'; +import { PropsDataType as ContactListItemPropsType } from '../conversationList/ContactListItem'; +import { LocalizerType } from '../../types/Util'; +import { AvatarInput } from '../AvatarInput'; +import { Alert } from '../Alert'; +import { Spinner } from '../Spinner'; +import { Button } from '../Button'; + +export type LeftPaneSetGroupMetadataPropsType = { + groupAvatar: undefined | ArrayBuffer; + groupName: string; + hasError: boolean; + isCreating: boolean; + selectedContacts: ReadonlyArray; +}; + +/* eslint-disable class-methods-use-this */ + +export class LeftPaneSetGroupMetadataHelper extends LeftPaneHelper< + LeftPaneSetGroupMetadataPropsType +> { + private readonly groupAvatar: undefined | ArrayBuffer; + + private readonly groupName: string; + + private readonly hasError: boolean; + + private readonly isCreating: boolean; + + private readonly selectedContacts: ReadonlyArray; + + constructor({ + groupAvatar, + groupName, + isCreating, + hasError, + selectedContacts, + }: Readonly) { + super(); + + this.groupAvatar = groupAvatar; + this.groupName = groupName; + this.hasError = hasError; + this.isCreating = isCreating; + this.selectedContacts = selectedContacts; + } + + getHeaderContents({ + i18n, + showChooseGroupMembers, + }: Readonly<{ + i18n: LocalizerType; + showChooseGroupMembers: () => void; + }>): ReactChild { + const backButtonLabel = i18n('setGroupMetadata__back-button'); + + return ( +
+
+ ); + } + + getPreRowsNode({ + clearGroupCreationError, + createGroup, + i18n, + setComposeGroupAvatar, + setComposeGroupName, + }: Readonly<{ + clearGroupCreationError: () => unknown; + createGroup: () => unknown; + i18n: LocalizerType; + setComposeGroupAvatar: (_: undefined | ArrayBuffer) => unknown; + setComposeGroupName: (_: string) => unknown; + }>): ReactChild { + const disabled = this.isCreating; + + return ( +
{ + event.preventDefault(); + event.stopPropagation(); + + if (!this.canCreateGroup()) { + return; + } + + createGroup(); + }} + > + + { + setComposeGroupName(event.target.value); + }} + placeholder={i18n('setGroupMetadata__group-name-placeholder')} + ref={focusRef} + type="text" + value={this.groupName} + /> + + {this.hasError && ( + + )} + + ); + } + + getFooterContents({ + createGroup, + i18n, + }: Readonly<{ + createGroup: () => unknown; + i18n: LocalizerType; + }>): ReactChild { + return ( + + ); + } + + getRowCount(): number { + if (!this.selectedContacts.length) { + return 0; + } + return this.selectedContacts.length + 2; + } + + getRow(rowIndex: number): undefined | Row { + if (!this.selectedContacts.length) { + return undefined; + } + + if (rowIndex === 0) { + return { + type: RowType.Header, + i18nKey: 'setGroupMetadata__members-header', + }; + } + + // This puts a blank row for the footer. + if (rowIndex === this.selectedContacts.length + 1) { + return { type: RowType.Blank }; + } + + const contact = this.selectedContacts[rowIndex - 1]; + return contact + ? { + type: RowType.Contact, + contact, + isClickable: false, + } + : undefined; + } + + // This is deliberately unimplemented because these keyboard shortcuts shouldn't work in + // the composer. The same is true for the "in direction" function below. + getConversationAndMessageAtIndex( + ..._args: ReadonlyArray + ): undefined { + return undefined; + } + + getConversationAndMessageInDirection( + ..._args: ReadonlyArray + ): undefined { + return undefined; + } + + shouldRecomputeRowHeights(_old: unknown): boolean { + return false; + } + + private canCreateGroup(): boolean { + return !this.isCreating && Boolean(this.groupName.trim()); + } +} + +function focusRef(el: HTMLElement | null) { + if (el) { + el.focus(); + } +} diff --git a/ts/groups.ts b/ts/groups.ts index c7e7d8313c..568cc56b52 100644 --- a/ts/groups.ts +++ b/ts/groups.ts @@ -7,7 +7,6 @@ import { difference, flatten, fromPairs, - isFinite, isNumber, values, } from 'lodash'; @@ -18,8 +17,10 @@ import { GROUP_CREDENTIALS_KEY, maybeFetchNewCredentials, } from './services/groupCredentialFetcher'; +import { isStorageWriteFeatureEnabled } from './storage/isFeatureEnabled'; import dataInterface from './sql/Client'; import { toWebSafeBase64, fromWebSafeBase64 } from './util/webSafeBase64'; +import { assert } from './util/assert'; import { ConversationAttributesType, GroupV2MemberType, @@ -72,6 +73,7 @@ import { import MessageSender, { CallbackResultType } from './textsecure/SendMessage'; import { CURRENT_SCHEMA_VERSION as MAX_MESSAGE_SCHEMA } from '../js/modules/types/message'; import { ConversationModel } from './models/conversations'; +import { getGroupSizeHardLimit } from './groups/limits'; export { joinViaLink } from './groups/joinViaLink'; @@ -222,6 +224,12 @@ type UpdatesResultType = { newAttributes: ConversationAttributesType; }; +type UploadedAvatarType = { + data: ArrayBuffer; + hash: string; + key: string; +}; + // Constants export const MASTER_KEY_LENGTH = 32; @@ -324,21 +332,25 @@ export function parseGroupLink( // Group Modifications -async function uploadAvatar({ - logId, - path, - publicParams, - secretParams, -}: { - logId: string; - path: string; - publicParams: string; - secretParams: string; -}): Promise<{ hash: string; key: string }> { +async function uploadAvatar( + options: { + logId: string; + publicParams: string; + secretParams: string; + } & ({ path: string } | { data: ArrayBuffer }) +): Promise { + const { logId, publicParams, secretParams } = options; + try { const clientZkGroupCipher = getClientZkGroupCipher(secretParams); - const data = await window.Signal.Migrations.readAttachmentData(path); + let data: ArrayBuffer; + if ('data' in options) { + ({ data } = options); + } else { + data = await window.Signal.Migrations.readAttachmentData(options.path); + } + const hash = await computeHash(data); const blob = new window.textsecure.protobuf.GroupAttributeBlob(); @@ -350,13 +362,14 @@ async function uploadAvatar({ logId: `uploadGroupAvatar/${logId}`, publicParams, secretParams, - request: (sender, options) => - sender.uploadGroupAvatar(ciphertext, options), + request: (sender, requestOptions) => + sender.uploadGroupAvatar(ciphertext, requestOptions), }); return { - key, + data, hash, + key, }; } catch (error) { window.log.warn( @@ -367,11 +380,22 @@ async function uploadAvatar({ } } -async function buildGroupProto({ - attributes, -}: { - attributes: ConversationAttributesType; -}): Promise { +function buildGroupProto( + attributes: Pick< + ConversationAttributesType, + | 'accessControl' + | 'expireTimer' + | 'id' + | 'membersV2' + | 'name' + | 'pendingMembersV2' + | 'publicParams' + | 'revision' + | 'secretParams' + > & { + avatarUrl?: string; + } +): GroupClass { const MEMBER_ROLE_ENUM = window.textsecure.protobuf.Member.Role; const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired; const logId = `groupv2(${attributes.id})`; @@ -404,21 +428,8 @@ async function buildGroupProto({ const titleBlobPlaintext = titleBlob.toArrayBuffer(); proto.title = encryptGroupBlob(clientZkGroupCipher, titleBlobPlaintext); - if (attributes.avatar && attributes.avatar.path) { - const { path } = attributes.avatar; - const { key, hash } = await uploadAvatar({ - logId, - path, - publicParams, - secretParams, - }); - - // eslint-disable-next-line no-param-reassign - attributes.avatar.hash = hash; - // eslint-disable-next-line no-param-reassign - attributes.avatar.url = key; - - proto.avatar = key; + if (attributes.avatarUrl) { + proto.avatar = attributes.avatarUrl; } if (attributes.expireTimer) { @@ -1159,6 +1170,237 @@ export async function fetchMembershipProof({ return response.token; } +// Creating a group + +export async function createGroupV2({ + name, + avatar, + conversationIds, +}: Readonly<{ + name: string; + avatar: undefined | ArrayBuffer; + conversationIds: Array; +}>): Promise { + // Ensure we have the credentials we need before attempting GroupsV2 operations + await maybeFetchNewCredentials(); + + if (!isStorageWriteFeatureEnabled()) { + throw new Error( + 'createGroupV2: storage service write is not enabled. Cannot create the group' + ); + } + + const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired; + const MEMBER_ROLE_ENUM = window.textsecure.protobuf.Member.Role; + + const masterKeyBuffer = getRandomBytes(32); + const fields = deriveGroupFields(masterKeyBuffer); + + const groupId = arrayBufferToBase64(fields.id); + const logId = `groupv2(${groupId})`; + + const masterKey = arrayBufferToBase64(masterKeyBuffer); + const secretParams = arrayBufferToBase64(fields.secretParams); + const publicParams = arrayBufferToBase64(fields.publicParams); + + const ourConversationId = window.ConversationController.getOurConversationIdOrThrow(); + const ourConversation = window.ConversationController.get(ourConversationId); + if (!ourConversation) { + throw new Error( + `createGroupV2/${logId}: cannot get our own conversation. Cannot create the group` + ); + } + + const membersV2: Array = [ + { + conversationId: ourConversationId, + role: MEMBER_ROLE_ENUM.ADMINISTRATOR, + joinedAtVersion: 0, + }, + ]; + const pendingMembersV2: Array = []; + + let uploadedAvatar: undefined | UploadedAvatarType; + + await Promise.all([ + ...conversationIds.map(async conversationId => { + const contact = window.ConversationController.get(conversationId); + if (!contact) { + assert( + false, + `createGroupV2/${logId}: missing local contact, skipping` + ); + return; + } + + if (!contact.get('uuid')) { + assert(false, `createGroupV2/${logId}: missing UUID; skipping`); + return; + } + + // Refresh our local data to be sure + if ( + !contact.get('capabilities')?.gv2 || + !contact.get('profileKey') || + !contact.get('profileKeyCredential') + ) { + await contact.getProfiles(); + } + + if (!contact.get('capabilities')?.gv2) { + assert( + false, + `createGroupV2/${logId}: member is missing GV2 capability; skipping` + ); + return; + } + + if (contact.get('profileKey') && contact.get('profileKeyCredential')) { + membersV2.push({ + conversationId, + role: MEMBER_ROLE_ENUM.DEFAULT, + joinedAtVersion: 0, + }); + } else { + pendingMembersV2.push({ + addedByUserId: ourConversationId, + conversationId, + timestamp: Date.now(), + role: MEMBER_ROLE_ENUM.DEFAULT, + }); + } + }), + (async () => { + if (!avatar) { + return; + } + + uploadedAvatar = await uploadAvatar({ + data: avatar, + logId, + publicParams, + secretParams, + }); + })(), + ]); + + if (membersV2.length + pendingMembersV2.length > getGroupSizeHardLimit()) { + throw new Error( + `createGroupV2/${logId}: Too many members! Member count: ${membersV2.length}, Pending member count: ${pendingMembersV2.length}` + ); + } + + const protoAndConversationAttributes = { + name, + + // Core GroupV2 info + revision: 0, + publicParams, + secretParams, + + // GroupV2 state + accessControl: { + attributes: ACCESS_ENUM.MEMBER, + members: ACCESS_ENUM.MEMBER, + addFromInviteLink: ACCESS_ENUM.UNSATISFIABLE, + }, + membersV2, + pendingMembersV2, + }; + + const groupProto = await buildGroupProto({ + id: groupId, + avatarUrl: uploadedAvatar?.key, + ...protoAndConversationAttributes, + }); + + await makeRequestWithTemporalRetry({ + logId: `createGroupV2/${logId}`, + publicParams, + secretParams, + request: (sender, options) => sender.createGroup(groupProto, options), + }); + + let avatarAttribute: ConversationAttributesType['avatar']; + if (uploadedAvatar) { + try { + avatarAttribute = { + url: uploadedAvatar.key, + path: await window.Signal.Migrations.writeNewAttachmentData( + uploadedAvatar.data + ), + hash: uploadedAvatar.hash, + }; + } catch (err) { + window.log.warn( + `createGroupV2/${logId}: avatar failed to save to disk. Continuing on` + ); + } + } + + const now = Date.now(); + + const conversation = await window.ConversationController.getOrCreateAndWait( + groupId, + 'group', + { + ...protoAndConversationAttributes, + active_at: now, + addedBy: ourConversationId, + avatar: avatarAttribute, + groupVersion: 2, + masterKey, + profileSharing: true, + timestamp: now, + needsStorageServiceSync: true, + } + ); + + await conversation.queueJob(() => { + window.Signal.Services.storageServiceUploadJob(); + }); + + const timestamp = Date.now(); + const profileKey = ourConversation.get('profileKey'); + + const groupV2Info = conversation.getGroupV2Info({ + includePendingMembers: true, + }); + + await wrapWithSyncMessageSend({ + conversation, + logId: `sendMessageToGroup/${logId}`, + send: async sender => + sender.sendMessageToGroup({ + groupV2: groupV2Info, + timestamp, + profileKey: profileKey ? base64ToArrayBuffer(profileKey) : undefined, + }), + timestamp, + }); + + const createdTheGroupMessage: MessageAttributesType = { + ...generateBasicMessage(), + type: 'group-v2-change', + sourceUuid: conversation.ourUuid, + conversationId: conversation.id, + received_at: timestamp, + sent_at: timestamp, + groupV2Change: { + from: ourConversationId, + details: [{ type: 'create' }], + }, + }; + await window.Signal.Data.saveMessages([createdTheGroupMessage], { + forceSave: true, + }); + const model = new window.Whisper.Message(createdTheGroupMessage); + window.MessageController.register(model.id, model); + conversation.trigger('newmessage', model); + + return conversation; +} + // Migrating a group export async function hasV1GroupBeenMigrated( @@ -1451,6 +1693,8 @@ export async function initiateMigrationToGroupV2( // Ensure we have the credentials we need before attempting GroupsV2 operations await maybeFetchNewCredentials(); + let ourProfileKey: undefined | string; + try { await conversation.queueJob(async () => { const ACCESS_ENUM = @@ -1485,6 +1729,15 @@ export async function initiateMigrationToGroupV2( `initiateMigrationToGroupV2/${logId}: Couldn't fetch our own conversationId!` ); } + const ourConversation = window.ConversationController.get( + ourConversationId + ); + if (!ourConversation) { + throw new Error( + `initiateMigrationToGroupV2/${logId}: cannot get our own conversation. Cannot migrate` + ); + } + ourProfileKey = ourConversation.get('profileKey'); const { membersV2, @@ -1493,33 +1746,37 @@ export async function initiateMigrationToGroupV2( previousGroupV1Members, } = await getGroupMigrationMembers(conversation); - const rawSizeLimit = window.Signal.RemoteConfig.getValue( - 'global.groupsv2.groupSizeHardLimit' - ); - if (!rawSizeLimit) { - throw new Error( - `initiateMigrationToGroupV2/${logId}: Failed to fetch group size limit` - ); - } - const sizeLimit = parseInt(rawSizeLimit, 10); - if (!isFinite(sizeLimit)) { - throw new Error( - `initiateMigrationToGroupV2/${logId}: Failed to parse group size limit` - ); - } - if (membersV2.length + pendingMembersV2.length > sizeLimit) { + if ( + membersV2.length + pendingMembersV2.length > + getGroupSizeHardLimit() + ) { throw new Error( `initiateMigrationToGroupV2/${logId}: Too many members! Member count: ${membersV2.length}, Pending member count: ${pendingMembersV2.length}` ); } // Note: A few group elements don't need to change here: - // - avatar // - name // - expireTimer + let avatarAttribute: ConversationAttributesType['avatar']; + const avatarPath = conversation.attributes.avatar?.path; + if (avatarPath) { + const { hash, key } = await uploadAvatar({ + logId, + publicParams, + secretParams, + path: avatarPath, + }); + avatarAttribute = { + url: key, + path: avatarPath, + hash, + }; + } const newAttributes = { ...conversation.attributes, + avatar: avatarAttribute, // Core GroupV2 info revision: 0, @@ -1550,12 +1807,10 @@ export async function initiateMigrationToGroupV2( members: undefined, }; - const groupProto = await buildGroupProto({ attributes: newAttributes }); - - // Capture the CDK key provided by the server when we uploade - if (groupProto.avatar && newAttributes.avatar) { - newAttributes.avatar.url = groupProto.avatar; - } + const groupProto = buildGroupProto({ + ...newAttributes, + avatarUrl: avatarAttribute?.url, + }); try { await makeRequestWithTemporalRetry({ @@ -1621,7 +1876,6 @@ export async function initiateMigrationToGroupV2( // We've migrated the group, now we need to let all other group members know about it const logId = conversation.idForLogging(); const timestamp = Date.now(); - const profileKey = conversation.get('profileKey'); await wrapWithSyncMessageSend({ conversation, @@ -1633,7 +1887,9 @@ export async function initiateMigrationToGroupV2( includePendingMembers: true, }), timestamp, - profileKey: profileKey ? base64ToArrayBuffer(profileKey) : undefined, + profileKey: ourProfileKey + ? base64ToArrayBuffer(ourProfileKey) + : undefined, }), timestamp, }); diff --git a/ts/groups/limits.ts b/ts/groups/limits.ts new file mode 100644 index 0000000000..9cce18fce0 --- /dev/null +++ b/ts/groups/limits.ts @@ -0,0 +1,29 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { isNumber } from 'lodash'; +import { parseIntOrThrow } from '../util/parseIntOrThrow'; +import { getValue, ConfigKeyType } from '../RemoteConfig'; + +function makeGetter(configKey: ConfigKeyType): (fallback?: number) => number { + return fallback => { + try { + return parseIntOrThrow( + getValue(configKey), + 'Failed to parse group size limit' + ); + } catch (err) { + if (isNumber(fallback)) { + return fallback; + } + throw err; + } + }; +} + +export const getGroupSizeRecommendedLimit = makeGetter( + 'global.groupsv2.maxGroupSize' +); +export const getGroupSizeHardLimit = makeGetter( + 'global.groupsv2.groupSizeHardLimit' +); diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 14037650c7..4fd817e53e 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -1320,6 +1320,9 @@ export class ConversationModel extends window.Backbone.Model< isBlocked: this.isBlocked(), isMe: this.isMe(), isGroupV1AndDisabled: this.isGroupV1AndDisabled(), + isGroupV2Capable: this.isPrivate() + ? Boolean(this.get('capabilities')?.gv2) + : undefined, isPinned: this.get('isPinned'), isUntrusted: this.isUntrusted(), isVerified: this.isVerified(), diff --git a/ts/services/storage.ts b/ts/services/storage.ts index aef9b39194..053aab740f 100644 --- a/ts/services/storage.ts +++ b/ts/services/storage.ts @@ -1,4 +1,4 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { debounce, isNumber, partition } from 'lodash'; @@ -19,7 +19,6 @@ import { StorageManifestClass, StorageRecordClass, } from '../textsecure.d'; -import { isEnabled } from '../RemoteConfig'; import { mergeAccountRecord, mergeContactRecord, @@ -33,6 +32,7 @@ import { import { ConversationModel } from '../models/conversations'; import { storageJobQueue } from '../util/JobQueue'; import { sleep } from '../util/sleep'; +import { isStorageWriteFeatureEnabled } from '../storage/isFeatureEnabled'; const { eraseStorageServiceStateFromConversations, @@ -882,7 +882,7 @@ async function processManifest( } async function sync(): Promise { - if (!isEnabled('desktop.storage')) { + if (!isStorageWriteFeatureEnabled()) { window.log.info( 'storageService.sync: Not starting desktop.storage is falsey' ); @@ -946,16 +946,9 @@ async function sync(): Promise { } async function upload(): Promise { - if (!isEnabled('desktop.storage')) { + if (!isStorageWriteFeatureEnabled()) { window.log.info( - 'storageService.upload: Not starting desktop.storage is falsey' - ); - - return; - } - if (!isEnabled('desktop.storageWrite2')) { - window.log.info( - 'storageService.upload: Not starting desktop.storageWrite2 is falsey' + 'storageService.upload: Not starting because the feature is not enabled' ); return; diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 1777ea3562..b6767fa7aa 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -16,6 +16,7 @@ import { } from 'lodash'; import { StateType as RootStateType } from '../reducer'; +import * as groups from '../../groups'; import { calling } from '../../services/calling'; import { getOwn } from '../../util/getOwn'; import { assert } from '../../util/assert'; @@ -30,6 +31,10 @@ import { } from '../../components/conversation/conversation-details/PendingInvites'; import { GroupV2Membership } from '../../components/conversation/conversation-details/ConversationDetailsMembershipList'; import { MediaItemType } from '../../components/LightboxGallery'; +import { + getGroupSizeRecommendedLimit, + getGroupSizeHardLimit, +} from '../../groups/limits'; // State @@ -70,6 +75,7 @@ export type ConversationType = { isArchived?: boolean; isBlocked?: boolean; isGroupV1AndDisabled?: boolean; + isGroupV2Capable?: boolean; isPinned?: boolean; isUntrusted?: boolean; isVerified?: boolean; @@ -220,8 +226,47 @@ export type PreJoinConversationType = { approvalRequired: boolean; }; +export enum ComposerStep { + StartDirectConversation, + ChooseGroupMembers, + SetGroupMetadata, +} + +export enum OneTimeModalState { + NeverShown, + Showing, + Shown, +} + +type ComposerGroupCreationState = { + groupAvatar: undefined | ArrayBuffer; + groupName: string; + maximumGroupSizeModalState: OneTimeModalState; + recommendedGroupSizeModalState: OneTimeModalState; + selectedConversationIds: Array; +}; + +type ComposerStateType = + | { + step: ComposerStep.StartDirectConversation; + contactSearchTerm: string; + } + | ({ + step: ComposerStep.ChooseGroupMembers; + contactSearchTerm: string; + cantAddContactIdForModal: undefined | string; + } & ComposerGroupCreationState) + | ({ + step: ComposerStep.SetGroupMetadata; + } & ComposerGroupCreationState & + ( + | { isCreating: false; hasError: boolean } + | { isCreating: true; hasError: false } + )); + export type ConversationsStateType = { preJoinConversation?: PreJoinConversationType; + invitedConversationIdsForNewlyCreatedGroup?: Array; conversationLookup: ConversationLookupType; conversationsByE164: ConversationLookupType; conversationsByUuid: ConversationLookupType; @@ -232,9 +277,7 @@ export type ConversationsStateType = { selectedConversationTitle?: string; selectedConversationPanelDepth: number; showArchived: boolean; - composer?: { - contactSearchTerm: string; - }; + composer?: ComposerStateType; // Note: it's very important that both of these locations are always kept up to date messagesLookup: MessageLookupType; @@ -268,6 +311,25 @@ export const getConversationCallMode = ( // Actions +type CantAddContactToGroupActionType = { + type: 'CANT_ADD_CONTACT_TO_GROUP'; + payload: { + conversationId: string; + }; +}; +type ClearGroupCreationErrorActionType = { type: 'CLEAR_GROUP_CREATION_ERROR' }; +type ClearInvitedConversationsForNewlyCreatedGroupActionType = { + type: 'CLEAR_INVITED_CONVERSATIONS_FOR_NEWLY_CREATED_GROUP'; +}; +type CloseCantAddContactToGroupModalActionType = { + type: 'CLOSE_CANT_ADD_CONTACT_TO_GROUP_MODAL'; +}; +type CloseMaximumGroupSizeModalActionType = { + type: 'CLOSE_MAXIMUM_GROUP_SIZE_MODAL'; +}; +type CloseRecommendedGroupSizeModalActionType = { + type: 'CLOSE_RECOMMENDED_GROUP_SIZE_MODAL'; +}; type SetPreJoinConversationActionType = { type: 'SET_PRE_JOIN_CONVERSATION'; payload: { @@ -301,6 +363,18 @@ export type ConversationUnloadedActionType = { id: string; }; }; +type CreateGroupPendingActionType = { + type: 'CREATE_GROUP_PENDING'; +}; +type CreateGroupFulfilledActionType = { + type: 'CREATE_GROUP_FULFILLED'; + payload: { + invitedConversationIds: Array; + }; +}; +type CreateGroupRejectedActionType = { + type: 'CREATE_GROUP_REJECTED'; +}; export type RemoveAllConversationsActionType = { type: 'CONVERSATIONS_REMOVE_ALL'; payload: null; @@ -435,6 +509,14 @@ export type ShowArchivedConversationsActionType = { type: 'SHOW_ARCHIVED_CONVERSATIONS'; payload: null; }; +type SetComposeGroupAvatarActionType = { + type: 'SET_COMPOSE_GROUP_AVATAR'; + payload: { groupAvatar: undefined | ArrayBuffer }; +}; +type SetComposeGroupNameActionType = { + type: 'SET_COMPOSE_GROUP_NAME'; + payload: { groupName: string }; +}; type SetComposeSearchTermActionType = { type: 'SET_COMPOSE_SEARCH_TERM'; payload: { contactSearchTerm: string }; @@ -449,19 +531,42 @@ type SetRecentMediaItemsActionType = { type StartComposingActionType = { type: 'START_COMPOSING'; }; +type ShowChooseGroupMembersActionType = { + type: 'SHOW_CHOOSE_GROUP_MEMBERS'; +}; +type StartSettingGroupMetadataActionType = { + type: 'START_SETTING_GROUP_METADATA'; +}; export type SwitchToAssociatedViewActionType = { type: 'SWITCH_TO_ASSOCIATED_VIEW'; payload: { conversationId: string }; }; +export type ToggleConversationInChooseMembersActionType = { + type: 'TOGGLE_CONVERSATION_IN_CHOOSE_MEMBERS'; + payload: { + conversationId: string; + maxRecommendedGroupSize: number; + maxGroupSize: number; + }; +}; export type ConversationActionType = + | CantAddContactToGroupActionType | ClearChangedMessagesActionType + | ClearGroupCreationErrorActionType + | ClearInvitedConversationsForNewlyCreatedGroupActionType | ClearSelectedMessageActionType | ClearUnreadMetricsActionType + | CloseCantAddContactToGroupModalActionType + | CloseMaximumGroupSizeModalActionType + | CloseRecommendedGroupSizeModalActionType | ConversationAddedActionType | ConversationChangedActionType | ConversationRemovedActionType | ConversationUnloadedActionType + | CreateGroupFulfilledActionType + | CreateGroupPendingActionType + | CreateGroupRejectedActionType | MessageChangedActionType | MessageDeletedActionType | MessagesAddedActionType @@ -473,6 +578,8 @@ export type ConversationActionType = | RepairOldestMessageActionType | ScrollToMessageActionType | SelectedConversationChangedActionType + | SetComposeGroupAvatarActionType + | SetComposeGroupNameActionType | SetComposeSearchTermActionType | SetConversationHeaderTitleActionType | SetIsNearBottomActionType @@ -484,18 +591,28 @@ export type ConversationActionType = | ShowArchivedConversationsActionType | ShowInboxActionType | StartComposingActionType - | SwitchToAssociatedViewActionType; + | ShowChooseGroupMembersActionType + | StartSettingGroupMetadataActionType + | SwitchToAssociatedViewActionType + | ToggleConversationInChooseMembersActionType; // Action Creators export const actions = { + cantAddContactToGroup, clearChangedMessages, + clearInvitedConversationsForNewlyCreatedGroup, + clearGroupCreationError, clearSelectedMessage, clearUnreadMetrics, + closeCantAddContactToGroupModal, + closeRecommendedGroupSizeModal, + closeMaximumGroupSizeModal, conversationAdded, conversationChanged, conversationRemoved, conversationUnloaded, + createGroup, messageChanged, messageDeleted, messagesAdded, @@ -508,6 +625,8 @@ export const actions = { repairOldestMessage, scrollToMessage, selectMessage, + setComposeGroupAvatar, + setComposeGroupName, setComposeSearchTerm, setIsNearBottom, setLoadCountdownStart, @@ -519,9 +638,20 @@ export const actions = { showArchivedConversations, showInbox, startComposing, + showChooseGroupMembers, startNewConversationFromPhoneNumber, + startSettingGroupMetadata, + toggleConversationInChooseMembers, }; +function cantAddContactToGroup( + conversationId: string +): CantAddContactToGroupActionType { + return { + type: 'CANT_ADD_CONTACT_TO_GROUP', + payload: { conversationId }, + }; +} function setPreJoinConversation( data: PreJoinConversationType | undefined ): SetPreJoinConversationActionType { @@ -576,6 +706,52 @@ function conversationUnloaded(id: string): ConversationUnloadedActionType { }, }; } + +function createGroup(): ThunkAction< + void, + RootStateType, + unknown, + | CreateGroupPendingActionType + | CreateGroupFulfilledActionType + | CreateGroupRejectedActionType + | SwitchToAssociatedViewActionType +> { + return async (dispatch, getState, ...args) => { + const { composer } = getState().conversations; + if ( + composer?.step !== ComposerStep.SetGroupMetadata || + composer.isCreating + ) { + assert(false, 'Cannot create group in this stage; doing nothing'); + return; + } + + dispatch({ type: 'CREATE_GROUP_PENDING' }); + + try { + const conversation = await groups.createGroupV2({ + name: composer.groupName, + avatar: composer.groupAvatar, + conversationIds: composer.selectedConversationIds, + }); + dispatch({ + type: 'CREATE_GROUP_FULFILLED', + payload: { + invitedConversationIds: ( + conversation.get('pendingMembersV2') || [] + ).map(member => member.conversationId), + }, + }); + openConversationInternal({ + conversationId: conversation.id, + switchToAssociatedView: true, + })(dispatch, getState, ...args); + } catch (err) { + dispatch({ type: 'CREATE_GROUP_REJECTED' }); + } + }; +} + function removeAllConversations(): RemoveAllConversationsActionType { return { type: 'CONVERSATIONS_REMOVE_ALL', @@ -761,6 +937,12 @@ function clearChangedMessages( }, }; } +function clearInvitedConversationsForNewlyCreatedGroup(): ClearInvitedConversationsForNewlyCreatedGroupActionType { + return { type: 'CLEAR_INVITED_CONVERSATIONS_FOR_NEWLY_CREATED_GROUP' }; +} +function clearGroupCreationError(): ClearGroupCreationErrorActionType { + return { type: 'CLEAR_GROUP_CREATION_ERROR' }; +} function clearSelectedMessage(): ClearSelectedMessageActionType { return { type: 'CLEAR_SELECTED_MESSAGE', @@ -777,7 +959,15 @@ function clearUnreadMetrics( }, }; } - +function closeCantAddContactToGroupModal(): CloseCantAddContactToGroupModalActionType { + return { type: 'CLOSE_CANT_ADD_CONTACT_TO_GROUP_MODAL' }; +} +function closeMaximumGroupSizeModal(): CloseMaximumGroupSizeModalActionType { + return { type: 'CLOSE_MAXIMUM_GROUP_SIZE_MODAL' }; +} +function closeRecommendedGroupSizeModal(): CloseRecommendedGroupSizeModalActionType { + return { type: 'CLOSE_RECOMMENDED_GROUP_SIZE_MODAL' }; +} function scrollToMessage( conversationId: string, messageId: string @@ -791,6 +981,22 @@ function scrollToMessage( }; } +function setComposeGroupAvatar( + groupAvatar: undefined | ArrayBuffer +): SetComposeGroupAvatarActionType { + return { + type: 'SET_COMPOSE_GROUP_AVATAR', + payload: { groupAvatar }, + }; +} + +function setComposeGroupName(groupName: string): SetComposeGroupNameActionType { + return { + type: 'SET_COMPOSE_GROUP_NAME', + payload: { groupName }, + }; +} + function setComposeSearchTerm( contactSearchTerm: string ): SetComposeSearchTermActionType { @@ -804,6 +1010,10 @@ function startComposing(): StartComposingActionType { return { type: 'START_COMPOSING' }; } +function showChooseGroupMembers(): ShowChooseGroupMembersActionType { + return { type: 'SHOW_CHOOSE_GROUP_MEMBERS' }; +} + function startNewConversationFromPhoneNumber( e164: string ): ThunkAction { @@ -814,6 +1024,37 @@ function startNewConversationFromPhoneNumber( }; } +function startSettingGroupMetadata(): StartSettingGroupMetadataActionType { + return { type: 'START_SETTING_GROUP_METADATA' }; +} + +function toggleConversationInChooseMembers( + conversationId: string +): ThunkAction< + void, + RootStateType, + unknown, + ToggleConversationInChooseMembersActionType +> { + return dispatch => { + const maxRecommendedGroupSize = getGroupSizeRecommendedLimit(151); + const maxGroupSize = Math.max( + getGroupSizeHardLimit(1001), + maxRecommendedGroupSize + 1 + ); + + assert( + maxGroupSize > maxRecommendedGroupSize, + 'Expected the hard max group size to be larger than the recommended maximum' + ); + + dispatch({ + type: 'TOGGLE_CONVERSATION_IN_CHOOSE_MEMBERS', + payload: { conversationId, maxGroupSize, maxRecommendedGroupSize }, + }); + }; +} + // Note: we need two actions here to simplify. Operations outside of the left pane can // trigger an 'openConversation' so we go through Whisper.events for all // conversation selection. Internal just triggers the Whisper.event, and External @@ -1007,10 +1248,94 @@ export function updateConversationLookups( return result; } +function closeComposerModal( + state: Readonly, + modalToClose: 'maximumGroupSizeModalState' | 'recommendedGroupSizeModalState' +): ConversationsStateType { + const { composer } = state; + if (composer?.step !== ComposerStep.ChooseGroupMembers) { + assert(false, "Can't close the modal in this composer step. Doing nothing"); + return state; + } + if (composer[modalToClose] !== OneTimeModalState.Showing) { + return state; + } + return { + ...state, + composer: { + ...composer, + [modalToClose]: OneTimeModalState.Shown, + }, + }; +} + export function reducer( state: Readonly = getEmptyState(), action: Readonly ): ConversationsStateType { + if (action.type === 'CANT_ADD_CONTACT_TO_GROUP') { + const { composer } = state; + if (composer?.step !== ComposerStep.ChooseGroupMembers) { + assert(false, "Can't update modal in this composer step. Doing nothing"); + return state; + } + return { + ...state, + composer: { + ...composer, + cantAddContactIdForModal: action.payload.conversationId, + }, + }; + } + + if (action.type === 'CLEAR_INVITED_CONVERSATIONS_FOR_NEWLY_CREATED_GROUP') { + return omit(state, 'invitedConversationIdsForNewlyCreatedGroup'); + } + + if (action.type === 'CLEAR_GROUP_CREATION_ERROR') { + const { composer } = state; + if (composer?.step !== ComposerStep.SetGroupMetadata) { + assert( + false, + "Can't clear group creation error in this composer state. Doing nothing" + ); + return state; + } + return { + ...state, + composer: { + ...composer, + hasError: false, + }, + }; + } + + if (action.type === 'CLOSE_CANT_ADD_CONTACT_TO_GROUP_MODAL') { + const { composer } = state; + if (composer?.step !== ComposerStep.ChooseGroupMembers) { + assert( + false, + "Can't close the modal in this composer step. Doing nothing" + ); + return state; + } + return { + ...state, + composer: { + ...composer, + cantAddContactIdForModal: undefined, + }, + }; + } + + if (action.type === 'CLOSE_MAXIMUM_GROUP_SIZE_MODAL') { + return closeComposerModal(state, 'maximumGroupSizeModalState' as const); + } + + if (action.type === 'CLOSE_RECOMMENDED_GROUP_SIZE_MODAL') { + return closeComposerModal(state, 'recommendedGroupSizeModalState' as const); + } + if (action.type === 'SET_PRE_JOIN_CONVERSATION') { const { payload } = action; const { data } = payload; @@ -1114,6 +1439,47 @@ export function reducer( if (action.type === 'CONVERSATIONS_REMOVE_ALL') { return getEmptyState(); } + if (action.type === 'CREATE_GROUP_PENDING') { + const { composer } = state; + if (composer?.step !== ComposerStep.SetGroupMetadata) { + // This should be unlikely, but it can happen if someone closes the composer while + // a group is being created. + return state; + } + return { + ...state, + composer: { + ...composer, + hasError: false, + isCreating: true, + }, + }; + } + if (action.type === 'CREATE_GROUP_FULFILLED') { + // We don't do much here and instead rely on `openConversationInternal` to do most of + // the work. + return { + ...state, + invitedConversationIdsForNewlyCreatedGroup: + action.payload.invitedConversationIds, + }; + } + if (action.type === 'CREATE_GROUP_REJECTED') { + const { composer } = state; + if (composer?.step !== ComposerStep.SetGroupMetadata) { + // This should be unlikely, but it can happen if someone closes the composer while + // a group is being created. + return state; + } + return { + ...state, + composer: { + ...composer, + hasError: true, + isCreating: false, + }, + }; + } if (action.type === 'SET_SELECTED_CONVERSATION_PANEL_DEPTH') { return { ...state, @@ -1728,7 +2094,7 @@ export function reducer( } if (action.type === 'START_COMPOSING') { - if (state.composer) { + if (state.composer?.step === ComposerStep.StartDirectConversation) { return state; } @@ -1736,11 +2102,125 @@ export function reducer( ...state, showArchived: false, composer: { + step: ComposerStep.StartDirectConversation, contactSearchTerm: '', }, }; } + if (action.type === 'SHOW_CHOOSE_GROUP_MEMBERS') { + let selectedConversationIds: Array; + let recommendedGroupSizeModalState: OneTimeModalState; + let maximumGroupSizeModalState: OneTimeModalState; + let groupName: string; + let groupAvatar: undefined | ArrayBuffer; + + switch (state.composer?.step) { + case ComposerStep.ChooseGroupMembers: + return state; + case ComposerStep.SetGroupMetadata: + ({ + selectedConversationIds, + recommendedGroupSizeModalState, + maximumGroupSizeModalState, + groupName, + groupAvatar, + } = state.composer); + break; + default: + selectedConversationIds = []; + recommendedGroupSizeModalState = OneTimeModalState.NeverShown; + maximumGroupSizeModalState = OneTimeModalState.NeverShown; + groupName = ''; + break; + } + + return { + ...state, + showArchived: false, + composer: { + step: ComposerStep.ChooseGroupMembers, + contactSearchTerm: '', + selectedConversationIds, + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState, + maximumGroupSizeModalState, + groupName, + groupAvatar, + }, + }; + } + + if (action.type === 'START_SETTING_GROUP_METADATA') { + const { composer } = state; + + switch (composer?.step) { + case ComposerStep.ChooseGroupMembers: + return { + ...state, + showArchived: false, + composer: { + step: ComposerStep.SetGroupMetadata, + isCreating: false, + hasError: false, + ...pick(composer, [ + 'groupAvatar', + 'groupName', + 'maximumGroupSizeModalState', + 'recommendedGroupSizeModalState', + 'selectedConversationIds', + ]), + }, + }; + case ComposerStep.SetGroupMetadata: + return state; + default: + assert( + false, + 'Cannot transition to setting group metadata from this state' + ); + return state; + } + } + + if (action.type === 'SET_COMPOSE_GROUP_AVATAR') { + const { composer } = state; + + switch (composer?.step) { + case ComposerStep.ChooseGroupMembers: + case ComposerStep.SetGroupMetadata: + return { + ...state, + composer: { + ...composer, + groupAvatar: action.payload.groupAvatar, + }, + }; + default: + assert(false, 'Setting compose group avatar at this step is a no-op'); + return state; + } + } + + if (action.type === 'SET_COMPOSE_GROUP_NAME') { + const { composer } = state; + + switch (composer?.step) { + case ComposerStep.ChooseGroupMembers: + case ComposerStep.SetGroupMetadata: + return { + ...state, + composer: { + ...composer, + groupName: action.payload.groupName, + }, + }; + default: + assert(false, 'Setting compose group name at this step is a no-op'); + return state; + } + } + if (action.type === 'SET_COMPOSE_SEARCH_TERM') { const { composer } = state; if (!composer) { @@ -1750,6 +2230,10 @@ export function reducer( ); return state; } + if (composer?.step === ComposerStep.SetGroupMetadata) { + assert(false, 'Setting compose search term at this step is a no-op'); + return state; + } return { ...state, @@ -1774,5 +2258,63 @@ export function reducer( }; } + if (action.type === 'TOGGLE_CONVERSATION_IN_CHOOSE_MEMBERS') { + const { composer } = state; + if (composer?.step !== ComposerStep.ChooseGroupMembers) { + assert( + false, + 'Toggling conversation members is a no-op in this composer step' + ); + return state; + } + + const { selectedConversationIds: oldSelectedConversationIds } = composer; + let { + maximumGroupSizeModalState, + recommendedGroupSizeModalState, + } = composer; + const { + conversationId, + maxGroupSize, + maxRecommendedGroupSize, + } = action.payload; + + const selectedConversationIds = without( + oldSelectedConversationIds, + conversationId + ); + const shouldAdd = + selectedConversationIds.length === oldSelectedConversationIds.length; + if (shouldAdd) { + // 1 for you, 1 for the new contact. + const newExpectedMemberCount = selectedConversationIds.length + 2; + if (newExpectedMemberCount > maxGroupSize) { + return state; + } + if ( + newExpectedMemberCount === maxGroupSize && + maximumGroupSizeModalState === OneTimeModalState.NeverShown + ) { + maximumGroupSizeModalState = OneTimeModalState.Showing; + } else if ( + newExpectedMemberCount >= maxRecommendedGroupSize && + recommendedGroupSizeModalState === OneTimeModalState.NeverShown + ) { + recommendedGroupSizeModalState = OneTimeModalState.Showing; + } + selectedConversationIds.push(conversationId); + } + + return { + ...state, + composer: { + ...composer, + maximumGroupSizeModalState, + recommendedGroupSizeModalState, + selectedConversationIds, + }, + }; + } + return state; } diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index df6971687f..22db86a242 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -8,6 +8,7 @@ import Fuse, { FuseOptions } from 'fuse.js'; import { StateType } from '../reducer'; import { + ComposerStep, ConversationLookupType, ConversationMessageType, ConversationsStateType, @@ -15,10 +16,12 @@ import { MessageLookupType, MessagesByConversationType, MessageType, + OneTimeModalState, PreJoinConversationType, } from '../ducks/conversations'; import { LocalizerType } from '../../types/Util'; import { getOwn } from '../../util/getOwn'; +import { deconstructLookup } from '../../util/deconstructLookup'; import type { CallsByConversationType } from '../ducks/calling'; import { getCallsByConversation } from './calling'; import { getBubbleProps } from '../../shims/Whisper'; @@ -143,9 +146,26 @@ const getComposerState = createSelector( (state: ConversationsStateType) => state.composer ); -export const isComposing = createSelector( +export const getComposerStep = createSelector( getComposerState, - (composerState): boolean => Boolean(composerState) + (composerState): undefined | ComposerStep => composerState?.step +); + +export const hasGroupCreationError = createSelector( + getComposerState, + (composerState): boolean => { + if (composerState?.step === ComposerStep.SetGroupMetadata) { + return composerState.hasError; + } + return false; + } +); + +export const isCreatingGroup = createSelector( + getComposerState, + (composerState): boolean => + composerState?.step === ComposerStep.SetGroupMetadata && + composerState.isCreating ); export const getMessages = createSelector( @@ -273,6 +293,40 @@ export const getLeftPaneLists = createSelector( _getLeftPaneLists ); +export const getMaximumGroupSizeModalState = createSelector( + getComposerState, + (composerState): OneTimeModalState => { + switch (composerState?.step) { + case ComposerStep.ChooseGroupMembers: + case ComposerStep.SetGroupMetadata: + return composerState.maximumGroupSizeModalState; + default: + assert( + false, + 'Can\'t get the maximum group size modal state in this composer state; returning "never shown"' + ); + return OneTimeModalState.NeverShown; + } + } +); + +export const getRecommendedGroupSizeModalState = createSelector( + getComposerState, + (composerState): OneTimeModalState => { + switch (composerState?.step) { + case ComposerStep.ChooseGroupMembers: + case ComposerStep.SetGroupMetadata: + return composerState.recommendedGroupSizeModalState; + default: + assert( + false, + 'Can\'t get the recommended group size modal state in this composer state; returning "never shown"' + ); + return OneTimeModalState.NeverShown; + } + } +); + export const getMe = createSelector( [getConversationLookup, getUserConversationId], ( @@ -290,6 +344,13 @@ export const getComposerContactSearchTerm = createSelector( assert(false, 'getComposerContactSearchTerm: composer is not open'); return ''; } + if (composer.step === ComposerStep.SetGroupMetadata) { + assert( + false, + 'getComposerContactSearchTerm: composer does not have a search term' + ); + return ''; + } return composer.contactSearchTerm; } ); @@ -363,6 +424,102 @@ export const getComposeContacts = createSelector( } ); +/* + * This returns contacts for the composer when you're picking new group members. It casts + * a wider net than `getContacts`. + */ +const getGroupContacts = createSelector( + getConversationLookup, + (conversationLookup): Array => + Object.values(conversationLookup).filter( + contact => + contact.type === 'direct' && + !contact.isMe && + !contact.isBlocked && + !isConversationUnregistered(contact) + ) +); + +export const getCandidateGroupContacts = createSelector( + getNormalizedComposerContactSearchTerm, + getGroupContacts, + (searchTerm, contacts): Array => { + if (searchTerm.length) { + return new Fuse( + contacts, + COMPOSE_CONTACTS_FUSE_OPTIONS + ).search(searchTerm); + } + return contacts.concat().sort((a, b) => collator.compare(a.title, b.title)); + } +); + +export const getCantAddContactForModal = createSelector( + getConversationLookup, + getComposerState, + (conversationLookup, composerState): undefined | ConversationType => { + if (composerState?.step !== ComposerStep.ChooseGroupMembers) { + return undefined; + } + + const conversationId = composerState.cantAddContactIdForModal; + if (!conversationId) { + return undefined; + } + + const result = getOwn(conversationLookup, conversationId); + assert( + result, + 'getCantAddContactForModal: failed to look up conversation by ID; returning undefined' + ); + return result; + } +); + +const getGroupCreationComposerState = createSelector( + getComposerState, + ( + composerState + ): { + groupName: string; + groupAvatar: undefined | ArrayBuffer; + selectedConversationIds: Array; + } => { + switch (composerState?.step) { + case ComposerStep.ChooseGroupMembers: + case ComposerStep.SetGroupMetadata: + return composerState; + default: + assert( + false, + 'getSetGroupMetadataComposerState: expected step to be SetGroupMetadata' + ); + return { + groupName: '', + groupAvatar: undefined, + selectedConversationIds: [], + }; + } + } +); + +export const getComposeGroupAvatar = createSelector( + getGroupCreationComposerState, + (composerState): undefined | ArrayBuffer => composerState.groupAvatar +); + +export const getComposeGroupName = createSelector( + getGroupCreationComposerState, + (composerState): string => composerState.groupName +); + +export const getComposeSelectedContacts = createSelector( + getConversationLookup, + getGroupCreationComposerState, + (conversationLookup, composerState): Array => + deconstructLookup(conversationLookup, composerState.selectedConversationIds) +); + // This is where we will put Conversation selector logic, replicating what // is currently in models/conversation.getProps() // What needs to happen to pull that selector logic here? @@ -666,3 +823,16 @@ export const getConversationMessagesSelector = createSelector( }; } ); + +export const getInvitedContactsForNewlyCreatedGroup = createSelector( + getConversationLookup, + getConversations, + ( + conversationLookup, + { invitedConversationIdsForNewlyCreatedGroup = [] } + ): Array => + deconstructLookup( + conversationLookup, + invitedConversationIdsForNewlyCreatedGroup + ) +); diff --git a/ts/state/smart/LeftPane.tsx b/ts/state/smart/LeftPane.tsx index 7f0ffb5026..28e513a3f9 100644 --- a/ts/state/smart/LeftPane.tsx +++ b/ts/state/smart/LeftPane.tsx @@ -10,17 +10,28 @@ import { PropsType as LeftPanePropsType, } from '../../components/LeftPane'; import { StateType } from '../reducer'; +import { missingCaseError } from '../../util/missingCaseError'; +import { ComposerStep, OneTimeModalState } from '../ducks/conversations'; import { getSearchResults, isSearching } from '../selectors/search'; import { getIntl, getRegionCode } from '../selectors/user'; import { + getCandidateGroupContacts, + getCantAddContactForModal, getComposeContacts, + getComposeGroupAvatar, + getComposeGroupName, + getComposeSelectedContacts, getComposerContactSearchTerm, + getComposerStep, getLeftPaneLists, + getMaximumGroupSizeModalState, + getRecommendedGroupSizeModalState, getSelectedConversationId, getSelectedMessage, getShowArchived, - isComposing, + hasGroupCreationError, + isCreatingGroup, } from '../selectors/conversations'; import { SmartExpiredBuildDialog } from './ExpiredBuildDialog'; @@ -61,34 +72,58 @@ function renderUpdateDialog(): JSX.Element { const getModeSpecificProps = ( state: StateType ): LeftPanePropsType['modeSpecificProps'] => { - if (isComposing(state)) { - return { - mode: LeftPaneMode.Compose, - composeContacts: getComposeContacts(state), - regionCode: getRegionCode(state), - searchTerm: getComposerContactSearchTerm(state), - }; + const composerStep = getComposerStep(state); + switch (composerStep) { + case undefined: + if (getShowArchived(state)) { + const { archivedConversations } = getLeftPaneLists(state); + return { + mode: LeftPaneMode.Archive, + archivedConversations, + }; + } + if (isSearching(state)) { + return { + mode: LeftPaneMode.Search, + ...getSearchResults(state), + }; + } + return { + mode: LeftPaneMode.Inbox, + ...getLeftPaneLists(state), + }; + case ComposerStep.StartDirectConversation: + return { + mode: LeftPaneMode.Compose, + composeContacts: getComposeContacts(state), + regionCode: getRegionCode(state), + searchTerm: getComposerContactSearchTerm(state), + }; + case ComposerStep.ChooseGroupMembers: + return { + mode: LeftPaneMode.ChooseGroupMembers, + candidateContacts: getCandidateGroupContacts(state), + cantAddContactForModal: getCantAddContactForModal(state), + isShowingRecommendedGroupSizeModal: + getRecommendedGroupSizeModalState(state) === + OneTimeModalState.Showing, + isShowingMaximumGroupSizeModal: + getMaximumGroupSizeModalState(state) === OneTimeModalState.Showing, + searchTerm: getComposerContactSearchTerm(state), + selectedContacts: getComposeSelectedContacts(state), + }; + case ComposerStep.SetGroupMetadata: + return { + mode: LeftPaneMode.SetGroupMetadata, + groupAvatar: getComposeGroupAvatar(state), + groupName: getComposeGroupName(state), + hasError: hasGroupCreationError(state), + isCreating: isCreatingGroup(state), + selectedContacts: getComposeSelectedContacts(state), + }; + default: + throw missingCaseError(composerStep); } - - if (getShowArchived(state)) { - const { archivedConversations } = getLeftPaneLists(state); - return { - mode: LeftPaneMode.Archive, - archivedConversations, - }; - } - - if (isSearching(state)) { - return { - mode: LeftPaneMode.Search, - ...getSearchResults(state), - }; - } - - return { - mode: LeftPaneMode.Inbox, - ...getLeftPaneLists(state), - }; }; const mapStateToProps = (state: StateType) => { diff --git a/ts/state/smart/Timeline.tsx b/ts/state/smart/Timeline.tsx index 6f0559e7b1..f1d52833ac 100644 --- a/ts/state/smart/Timeline.tsx +++ b/ts/state/smart/Timeline.tsx @@ -13,6 +13,7 @@ import { getIntl } from '../selectors/user'; import { getConversationMessagesSelector, getConversationSelector, + getInvitedContactsForNewlyCreatedGroup, getSelectedMessage, } from '../selectors/conversations'; @@ -107,6 +108,9 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => { 'isGroupV1AndDisabled', ]), ...conversationMessages, + invitedContactsForNewlyCreatedGroup: getInvitedContactsForNewlyCreatedGroup( + state + ), selectedMessageId: selectedMessage ? selectedMessage.id : undefined, i18n: getIntl(state), renderItem, diff --git a/ts/storage/isFeatureEnabled.ts b/ts/storage/isFeatureEnabled.ts new file mode 100644 index 0000000000..7cc7b12936 --- /dev/null +++ b/ts/storage/isFeatureEnabled.ts @@ -0,0 +1,12 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { isEnabled } from '../RemoteConfig'; + +function isStorageFeatureEnabled(): boolean { + return isEnabled('desktop.storage'); +} + +export function isStorageWriteFeatureEnabled(): boolean { + return isStorageFeatureEnabled() && isEnabled('desktop.storageWrite2'); +} diff --git a/ts/test-both/groups/limits_test.ts b/ts/test-both/groups/limits_test.ts new file mode 100644 index 0000000000..77a79e7049 --- /dev/null +++ b/ts/test-both/groups/limits_test.ts @@ -0,0 +1,67 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import * as sinon from 'sinon'; +import * as remoteConfig from '../../RemoteConfig'; + +import { + getGroupSizeRecommendedLimit, + getGroupSizeHardLimit, +} from '../../groups/limits'; + +describe('group limit utilities', () => { + let sinonSandbox: sinon.SinonSandbox; + let getRecommendedLimitStub: sinon.SinonStub; + let getHardLimitStub: sinon.SinonStub; + + beforeEach(() => { + sinonSandbox = sinon.createSandbox(); + + const getValueStub = sinonSandbox.stub(remoteConfig, 'getValue'); + getRecommendedLimitStub = getValueStub.withArgs( + 'global.groupsv2.maxGroupSize' + ); + getHardLimitStub = getValueStub.withArgs( + 'global.groupsv2.groupSizeHardLimit' + ); + }); + + afterEach(() => { + sinonSandbox.restore(); + }); + + describe('getGroupSizeRecommendedLimit', () => { + it('throws if the value in remote config is not defined', () => { + getRecommendedLimitStub.returns(undefined); + assert.throws(getGroupSizeRecommendedLimit); + }); + + it('throws if the value in remote config is not a parseable integer', () => { + getRecommendedLimitStub.returns('uh oh'); + assert.throws(getGroupSizeRecommendedLimit); + }); + + it('returns the value in remote config, parsed as an integer', () => { + getRecommendedLimitStub.returns('123'); + assert.strictEqual(getGroupSizeRecommendedLimit(), 123); + }); + }); + + describe('getGroupSizeHardLimit', () => { + it('throws if the value in remote config is not defined', () => { + getHardLimitStub.returns(undefined); + assert.throws(getGroupSizeHardLimit); + }); + + it('throws if the value in remote config is not a parseable integer', () => { + getHardLimitStub.returns('uh oh'); + assert.throws(getGroupSizeHardLimit); + }); + + it('returns the value in remote config, parsed as an integer', () => { + getHardLimitStub.returns('123'); + assert.strictEqual(getGroupSizeHardLimit(), 123); + }); + }); +}); diff --git a/ts/test-both/state/selectors/conversations_test.ts b/ts/test-both/state/selectors/conversations_test.ts index 4238107c67..7afa0bcce6 100644 --- a/ts/test-both/state/selectors/conversations_test.ts +++ b/ts/test-both/state/selectors/conversations_test.ts @@ -4,6 +4,8 @@ import { assert } from 'chai'; import { + OneTimeModalState, + ComposerStep, ConversationLookupType, ConversationType, getEmptyState, @@ -11,14 +13,24 @@ import { import { _getConversationComparator, _getLeftPaneLists, + getCandidateGroupContacts, + getCantAddContactForModal, getComposeContacts, + getComposeGroupAvatar, + getComposeGroupName, + getComposeSelectedContacts, getComposerContactSearchTerm, + getComposerStep, getConversationSelector, + getInvitedContactsForNewlyCreatedGroup, getIsConversationEmptySelector, + getMaximumGroupSizeModalState, getPlaceholderContact, + getRecommendedGroupSizeModalState, getSelectedConversation, getSelectedConversationId, - isComposing, + hasGroupCreationError, + isCreatingGroup, } from '../../../state/selectors/conversations'; import { noopAction } from '../../../state/ducks/noop'; import { StateType, reducer as rootReducer } from '../../../state/reducer'; @@ -219,6 +231,32 @@ describe('both/state/selectors/conversations', () => { }); }); + describe('#getInvitedContactsForNewlyCreatedGroup', () => { + it('returns an empty array if there are no invited contacts', () => { + const state = getEmptyRootState(); + + assert.deepEqual(getInvitedContactsForNewlyCreatedGroup(state), []); + }); + + it('returns "hydrated" invited contacts', () => { + const state = { + ...getEmptyRootState(), + conversations: { + ...getEmptyState(), + conversationLookup: { + abc: getDefaultConversation('abc'), + def: getDefaultConversation('def'), + }, + invitedConversationIdsForNewlyCreatedGroup: ['def', 'abc'], + }, + }; + const result = getInvitedContactsForNewlyCreatedGroup(state); + const titles = result.map(conversation => conversation.title); + + assert.deepEqual(titles, ['def title', 'abc title']); + }); + }); + describe('#getIsConversationEmptySelector', () => { it('returns a selector that returns true for conversations that have no messages', () => { const state = { @@ -287,24 +325,196 @@ describe('both/state/selectors/conversations', () => { }); }); - describe('#isComposing', () => { - it('returns false if there is no composer state', () => { - assert.isFalse(isComposing(getEmptyRootState())); + describe('#getComposerStep', () => { + it("returns undefined if the composer isn't open", () => { + const state = getEmptyRootState(); + const result = getComposerStep(state); + + assert.isUndefined(result); }); - it('returns true if there is composer state', () => { - assert.isTrue( - isComposing({ + it('returns the first step of the composer', () => { + const state = { + ...getEmptyRootState(), + conversations: { + ...getEmptyState(), + composer: { + step: ComposerStep.StartDirectConversation as const, + contactSearchTerm: 'foo', + }, + }, + }; + const result = getComposerStep(state); + + assert.strictEqual(result, ComposerStep.StartDirectConversation); + }); + + it('returns the second step of the composer', () => { + const state = { + ...getEmptyRootState(), + conversations: { + ...getEmptyState(), + composer: { + step: ComposerStep.ChooseGroupMembers as const, + contactSearchTerm: 'foo', + selectedConversationIds: ['abc'], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + }, + }, + }; + const result = getComposerStep(state); + + assert.strictEqual(result, ComposerStep.ChooseGroupMembers); + }); + + it('returns the third step of the composer', () => { + const state = { + ...getEmptyRootState(), + conversations: { + ...getEmptyState(), + composer: { + step: ComposerStep.SetGroupMetadata as const, + selectedConversationIds: ['abc'], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + isCreating: false, + hasError: false as const, + }, + }, + }; + const result = getComposerStep(state); + + assert.strictEqual(result, ComposerStep.SetGroupMetadata); + }); + }); + + describe('#hasGroupCreationError', () => { + it('returns false if not in the "set group metadata" composer step', () => { + assert.isFalse(hasGroupCreationError(getEmptyRootState())); + + assert.isFalse( + hasGroupCreationError({ ...getEmptyRootState(), conversations: { ...getEmptyState(), composer: { + step: ComposerStep.StartDirectConversation, contactSearchTerm: '', }, }, }) ); }); + + it('returns false if there is no group creation error', () => { + assert.isFalse( + hasGroupCreationError({ + ...getEmptyRootState(), + conversations: { + ...getEmptyState(), + composer: { + step: ComposerStep.SetGroupMetadata as const, + selectedConversationIds: [], + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + isCreating: false as const, + hasError: false as const, + }, + }, + }) + ); + }); + + it('returns true if there is a group creation error', () => { + assert.isTrue( + hasGroupCreationError({ + ...getEmptyRootState(), + conversations: { + ...getEmptyState(), + composer: { + step: ComposerStep.SetGroupMetadata as const, + selectedConversationIds: [], + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + isCreating: false as const, + hasError: true as const, + }, + }, + }) + ); + }); + }); + + describe('#isCreatingGroup', () => { + it('returns false if not in the "set group metadata" composer step', () => { + assert.isFalse(hasGroupCreationError(getEmptyRootState())); + + assert.isFalse( + isCreatingGroup({ + ...getEmptyRootState(), + conversations: { + ...getEmptyState(), + composer: { + step: ComposerStep.StartDirectConversation, + contactSearchTerm: '', + }, + }, + }) + ); + }); + + it('returns false if the group is not being created', () => { + assert.isFalse( + isCreatingGroup({ + ...getEmptyRootState(), + conversations: { + ...getEmptyState(), + composer: { + step: ComposerStep.SetGroupMetadata as const, + selectedConversationIds: [], + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + isCreating: false as const, + hasError: true as const, + }, + }, + }) + ); + }); + + it('returns true if the group is being created', () => { + assert.isTrue( + isCreatingGroup({ + ...getEmptyRootState(), + conversations: { + ...getEmptyState(), + composer: { + step: ComposerStep.SetGroupMetadata as const, + selectedConversationIds: [], + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + isCreating: true as const, + hasError: false as const, + }, + }, + }) + ); + }); }); describe('#getComposeContacts', () => { @@ -321,6 +531,7 @@ describe('both/state/selectors/conversations', () => { }, }, composer: { + step: ComposerStep.StartDirectConversation, contactSearchTerm, }, }, @@ -413,6 +624,154 @@ describe('both/state/selectors/conversations', () => { }); }); + describe('#getCandidateGroupContacts', () => { + const getRootState = (contactSearchTerm = ''): StateType => { + const rootState = getEmptyRootState(); + return { + ...rootState, + conversations: { + ...getEmptyState(), + conversationLookup: { + 'our-conversation-id': { + ...getDefaultConversation('our-conversation-id'), + isMe: true, + }, + 'convo-1': { + ...getDefaultConversation('convo-1'), + name: 'In System Contacts', + title: 'A. Sorted First', + }, + 'convo-2': { + ...getDefaultConversation('convo-2'), + title: 'B. Sorted Second', + }, + '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'), + title: 'D. Sorted Last', + }, + 'convo-7': { + ...getDefaultConversation('convo-7'), + discoveredUnregisteredAt: Date.now(), + name: 'In System Contacts (and only recently unregistered)', + title: 'C. Sorted Third', + }, + }, + composer: { + step: ComposerStep.ChooseGroupMembers, + contactSearchTerm, + selectedConversationIds: ['abc'], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + }, + }, + user: { + ...rootState.user, + ourConversationId: 'our-conversation-id', + i18n, + }, + }; + }; + + it('returns sorted contacts when there is no search term', () => { + const state = getRootState(); + const result = getCandidateGroupContacts(state); + + const ids = result.map(contact => contact.id); + assert.deepEqual(ids, ['convo-1', 'convo-2', 'convo-7', 'convo-6']); + }); + + it('can search for contacts', () => { + const state = getRootState('system contacts'); + const result = getCandidateGroupContacts(state); + + const ids = result.map(contact => contact.id); + assert.deepEqual(ids, ['convo-1', 'convo-7']); + }); + }); + + describe('#getCantAddContactForModal', () => { + it('returns undefined if not in the "choose group members" composer step', () => { + assert.isUndefined(getCantAddContactForModal(getEmptyRootState())); + + assert.isUndefined( + getCantAddContactForModal({ + ...getEmptyRootState(), + conversations: { + ...getEmptyState(), + composer: { + step: ComposerStep.StartDirectConversation, + contactSearchTerm: '', + }, + }, + }) + ); + }); + + it("returns undefined if there's no contact marked", () => { + assert.isUndefined( + getCantAddContactForModal({ + ...getEmptyRootState(), + conversations: { + ...getEmptyState(), + composer: { + cantAddContactIdForModal: undefined, + contactSearchTerm: '', + groupAvatar: undefined, + groupName: '', + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + selectedConversationIds: [], + step: ComposerStep.ChooseGroupMembers as const, + }, + }, + }) + ); + }); + + it('returns the marked contact', () => { + const conversation = getDefaultConversation('abc123'); + + assert.deepEqual( + getCantAddContactForModal({ + ...getEmptyRootState(), + conversations: { + ...getEmptyState(), + conversationLookup: { abc123: conversation }, + composer: { + cantAddContactIdForModal: 'abc123', + contactSearchTerm: '', + groupAvatar: undefined, + groupName: '', + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + selectedConversationIds: [], + step: ComposerStep.ChooseGroupMembers as const, + }, + }, + }), + conversation + ); + }); + }); + describe('#getComposerContactSearchTerm', () => { it("returns the composer's contact search term", () => { assert.strictEqual( @@ -421,6 +780,7 @@ describe('both/state/selectors/conversations', () => { conversations: { ...getEmptyState(), composer: { + step: ComposerStep.StartDirectConversation, contactSearchTerm: 'foo bar', }, }, @@ -668,6 +1028,163 @@ describe('both/state/selectors/conversations', () => { }); }); + describe('#getMaximumGroupSizeModalState', () => { + it('returns the modal state', () => { + const state = { + ...getEmptyRootState(), + conversations: { + ...getEmptyState(), + composer: { + cantAddContactIdForModal: undefined, + contactSearchTerm: 'to be cleared', + groupAvatar: undefined, + groupName: '', + maximumGroupSizeModalState: OneTimeModalState.Showing, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + selectedConversationIds: [], + step: ComposerStep.ChooseGroupMembers as const, + }, + }, + }; + assert.strictEqual( + getMaximumGroupSizeModalState(state), + OneTimeModalState.Showing + ); + }); + }); + + describe('#getRecommendedGroupSizeModalState', () => { + it('returns the modal state', () => { + const state = { + ...getEmptyRootState(), + conversations: { + ...getEmptyState(), + composer: { + cantAddContactIdForModal: undefined, + contactSearchTerm: 'to be cleared', + groupAvatar: undefined, + groupName: '', + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + recommendedGroupSizeModalState: OneTimeModalState.Showing, + selectedConversationIds: [], + step: ComposerStep.ChooseGroupMembers as const, + }, + }, + }; + assert.strictEqual( + getRecommendedGroupSizeModalState(state), + OneTimeModalState.Showing + ); + }); + }); + + describe('#getComposeGroupAvatar', () => { + it('returns undefined if there is no group avatar', () => { + const state = { + ...getEmptyRootState(), + conversations: { + ...getEmptyState(), + composer: { + step: ComposerStep.SetGroupMetadata as const, + selectedConversationIds: ['abc'], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + isCreating: false, + hasError: false as const, + }, + }, + }; + assert.isUndefined(getComposeGroupAvatar(state)); + }); + + it('returns the group avatar', () => { + const state = { + ...getEmptyRootState(), + conversations: { + ...getEmptyState(), + composer: { + step: ComposerStep.SetGroupMetadata as const, + selectedConversationIds: ['abc'], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: new Uint8Array([1, 2, 3]).buffer, + isCreating: false, + hasError: false as const, + }, + }, + }; + assert.deepEqual( + getComposeGroupAvatar(state), + new Uint8Array([1, 2, 3]).buffer + ); + }); + }); + + describe('#getComposeGroupName', () => { + it('returns the group name', () => { + const state = { + ...getEmptyRootState(), + conversations: { + ...getEmptyState(), + composer: { + step: ComposerStep.SetGroupMetadata as const, + selectedConversationIds: ['abc'], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: 'foo bar', + groupAvatar: undefined, + isCreating: false, + hasError: false as const, + }, + }, + }; + assert.deepEqual(getComposeGroupName(state), 'foo bar'); + }); + }); + + describe('#getComposeSelectedContacts', () => { + it("returns the composer's selected contacts", () => { + const state = { + ...getEmptyRootState(), + conversations: { + ...getEmptyState(), + conversationLookup: { + 'convo-1': { + ...getDefaultConversation('convo-1'), + title: 'Person One', + }, + 'convo-2': { + ...getDefaultConversation('convo-2'), + title: 'Person Two', + }, + }, + composer: { + step: ComposerStep.SetGroupMetadata as const, + selectedConversationIds: ['convo-2', 'convo-1'], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: 'foo bar', + groupAvatar: undefined, + isCreating: false, + hasError: false as const, + }, + }, + }; + + const titles = getComposeSelectedContacts(state).map( + contact => contact.title + ); + assert.deepEqual(titles, ['Person Two', 'Person One']); + }); + }); + describe('#getSelectedConversationId', () => { it('returns undefined if no conversation is selected', () => { const state = { diff --git a/ts/test-both/util/parseIntOrThrow_test.ts b/ts/test-both/util/parseIntOrThrow_test.ts new file mode 100644 index 0000000000..8e2abf4c8d --- /dev/null +++ b/ts/test-both/util/parseIntOrThrow_test.ts @@ -0,0 +1,71 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import { parseIntOrThrow } from '../../util/parseIntOrThrow'; + +describe('parseIntOrThrow', () => { + describe('when passed a number argument', () => { + it('returns the number when passed an integer', () => { + assert.strictEqual(parseIntOrThrow(0, "shouldn't happen"), 0); + assert.strictEqual(parseIntOrThrow(123, "shouldn't happen"), 123); + assert.strictEqual(parseIntOrThrow(-123, "shouldn't happen"), -123); + }); + + it('throws when passed a decimal value', () => { + assert.throws(() => parseIntOrThrow(0.2, 'uh oh'), 'uh oh'); + assert.throws(() => parseIntOrThrow(1.23, 'uh oh'), 'uh oh'); + }); + + it('throws when passed NaN', () => { + assert.throws(() => parseIntOrThrow(NaN, 'uh oh'), 'uh oh'); + }); + + it('throws when passed ∞', () => { + assert.throws(() => parseIntOrThrow(Infinity, 'uh oh'), 'uh oh'); + assert.throws(() => parseIntOrThrow(-Infinity, 'uh oh'), 'uh oh'); + }); + }); + + describe('when passed a string argument', () => { + it('returns the number when passed an integer', () => { + assert.strictEqual(parseIntOrThrow('0', "shouldn't happen"), 0); + assert.strictEqual(parseIntOrThrow('123', "shouldn't happen"), 123); + assert.strictEqual(parseIntOrThrow('-123', "shouldn't happen"), -123); + }); + + it('parses decimal values like parseInt', () => { + assert.strictEqual(parseIntOrThrow('0.2', "shouldn't happen"), 0); + assert.strictEqual(parseIntOrThrow('12.34', "shouldn't happen"), 12); + assert.strictEqual(parseIntOrThrow('-12.34', "shouldn't happen"), -12); + }); + + it('parses values in base 10', () => { + assert.strictEqual(parseIntOrThrow('0x12', "shouldn't happen"), 0); + }); + + it('throws when passed non-parseable strings', () => { + assert.throws(() => parseIntOrThrow('', 'uh oh'), 'uh oh'); + assert.throws(() => parseIntOrThrow('uh 123', 'uh oh'), 'uh oh'); + assert.throws(() => parseIntOrThrow('uh oh', 'uh oh'), 'uh oh'); + }); + }); + + describe('when passed other arguments', () => { + it("throws when passed arguments that aren't strings or numbers", () => { + assert.throws(() => parseIntOrThrow(null, 'uh oh'), 'uh oh'); + assert.throws(() => parseIntOrThrow(undefined, 'uh oh'), 'uh oh'); + assert.throws(() => parseIntOrThrow(['123'], 'uh oh'), 'uh oh'); + }); + + it('throws when passed a stringifiable argument, unlike parseInt', () => { + const obj = { + toString() { + return '123'; + }, + }; + assert.throws(() => parseIntOrThrow(obj, 'uh oh'), 'uh oh'); + }); + }); +}); diff --git a/ts/test-both/util/parseIntWithFallback_test.ts b/ts/test-both/util/parseIntWithFallback_test.ts new file mode 100644 index 0000000000..f5e68f911e --- /dev/null +++ b/ts/test-both/util/parseIntWithFallback_test.ts @@ -0,0 +1,71 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import { parseIntWithFallback } from '../../util/parseIntWithFallback'; + +describe('parseIntWithFallback', () => { + describe('when passed a number argument', () => { + it('returns the number when passed an integer', () => { + assert.strictEqual(parseIntWithFallback(0, -1), 0); + assert.strictEqual(parseIntWithFallback(123, -1), 123); + assert.strictEqual(parseIntWithFallback(-123, -1), -123); + }); + + it('returns the fallback when passed a decimal value', () => { + assert.strictEqual(parseIntWithFallback(0.2, -1), -1); + assert.strictEqual(parseIntWithFallback(1.23, -1), -1); + }); + + it('returns the fallback when passed NaN', () => { + assert.strictEqual(parseIntWithFallback(NaN, -1), -1); + }); + + it('returns the fallback when passed ∞', () => { + assert.strictEqual(parseIntWithFallback(Infinity, -1), -1); + assert.strictEqual(parseIntWithFallback(-Infinity, -1), -1); + }); + }); + + describe('when passed a string argument', () => { + it('returns the number when passed an integer', () => { + assert.strictEqual(parseIntWithFallback('0', -1), 0); + assert.strictEqual(parseIntWithFallback('123', -1), 123); + assert.strictEqual(parseIntWithFallback('-123', -1), -123); + }); + + it('parses decimal values like parseInt', () => { + assert.strictEqual(parseIntWithFallback('0.2', -1), 0); + assert.strictEqual(parseIntWithFallback('12.34', -1), 12); + assert.strictEqual(parseIntWithFallback('-12.34', -1), -12); + }); + + it('parses values in base 10', () => { + assert.strictEqual(parseIntWithFallback('0x12', -1), 0); + }); + + it('returns the fallback when passed non-parseable strings', () => { + assert.strictEqual(parseIntWithFallback('', -1), -1); + assert.strictEqual(parseIntWithFallback('uh 123', -1), -1); + assert.strictEqual(parseIntWithFallback('uh oh', -1), -1); + }); + }); + + describe('when passed other arguments', () => { + it("returns the fallback when passed arguments that aren't strings or numbers", () => { + assert.strictEqual(parseIntWithFallback(null, -1), -1); + assert.strictEqual(parseIntWithFallback(undefined, -1), -1); + assert.strictEqual(parseIntWithFallback(['123'], -1), -1); + }); + + it('returns the fallback when passed a stringifiable argument, unlike parseInt', () => { + const obj = { + toString() { + return '123'; + }, + }; + assert.strictEqual(parseIntWithFallback(obj, -1), -1); + }); + }); +}); diff --git a/ts/test-electron/state/ducks/conversations_test.ts b/ts/test-electron/state/ducks/conversations_test.ts index 56e163253b..e94f93b005 100644 --- a/ts/test-electron/state/ducks/conversations_test.ts +++ b/ts/test-electron/state/ducks/conversations_test.ts @@ -3,42 +3,65 @@ import { assert } from 'chai'; import * as sinon from 'sinon'; +import { v4 as uuid } from 'uuid'; +import { times } from 'lodash'; import { set } from 'lodash/fp'; import { reducer as rootReducer } from '../../../state/reducer'; import { noopAction } from '../../../state/ducks/noop'; import { actions, + OneTimeModalState, + ComposerStep, ConversationMessageType, - ConversationsStateType, ConversationType, + ConversationsStateType, + MessageType, + SwitchToAssociatedViewActionType, + ToggleConversationInChooseMembersActionType, getConversationCallMode, getEmptyState, - MessageType, reducer, updateConversationLookups, - SwitchToAssociatedViewActionType, } from '../../../state/ducks/conversations'; import { CallMode } from '../../../types/Calling'; +import * as groups from '../../../groups'; const { + cantAddContactToGroup, + clearGroupCreationError, + clearInvitedConversationsForNewlyCreatedGroup, + closeCantAddContactToGroupModal, + closeMaximumGroupSizeModal, + closeRecommendedGroupSizeModal, + createGroup, messageSizeChanged, openConversationInternal, repairNewestMessage, repairOldestMessage, + setComposeGroupAvatar, + setComposeGroupName, setComposeSearchTerm, setPreJoinConversation, showArchivedConversations, showInbox, startComposing, + showChooseGroupMembers, + 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 = sinonSandbox.stub(groups, 'createGroupV2'); }); afterEach(() => { @@ -317,10 +340,6 @@ describe('both/state/ducks/conversations', () => { } describe('openConversationInternal', () => { - beforeEach(() => { - sinonSandbox.stub(window.Whisper.events, 'trigger'); - }); - it("returns a thunk that triggers a 'showConversation' event when passed a conversation ID", () => { const dispatch = sinon.spy(); @@ -442,6 +461,390 @@ describe('both/state/ducks/conversations', () => { }); }); + describe('CANT_ADD_CONTACT_TO_GROUP', () => { + it('marks the conversation ID as "cannot add"', () => { + const state = { + ...getEmptyState(), + composer: { + cantAddContactIdForModal: undefined, + contactSearchTerm: '', + groupAvatar: undefined, + groupName: '', + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + selectedConversationIds: [], + step: ComposerStep.ChooseGroupMembers as const, + }, + }; + const action = cantAddContactToGroup('abc123'); + const result = reducer(state, action); + + assert( + result.composer?.step === ComposerStep.ChooseGroupMembers && + result.composer.cantAddContactIdForModal === 'abc123' + ); + }); + }); + + describe('CLEAR_GROUP_CREATION_ERROR', () => { + it('clears the group creation error', () => { + const state = { + ...getEmptyState(), + composer: { + step: ComposerStep.SetGroupMetadata as const, + selectedConversationIds: [], + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + isCreating: false as const, + 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_CONVERSATIONS_FOR_NEWLY_CREATED_GROUP', () => { + it('clears the list of invited conversation IDs', () => { + const state = { + ...getEmptyState(), + invitedConversationIdsForNewlyCreatedGroup: ['abc123', 'def456'], + }; + const action = clearInvitedConversationsForNewlyCreatedGroup(); + const result = reducer(state, action); + + assert.isUndefined(result.invitedConversationIdsForNewlyCreatedGroup); + }); + }); + + describe('CLOSE_CANT_ADD_CONTACT_TO_GROUP_MODAL', () => { + it('closes the "cannot add contact" modal"', () => { + const state = { + ...getEmptyState(), + composer: { + cantAddContactIdForModal: 'abc123', + contactSearchTerm: '', + groupAvatar: undefined, + groupName: '', + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + selectedConversationIds: [], + step: ComposerStep.ChooseGroupMembers as const, + }, + }; + const action = closeCantAddContactToGroupModal(); + const result = reducer(state, action); + + assert( + result.composer?.step === ComposerStep.ChooseGroupMembers && + result.composer.cantAddContactIdForModal === undefined, + 'Expected the contact ID to be cleared' + ); + }); + }); + + describe('CLOSE_MAXIMUM_GROUP_SIZE_MODAL', () => { + it('closes the maximum group size modal if it was open', () => { + const state = { + ...getEmptyState(), + composer: { + cantAddContactIdForModal: 'abc123', + contactSearchTerm: '', + groupAvatar: undefined, + groupName: '', + maximumGroupSizeModalState: OneTimeModalState.Showing, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + selectedConversationIds: [], + step: ComposerStep.ChooseGroupMembers as const, + }, + }; + 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: { + cantAddContactIdForModal: 'abc123', + contactSearchTerm: '', + groupAvatar: undefined, + groupName: '', + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + selectedConversationIds: [], + step: ComposerStep.ChooseGroupMembers as const, + }, + }; + 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: { + cantAddContactIdForModal: 'abc123', + contactSearchTerm: '', + groupAvatar: undefined, + groupName: '', + maximumGroupSizeModalState: OneTimeModalState.Shown, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + selectedConversationIds: [], + step: ComposerStep.ChooseGroupMembers as const, + }, + }; + 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: { + cantAddContactIdForModal: 'abc123', + contactSearchTerm: '', + groupAvatar: undefined, + groupName: '', + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + recommendedGroupSizeModalState: OneTimeModalState.Showing, + selectedConversationIds: [], + step: ComposerStep.ChooseGroupMembers as const, + }, + }; + 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: { + cantAddContactIdForModal: 'abc123', + contactSearchTerm: '', + groupAvatar: undefined, + groupName: '', + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + selectedConversationIds: [], + step: ComposerStep.ChooseGroupMembers as const, + }, + }; + 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: { + cantAddContactIdForModal: 'abc123', + contactSearchTerm: '', + groupAvatar: undefined, + groupName: '', + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + recommendedGroupSizeModalState: OneTimeModalState.Shown, + selectedConversationIds: [], + step: ComposerStep.ChooseGroupMembers as const, + }, + }; + const action = closeRecommendedGroupSizeModal(); + const result = reducer(state, action); + + assert.strictEqual(result, state); + }); + }); + + describe('createGroup', () => { + const conversationsState = { + ...getEmptyState(), + composer: { + step: ComposerStep.SetGroupMetadata as const, + selectedConversationIds: ['abc123'], + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: 'Foo Bar Group', + groupAvatar: new Uint8Array([1, 2, 3]).buffer, + isCreating: false as const, + hasError: true as const, + }, + }; + + 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()( + sinon.spy(), + () => ({ + ...getEmptyRootState(), + conversations: conversationsState, + }), + null + ); + + sinon.assert.calledOnce(createGroupStub); + sinon.assert.calledWith(createGroupStub, { + name: 'Foo Bar Group', + avatar: new Uint8Array([1, 2, 3]).buffer, + conversationIds: ['abc123'], + }); + }); + + 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()( + 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()( + 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 () => { + createGroupStub.resolves({ + id: '9876', + get: (key: string) => { + if (key !== 'pendingMembersV2') { + throw new Error('This getter is not set up for this test'); + } + return [{ conversationId: 'xyz999' }]; + }, + }); + + const dispatch = sinon.spy(); + + await createGroup()( + dispatch, + () => ({ + ...getEmptyRootState(), + conversations: conversationsState, + }), + null + ); + + sinon.assert.calledWith( + window.Whisper.events.trigger as sinon.SinonSpy, + 'showConversation', + '9876', + undefined + ); + + sinon.assert.calledWith(dispatch, { + type: 'CREATE_GROUP_FULFILLED', + payload: { invitedConversationIds: ['xyz999'] }, + }); + + const fulfilledAction = dispatch.getCall(1).args[0]; + const result = reducer(conversationsState, fulfilledAction); + assert.deepEqual(result.invitedConversationIdsForNewlyCreatedGroup, [ + 'xyz999', + ]); + + sinon.assert.calledWith(dispatch, { + type: 'SWITCH_TO_ASSOCIATED_VIEW', + payload: { conversationId: '9876' }, + }); + }); + }); + describe('MESSAGE_SIZE_CHANGED', () => { const stateWithActiveConversation = { ...getEmptyState(), @@ -726,18 +1129,97 @@ describe('both/state/ducks/conversations', () => { }); }); + describe('SET_COMPOSE_GROUP_AVATAR', () => { + it("can clear the composer's group avatar", () => { + const state = { + ...getEmptyState(), + composer: { + step: ComposerStep.SetGroupMetadata as const, + selectedConversationIds: [], + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: 'foo', + groupAvatar: new ArrayBuffer(2), + isCreating: false as const, + hasError: false as const, + }, + }; + 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]).buffer; + + const state = { + ...getEmptyState(), + composer: { + step: ComposerStep.SetGroupMetadata as const, + selectedConversationIds: [], + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: 'foo', + groupAvatar: undefined, + isCreating: false as const, + hasError: false as const, + }, + }; + 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: { + step: ComposerStep.SetGroupMetadata as const, + selectedConversationIds: [], + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + isCreating: false as const, + hasError: false as const, + }, + }; + 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: { + step: ComposerStep.StartDirectConversation as const, contactSearchTerm: '', }, }; const action = setComposeSearchTerm('foo bar'); const result = reducer(state, action); - assert.strictEqual(result.composer?.contactSearchTerm, 'foo bar'); + assert.deepEqual(result.composer, { + step: ComposerStep.StartDirectConversation, + contactSearchTerm: 'foo bar', + }); }); }); @@ -801,6 +1283,7 @@ describe('both/state/ducks/conversations', () => { const state = { ...getEmptyState(), composer: { + step: ComposerStep.StartDirectConversation as const, contactSearchTerm: '', }, }; @@ -838,6 +1321,7 @@ describe('both/state/ducks/conversations', () => { const state = { ...getEmptyState(), composer: { + step: ComposerStep.StartDirectConversation as const, contactSearchTerm: '', }, }; @@ -850,10 +1334,11 @@ describe('both/state/ducks/conversations', () => { }); describe('START_COMPOSING', () => { - it('if already at the composer, does nothing', () => { + it('does nothing if on the first step of the composer', () => { const state = { ...getEmptyState(), composer: { + step: ComposerStep.StartDirectConversation as const, contactSearchTerm: 'foo bar', }, }; @@ -861,7 +1346,58 @@ describe('both/state/ducks/conversations', () => { const result = reducer(state, action); assert.isFalse(result.showArchived); - assert.deepEqual(result.composer, { contactSearchTerm: 'foo bar' }); + assert.deepEqual(result.composer, { + step: ComposerStep.StartDirectConversation, + contactSearchTerm: 'foo bar', + }); + }); + + it('if on the second step of the composer, goes back to the first step, clearing the search term', () => { + const state = { + ...getEmptyState(), + composer: { + cantAddContactIdForModal: undefined, + contactSearchTerm: 'to be cleared', + groupAvatar: undefined, + groupName: '', + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + selectedConversationIds: [], + step: ComposerStep.ChooseGroupMembers as const, + }, + }; + const action = startComposing(); + const result = reducer(state, action); + + assert.isFalse(result.showArchived); + assert.deepEqual(result.composer, { + step: ComposerStep.StartDirectConversation, + contactSearchTerm: '', + }); + }); + + it('if on the third step of the composer, goes back to the first step, clearing everything', () => { + const state = { + ...getEmptyState(), + composer: { + step: ComposerStep.SetGroupMetadata as const, + selectedConversationIds: [], + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + isCreating: false, + hasError: false as const, + }, + }; + const action = startComposing(); + const result = reducer(state, action); + + assert.isFalse(result.showArchived); + assert.deepEqual(result.composer, { + step: ComposerStep.StartDirectConversation, + contactSearchTerm: '', + }); }); it('switches from the inbox to the composer', () => { @@ -870,7 +1406,10 @@ describe('both/state/ducks/conversations', () => { const result = reducer(state, action); assert.isFalse(result.showArchived); - assert.deepEqual(result.composer, { contactSearchTerm: '' }); + assert.deepEqual(result.composer, { + step: ComposerStep.StartDirectConversation, + contactSearchTerm: '', + }); }); it('switches from the archive to the inbox', () => { @@ -882,7 +1421,520 @@ describe('both/state/ducks/conversations', () => { const result = reducer(state, action); assert.isFalse(result.showArchived); - assert.deepEqual(result.composer, { contactSearchTerm: '' }); + assert.deepEqual(result.composer, { + step: ComposerStep.StartDirectConversation, + contactSearchTerm: '', + }); + }); + }); + + describe('SHOW_CHOOSE_GROUP_MEMBERS', () => { + it('switches to the second step of the composer if on the first step', () => { + const state = { + ...getEmptyState(), + composer: { + step: ComposerStep.StartDirectConversation as const, + contactSearchTerm: 'to be cleared', + }, + }; + const action = showChooseGroupMembers(); + const result = reducer(state, action); + + assert.isFalse(result.showArchived); + assert.deepEqual(result.composer, { + step: ComposerStep.ChooseGroupMembers, + contactSearchTerm: '', + selectedConversationIds: [], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + }); + }); + + it('does nothing if already on the second step of the composer', () => { + const state = { + ...getEmptyState(), + composer: { + step: ComposerStep.ChooseGroupMembers as const, + contactSearchTerm: 'foo bar', + selectedConversationIds: [], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + }, + }; + 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: { + step: ComposerStep.SetGroupMetadata as const, + selectedConversationIds: [], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: 'Foo Bar Group', + groupAvatar: new Uint8Array([4, 2]).buffer, + isCreating: false, + hasError: false as const, + }, + }; + const action = showChooseGroupMembers(); + const result = reducer(state, action); + + assert.isFalse(result.showArchived); + assert.deepEqual(result.composer, { + step: ComposerStep.ChooseGroupMembers, + contactSearchTerm: '', + selectedConversationIds: [], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: 'Foo Bar Group', + groupAvatar: new Uint8Array([4, 2]).buffer, + }); + }); + + 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, { + step: ComposerStep.ChooseGroupMembers, + contactSearchTerm: '', + selectedConversationIds: [], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + }); + }); + + 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, { + step: ComposerStep.ChooseGroupMembers, + contactSearchTerm: '', + selectedConversationIds: [], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + }); + }); + }); + + describe('START_SETTING_GROUP_METADATA', () => { + it('moves from the second to the third step of the composer', () => { + const state = { + ...getEmptyState(), + composer: { + step: ComposerStep.ChooseGroupMembers as const, + contactSearchTerm: 'foo bar', + selectedConversationIds: ['abc', 'def'], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + }, + }; + const action = startSettingGroupMetadata(); + const result = reducer(state, action); + + assert.deepEqual(result.composer, { + step: ComposerStep.SetGroupMetadata, + selectedConversationIds: ['abc', 'def'], + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + isCreating: false, + hasError: false, + }); + }); + + 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: { + step: ComposerStep.ChooseGroupMembers as const, + contactSearchTerm: 'foo bar', + selectedConversationIds: ['abc', 'def'], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: 'Foo Bar Group', + groupAvatar: new Uint8Array([6, 9]).buffer, + }, + }; + const action = startSettingGroupMetadata(); + const result = reducer(state, action); + + assert.deepEqual(result.composer, { + step: ComposerStep.SetGroupMetadata, + selectedConversationIds: ['abc', 'def'], + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: 'Foo Bar Group', + groupAvatar: new Uint8Array([6, 9]).buffer, + isCreating: false, + hasError: false as const, + }); + }); + + it('does nothing if already on the third step of the composer', () => { + const state = { + ...getEmptyState(), + composer: { + step: ComposerStep.SetGroupMetadata as const, + selectedConversationIds: [], + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: 'Foo Bar Group', + groupAvatar: new Uint8Array([4, 2]).buffer, + isCreating: false, + hasError: false as const, + }, + }; + 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]; + } + + let remoteConfigGetValueStub: sinon.SinonStub; + + beforeEach(() => { + remoteConfigGetValueStub = sinonSandbox + .stub(window.Signal.RemoteConfig, 'getValue') + .withArgs('global.groupsv2.maxGroupSize') + .returns('22') + .withArgs('global.groupsv2.groupSizeHardLimit') + .returns('33'); + }); + + it('adds conversation IDs to the list', () => { + const zero = { + ...getEmptyState(), + composer: { + step: ComposerStep.ChooseGroupMembers as const, + contactSearchTerm: '', + selectedConversationIds: [], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + }, + }; + const one = reducer(zero, getAction('abc', zero)); + const two = reducer(one, getAction('def', one)); + + assert.deepEqual(two.composer, { + step: ComposerStep.ChooseGroupMembers, + contactSearchTerm: '', + selectedConversationIds: ['abc', 'def'], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + }); + }); + + it('removes conversation IDs from the list', () => { + const state = { + ...getEmptyState(), + composer: { + step: ComposerStep.ChooseGroupMembers as const, + contactSearchTerm: '', + selectedConversationIds: ['abc', 'def'], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + }, + }; + const action = getAction('abc', state); + const result = reducer(state, action); + + assert.deepEqual(result.composer, { + step: ComposerStep.ChooseGroupMembers, + contactSearchTerm: '', + selectedConversationIds: ['def'], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + }); + }); + + 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: { + step: ComposerStep.ChooseGroupMembers as const, + contactSearchTerm: '', + selectedConversationIds: oldSelectedConversationIds, + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + }, + }; + const action = getAction(newUuid, state); + const result = reducer(state, action); + + assert.deepEqual(result.composer, { + step: ComposerStep.ChooseGroupMembers, + contactSearchTerm: '', + selectedConversationIds: [...oldSelectedConversationIds, newUuid], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.Showing, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + }); + }); + + it("doesn't show the recommended group size modal twice", () => { + const oldSelectedConversationIds = times(21, () => uuid()); + const newUuid = uuid(); + + const state = { + ...getEmptyState(), + composer: { + step: ComposerStep.ChooseGroupMembers as const, + contactSearchTerm: '', + selectedConversationIds: oldSelectedConversationIds, + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.Shown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + }, + }; + const action = getAction(newUuid, state); + const result = reducer(state, action); + + assert.deepEqual(result.composer, { + step: ComposerStep.ChooseGroupMembers, + contactSearchTerm: '', + selectedConversationIds: [...oldSelectedConversationIds, newUuid], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.Shown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + }); + }); + + it('defaults the maximum recommended size to 151', () => { + [undefined, 'xyz'].forEach(value => { + remoteConfigGetValueStub + .withArgs('global.groupsv2.maxGroupSize') + .returns(value); + + const state = { + ...getEmptyState(), + composer: { + step: ComposerStep.ChooseGroupMembers as const, + contactSearchTerm: '', + selectedConversationIds: [], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + }, + }; + 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: { + step: ComposerStep.ChooseGroupMembers as const, + contactSearchTerm: '', + selectedConversationIds: oldSelectedConversationIds, + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.Shown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + }, + }; + const action = getAction(newUuid, state); + const result = reducer(state, action); + + assert.deepEqual(result.composer, { + step: ComposerStep.ChooseGroupMembers, + contactSearchTerm: '', + selectedConversationIds: [...oldSelectedConversationIds, newUuid], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.Shown, + maximumGroupSizeModalState: OneTimeModalState.Showing, + groupName: '', + groupAvatar: undefined, + }); + }); + + it("doesn't show the maximum group size modal twice", () => { + const oldSelectedConversationIds = times(31, () => uuid()); + const newUuid = uuid(); + + const state = { + ...getEmptyState(), + composer: { + step: ComposerStep.ChooseGroupMembers as const, + contactSearchTerm: '', + selectedConversationIds: oldSelectedConversationIds, + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.Shown, + maximumGroupSizeModalState: OneTimeModalState.Shown, + groupName: '', + groupAvatar: undefined, + }, + }; + const action = getAction(newUuid, state); + const result = reducer(state, action); + + assert.deepEqual(result.composer, { + step: ComposerStep.ChooseGroupMembers, + contactSearchTerm: '', + selectedConversationIds: [...oldSelectedConversationIds, newUuid], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.Shown, + maximumGroupSizeModalState: OneTimeModalState.Shown, + groupName: '', + groupAvatar: undefined, + }); + }); + + it('cannot select more than the maximum number of conversations', () => { + const state = { + ...getEmptyState(), + composer: { + step: ComposerStep.ChooseGroupMembers as const, + contactSearchTerm: '', + selectedConversationIds: times(1000, () => uuid()), + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + }, + }; + const action = getAction(uuid(), state); + const result = reducer(state, action); + + assert.strictEqual(result, state); + }); + + it('defaults the maximum group size to 1001 if the recommended maximum is smaller', () => { + [undefined, 'xyz'].forEach(value => { + remoteConfigGetValueStub + .withArgs('global.groupsv2.maxGroupSize') + .returns('2') + .withArgs('global.groupsv2.groupSizeHardLimit') + .returns(value); + + const state = { + ...getEmptyState(), + composer: { + step: ComposerStep.ChooseGroupMembers as const, + contactSearchTerm: '', + selectedConversationIds: [], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + }, + }; + 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', () => { + remoteConfigGetValueStub + .withArgs('global.groupsv2.maxGroupSize') + .returns('1234') + .withArgs('global.groupsv2.groupSizeHardLimit') + .returns('2'); + + const state = { + ...getEmptyState(), + composer: { + step: ComposerStep.ChooseGroupMembers as const, + contactSearchTerm: '', + selectedConversationIds: [], + cantAddContactIdForModal: undefined, + recommendedGroupSizeModalState: OneTimeModalState.NeverShown, + maximumGroupSizeModalState: OneTimeModalState.NeverShown, + groupName: '', + groupAvatar: undefined, + }, + }; + const action = getAction(uuid(), state); + + assert.strictEqual(action.payload.maxGroupSize, 1235); }); }); }); diff --git a/ts/test-node/components/leftPane/LeftPaneChooseGroupMembersHelper_test.ts b/ts/test-node/components/leftPane/LeftPaneChooseGroupMembersHelper_test.ts new file mode 100644 index 0000000000..16b20b23e0 --- /dev/null +++ b/ts/test-node/components/leftPane/LeftPaneChooseGroupMembersHelper_test.ts @@ -0,0 +1,196 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import * as sinon from 'sinon'; +import { times } from 'lodash'; +import { v4 as uuid } from 'uuid'; +import { RowType } from '../../../components/ConversationList'; +import * as remoteConfig from '../../../RemoteConfig'; +import { ContactCheckboxDisabledReason } from '../../../components/conversationList/ContactCheckbox'; + +import { LeftPaneChooseGroupMembersHelper } from '../../../components/leftPane/LeftPaneChooseGroupMembersHelper'; + +describe('LeftPaneChooseGroupMembersHelper', () => { + const defaults = { + candidateContacts: [], + cantAddContactForModal: undefined, + isShowingRecommendedGroupSizeModal: false, + isShowingMaximumGroupSizeModal: false, + searchTerm: '', + selectedContacts: [], + }; + + const fakeContact = () => ({ + id: uuid(), + isGroupV2Capable: true, + title: uuid(), + type: 'direct' as const, + }); + + let sinonSandbox: sinon.SinonSandbox; + + beforeEach(() => { + sinonSandbox = sinon.createSandbox(); + + sinonSandbox + .stub(remoteConfig, 'getValue') + .withArgs('global.groupsv2.maxGroupSize') + .returns('22') + .withArgs('global.groupsv2.groupSizeHardLimit') + .returns('33'); + }); + + afterEach(() => { + sinonSandbox.restore(); + }); + + describe('getRowCount', () => { + it('returns 0 if there are no contacts', () => { + assert.strictEqual( + new LeftPaneChooseGroupMembersHelper({ + ...defaults, + candidateContacts: [], + searchTerm: '', + selectedContacts: [fakeContact()], + }).getRowCount(), + 0 + ); + assert.strictEqual( + new LeftPaneChooseGroupMembersHelper({ + ...defaults, + candidateContacts: [], + searchTerm: 'foo bar', + selectedContacts: [fakeContact()], + }).getRowCount(), + 0 + ); + }); + + it('returns the number of candidate contacts + 2 if there are any', () => { + assert.strictEqual( + new LeftPaneChooseGroupMembersHelper({ + ...defaults, + candidateContacts: [fakeContact(), fakeContact()], + searchTerm: '', + selectedContacts: [fakeContact()], + }).getRowCount(), + 4 + ); + }); + }); + + describe('getRow', () => { + it('returns undefined if there are no contacts', () => { + assert.isUndefined( + new LeftPaneChooseGroupMembersHelper({ + ...defaults, + candidateContacts: [], + searchTerm: '', + selectedContacts: [fakeContact()], + }).getRow(0) + ); + assert.isUndefined( + new LeftPaneChooseGroupMembersHelper({ + ...defaults, + candidateContacts: [], + searchTerm: '', + selectedContacts: [fakeContact()], + }).getRow(99) + ); + assert.isUndefined( + new LeftPaneChooseGroupMembersHelper({ + ...defaults, + candidateContacts: [], + searchTerm: 'foo bar', + selectedContacts: [fakeContact()], + }).getRow(0) + ); + }); + + it('returns a header, then the contacts, then a blank space if there are contacts', () => { + const candidateContacts = [fakeContact(), fakeContact()]; + const helper = new LeftPaneChooseGroupMembersHelper({ + ...defaults, + candidateContacts, + searchTerm: 'foo bar', + selectedContacts: [candidateContacts[1]], + }); + + assert.deepEqual(helper.getRow(0), { + type: RowType.Header, + i18nKey: 'contactsHeader', + }); + assert.deepEqual(helper.getRow(1), { + type: RowType.ContactCheckbox, + contact: candidateContacts[0], + isChecked: false, + disabledReason: undefined, + }); + assert.deepEqual(helper.getRow(2), { + type: RowType.ContactCheckbox, + contact: candidateContacts[1], + isChecked: true, + disabledReason: undefined, + }); + assert.deepEqual(helper.getRow(3), { type: RowType.Blank }); + }); + + it("disables non-selected contact checkboxes if you've selected the maximum number of contacts", () => { + const candidateContacts = times(50, () => fakeContact()); + const helper = new LeftPaneChooseGroupMembersHelper({ + ...defaults, + candidateContacts, + searchTerm: 'foo bar', + selectedContacts: candidateContacts.slice(1, 33), + }); + + assert.deepEqual(helper.getRow(1), { + type: RowType.ContactCheckbox, + contact: candidateContacts[0], + isChecked: false, + disabledReason: ContactCheckboxDisabledReason.MaximumContactsSelected, + }); + assert.deepEqual(helper.getRow(2), { + type: RowType.ContactCheckbox, + contact: candidateContacts[1], + isChecked: true, + disabledReason: undefined, + }); + }); + + it("disables contacts that aren't GV2-capable, unless they are already selected somehow", () => { + const candidateContacts = [ + { ...fakeContact(), isGroupV2Capable: false }, + { ...fakeContact(), isGroupV2Capable: undefined }, + { ...fakeContact(), isGroupV2Capable: false }, + ]; + + const helper = new LeftPaneChooseGroupMembersHelper({ + ...defaults, + candidateContacts, + searchTerm: 'foo bar', + selectedContacts: [candidateContacts[2]], + }); + + assert.deepEqual(helper.getRow(1), { + type: RowType.ContactCheckbox, + contact: candidateContacts[0], + isChecked: false, + disabledReason: ContactCheckboxDisabledReason.NotCapable, + }); + assert.deepEqual(helper.getRow(2), { + type: RowType.ContactCheckbox, + contact: candidateContacts[1], + isChecked: false, + disabledReason: ContactCheckboxDisabledReason.NotCapable, + }); + assert.deepEqual(helper.getRow(3), { + type: RowType.ContactCheckbox, + contact: candidateContacts[2], + isChecked: true, + disabledReason: undefined, + }); + }); + }); +}); diff --git a/ts/test-node/components/leftPane/LeftPaneComposeHelper_test.ts b/ts/test-node/components/leftPane/LeftPaneComposeHelper_test.ts index 9967ac8453..a17d3f9305 100644 --- a/ts/test-node/components/leftPane/LeftPaneComposeHelper_test.ts +++ b/ts/test-node/components/leftPane/LeftPaneComposeHelper_test.ts @@ -2,9 +2,11 @@ // SPDX-License-Identifier: AGPL-3.0-only import { assert } from 'chai'; +import * as sinon from 'sinon'; import { v4 as uuid } from 'uuid'; import { RowType } from '../../../components/ConversationList'; import { FindDirection } from '../../../components/leftPane/LeftPaneHelper'; +import * as remoteConfig from '../../../RemoteConfig'; import { LeftPaneComposeHelper } from '../../../components/leftPane/LeftPaneComposeHelper'; @@ -15,8 +17,48 @@ describe('LeftPaneComposeHelper', () => { type: 'direct' as const, }); + let sinonSandbox: sinon.SinonSandbox; + let remoteConfigStub: sinon.SinonStub; + + beforeEach(() => { + sinonSandbox = sinon.createSandbox(); + + remoteConfigStub = sinonSandbox + .stub(remoteConfig, 'isEnabled') + .withArgs('desktop.storage') + .returns(true) + .withArgs('desktop.storageWrite2') + .returns(true); + }); + + afterEach(() => { + sinonSandbox.restore(); + }); + describe('getRowCount', () => { - it('returns the number of contacts if not searching for a phone number', () => { + it('returns 1 (for the "new group" button) if not searching and there are no contacts', () => { + assert.strictEqual( + new LeftPaneComposeHelper({ + composeContacts: [], + regionCode: 'US', + searchTerm: '', + }).getRowCount(), + 1 + ); + }); + + it('returns the number of contacts + 2 (for the "new group" button and header) if not searching', () => { + assert.strictEqual( + new LeftPaneComposeHelper({ + composeContacts: [fakeContact(), fakeContact()], + regionCode: 'US', + searchTerm: '', + }).getRowCount(), + 4 + ); + }); + + it('returns the number of contacts if searching, but not for a phone number', () => { assert.strictEqual( new LeftPaneComposeHelper({ composeContacts: [], @@ -29,26 +71,50 @@ describe('LeftPaneComposeHelper', () => { new LeftPaneComposeHelper({ composeContacts: [fakeContact(), fakeContact()], regionCode: 'US', - searchTerm: '', + searchTerm: 'foo bar', }).getRowCount(), 2 ); }); - it('returns the number of contacts + 1 if searching for a phone number', () => { + it('returns 1 (for the "Start new conversation" button) if searching for a phone number with no contacts', () => { + assert.strictEqual( + new LeftPaneComposeHelper({ + composeContacts: [], + regionCode: 'US', + searchTerm: '+16505551234', + }).getRowCount(), + 1 + ); + }); + + it('returns the number of contacts + 2 (for the "Start new conversation" button and header) if searching for a phone number', () => { assert.strictEqual( new LeftPaneComposeHelper({ composeContacts: [fakeContact(), fakeContact()], regionCode: 'US', searchTerm: '+16505551234', }).getRowCount(), - 3 + 4 ); }); }); describe('getRow', () => { - it('returns each contact as a row if not searching for a phone number', () => { + it('returns a "new group" button if not searching and there are no contacts', () => { + const helper = new LeftPaneComposeHelper({ + composeContacts: [], + regionCode: 'US', + searchTerm: '', + }); + + assert.deepEqual(helper.getRow(0), { + type: RowType.CreateNewGroup, + }); + assert.isUndefined(helper.getRow(1)); + }); + + it('returns a "new group" button, a header, and contacts if not searching', () => { const composeContacts = [fakeContact(), fakeContact()]; const helper = new LeftPaneComposeHelper({ composeContacts, @@ -56,6 +122,72 @@ describe('LeftPaneComposeHelper', () => { searchTerm: '', }); + assert.deepEqual(helper.getRow(0), { + type: RowType.CreateNewGroup, + }); + assert.deepEqual(helper.getRow(1), { + type: RowType.Header, + i18nKey: 'contactsHeader', + }); + assert.deepEqual(helper.getRow(2), { + type: RowType.Contact, + contact: composeContacts[0], + }); + assert.deepEqual(helper.getRow(3), { + type: RowType.Contact, + contact: composeContacts[1], + }); + }); + + it("doesn't let you create new groups if storage service write is disabled", () => { + remoteConfigStub + .withArgs('desktop.storage') + .returns(false) + .withArgs('desktop.storageWrite2') + .returns(false); + + assert.isUndefined( + new LeftPaneComposeHelper({ + composeContacts: [], + regionCode: 'US', + searchTerm: '', + }).getRow(0) + ); + + remoteConfigStub + .withArgs('desktop.storage') + .returns(true) + .withArgs('desktop.storageWrite2') + .returns(false); + + assert.isUndefined( + new LeftPaneComposeHelper({ + composeContacts: [], + regionCode: 'US', + searchTerm: '', + }).getRow(0) + ); + }); + + it('returns no rows if searching and there are no results', () => { + const helper = new LeftPaneComposeHelper({ + composeContacts: [], + regionCode: 'US', + searchTerm: 'foo bar', + }); + + assert.isUndefined(helper.getRow(0)); + assert.isUndefined(helper.getRow(1)); + }); + + it('returns one row per contact if searching', () => { + const composeContacts = [fakeContact(), fakeContact()]; + const helper = new LeftPaneComposeHelper({ + composeContacts, + regionCode: 'US', + searchTerm: 'foo bar', + }); + assert.deepEqual(helper.getRow(0), { type: RowType.Contact, contact: composeContacts[0], @@ -66,7 +198,21 @@ describe('LeftPaneComposeHelper', () => { }); }); - it('returns a "start new conversation" row if searching for a phone number', () => { + it('returns a "start new conversation" row if searching for a phone number and there are no results', () => { + const helper = new LeftPaneComposeHelper({ + composeContacts: [], + regionCode: 'US', + searchTerm: '+16505551234', + }); + + assert.deepEqual(helper.getRow(0), { + type: RowType.StartNewConversation, + phoneNumber: '+16505551234', + }); + assert.isUndefined(helper.getRow(1)); + }); + + it('returns a "start new conversation" row, a header, and contacts if searching for a phone number', () => { const composeContacts = [fakeContact(), fakeContact()]; const helper = new LeftPaneComposeHelper({ composeContacts, @@ -79,10 +225,14 @@ describe('LeftPaneComposeHelper', () => { phoneNumber: '+16505551234', }); assert.deepEqual(helper.getRow(1), { + type: RowType.Header, + i18nKey: 'contactsHeader', + }); + assert.deepEqual(helper.getRow(2), { type: RowType.Contact, contact: composeContacts[0], }); - assert.deepEqual(helper.getRow(2), { + assert.deepEqual(helper.getRow(3), { type: RowType.Contact, contact: composeContacts[1], }); @@ -120,7 +270,7 @@ describe('LeftPaneComposeHelper', () => { }); describe('shouldRecomputeRowHeights', () => { - it('always returns false because row heights are constant', () => { + it('returns false if going from "no header" to "no header"', () => { const helper = new LeftPaneComposeHelper({ composeContacts: [fakeContact(), fakeContact()], regionCode: 'US', @@ -130,15 +280,79 @@ describe('LeftPaneComposeHelper', () => { assert.isFalse( helper.shouldRecomputeRowHeights({ composeContacts: [fakeContact()], + regionCode: 'US', searchTerm: 'foo bar', }) ); assert.isFalse( helper.shouldRecomputeRowHeights({ composeContacts: [fakeContact(), fakeContact(), fakeContact()], + regionCode: 'US', + searchTerm: 'bing bong', + }) + ); + }); + + it('returns false if going from "has header" to "has header"', () => { + const helper = new LeftPaneComposeHelper({ + composeContacts: [fakeContact(), fakeContact()], + regionCode: 'US', + searchTerm: '', + }); + + assert.isFalse( + helper.shouldRecomputeRowHeights({ + composeContacts: [fakeContact()], + regionCode: 'US', searchTerm: '', }) ); + assert.isFalse( + helper.shouldRecomputeRowHeights({ + composeContacts: [fakeContact()], + regionCode: 'US', + searchTerm: '+16505559876', + }) + ); + }); + + it('returns true if going from "no header" to "has header"', () => { + const helper = new LeftPaneComposeHelper({ + composeContacts: [fakeContact(), fakeContact()], + regionCode: 'US', + searchTerm: 'foo bar', + }); + + assert.isTrue( + helper.shouldRecomputeRowHeights({ + composeContacts: [fakeContact(), fakeContact()], + regionCode: 'US', + searchTerm: '', + }) + ); + assert.isTrue( + helper.shouldRecomputeRowHeights({ + composeContacts: [fakeContact(), fakeContact()], + regionCode: 'US', + searchTerm: '+16505551234', + }) + ); + }); + + it('returns true if going from "has header" to "no header"', () => { + const helper = new LeftPaneComposeHelper({ + composeContacts: [fakeContact(), fakeContact()], + regionCode: 'US', + searchTerm: '', + }); + + assert.isTrue( + helper.shouldRecomputeRowHeights({ + composeContacts: [fakeContact(), fakeContact()], + regionCode: 'US', + searchTerm: 'foo bar', + }) + ); }); }); }); diff --git a/ts/test-node/components/leftPane/LeftPaneSetGroupMetadataHelper_test.ts b/ts/test-node/components/leftPane/LeftPaneSetGroupMetadataHelper_test.ts new file mode 100644 index 0000000000..28a4d88dfd --- /dev/null +++ b/ts/test-node/components/leftPane/LeftPaneSetGroupMetadataHelper_test.ts @@ -0,0 +1,85 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import { v4 as uuid } from 'uuid'; +import { RowType } from '../../../components/ConversationList'; + +import { LeftPaneSetGroupMetadataHelper } from '../../../components/leftPane/LeftPaneSetGroupMetadataHelper'; + +describe('LeftPaneSetGroupMetadataHelper', () => { + const fakeContact = () => ({ + id: uuid(), + title: uuid(), + type: 'direct' as const, + }); + + describe('getRowCount', () => { + it('returns 0 if there are no contacts', () => { + assert.strictEqual( + new LeftPaneSetGroupMetadataHelper({ + groupAvatar: undefined, + groupName: '', + hasError: false, + isCreating: false, + selectedContacts: [], + }).getRowCount(), + 0 + ); + }); + + it('returns the number of candidate contacts + 2 if there are any', () => { + assert.strictEqual( + new LeftPaneSetGroupMetadataHelper({ + groupAvatar: undefined, + groupName: '', + hasError: false, + isCreating: false, + selectedContacts: [fakeContact(), fakeContact()], + }).getRowCount(), + 4 + ); + }); + }); + + describe('getRow', () => { + it('returns undefined if there are no contacts', () => { + assert.isUndefined( + new LeftPaneSetGroupMetadataHelper({ + groupAvatar: undefined, + groupName: '', + hasError: false, + isCreating: false, + selectedContacts: [], + }).getRow(0) + ); + }); + + it('returns a header, then the contacts, then a blank space if there are contacts', () => { + const selectedContacts = [fakeContact(), fakeContact()]; + const helper = new LeftPaneSetGroupMetadataHelper({ + groupAvatar: undefined, + groupName: '', + hasError: false, + isCreating: false, + selectedContacts, + }); + + assert.deepEqual(helper.getRow(0), { + type: RowType.Header, + i18nKey: 'setGroupMetadata__members-header', + }); + assert.deepEqual(helper.getRow(1), { + type: RowType.Contact, + contact: selectedContacts[0], + isClickable: false, + }); + assert.deepEqual(helper.getRow(2), { + type: RowType.Contact, + contact: selectedContacts[1], + isClickable: false, + }); + assert.deepEqual(helper.getRow(3), { type: RowType.Blank }); + }); + }); +}); diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index aaef610e4a..1872069bda 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -14461,6 +14461,24 @@ "updated": "2021-01-06T00:47:54.313Z", "reasonDetail": "Needed to render remote video elements. Doesn't interact with the DOM." }, + { + "rule": "React-useRef", + "path": "ts/components/AvatarInput.js", + "line": " const fileInputRef = react_1.useRef(null);", + "lineNumber": 40, + "reasonCategory": "usageTrusted", + "updated": "2021-03-01T18:34:36.638Z", + "reasonDetail": "Needed to trigger a click on a 'file' input. Doesn't update the DOM." + }, + { + "rule": "React-useRef", + "path": "ts/components/AvatarInput.js", + "line": " const menuTriggerRef = react_1.useRef(null);", + "lineNumber": 43, + "reasonCategory": "usageTrusted", + "updated": "2021-03-01T18:34:36.638Z", + "reasonDetail": "Used to reference popup menu" + }, { "rule": "React-useRef", "path": "ts/components/AvatarPopup.js", @@ -14641,11 +14659,29 @@ "updated": "2020-10-26T23:56:13.482Z", "reasonDetail": "Doesn't refer to a DOM element." }, + { + "rule": "React-useRef", + "path": "ts/components/ContactPills.js", + "line": " const elRef = react_1.useRef(null);", + "lineNumber": 27, + "reasonCategory": "usageTrusted", + "updated": "2021-03-01T18:34:36.638Z", + "reasonDetail": "Used for scrolling. Doesn't otherwise manipulate the DOM" + }, + { + "rule": "React-useRef", + "path": "ts/components/ContactPills.js", + "line": " const previousChildCountRef = react_1.useRef(childCount);", + "lineNumber": 29, + "reasonCategory": "usageTrusted", + "updated": "2021-03-01T18:34:36.638Z", + "reasonDetail": "Doesn't reference the DOM. Refers to a number" + }, { "rule": "React-useRef", "path": "ts/components/ConversationList.js", "line": " const listRef = react_1.useRef(null);", - "lineNumber": 44, + "lineNumber": 49, "reasonCategory": "usageTrusted", "updated": "2021-02-12T16:25:08.285Z", "reasonDetail": "Used for scroll calculations" @@ -14706,7 +14742,7 @@ "rule": "React-useRef", "path": "ts/components/LeftPane.js", "line": " const previousModeSpecificPropsRef = react_1.useRef(modeSpecificProps);", - "lineNumber": 47, + "lineNumber": 52, "reasonCategory": "usageTrusted", "updated": "2021-02-12T16:25:08.285Z", "reasonDetail": "Doesn't interact with the DOM." @@ -14715,7 +14751,7 @@ "rule": "React-useRef", "path": "ts/components/LeftPane.tsx", "line": " const previousModeSpecificPropsRef = useRef(modeSpecificProps);", - "lineNumber": 104, + "lineNumber": 143, "reasonCategory": "usageTrusted", "updated": "2021-02-12T16:25:08.285Z", "reasonDetail": "Doesn't interact with the DOM." @@ -14969,7 +15005,7 @@ "rule": "React-createRef", "path": "ts/components/conversation/Timeline.js", "line": " this.listRef = react_1.default.createRef();", - "lineNumber": 31, + "lineNumber": 32, "reasonCategory": "usageTrusted", "updated": "2019-07-31T00:19:18.696Z", "reasonDetail": "Timeline needs to interact with its child List directly" diff --git a/ts/util/parseIntOrThrow.ts b/ts/util/parseIntOrThrow.ts new file mode 100644 index 0000000000..21537f2b28 --- /dev/null +++ b/ts/util/parseIntOrThrow.ts @@ -0,0 +1,24 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export function parseIntOrThrow(value: unknown, message: string): number { + let result: number; + + switch (typeof value) { + case 'number': + result = value; + break; + case 'string': + result = parseInt(value, 10); + break; + default: + result = NaN; + break; + } + + if (!Number.isInteger(result)) { + throw new Error(message); + } + + return result; +} diff --git a/ts/util/parseIntWithFallback.ts b/ts/util/parseIntWithFallback.ts new file mode 100644 index 0000000000..2978bd4720 --- /dev/null +++ b/ts/util/parseIntWithFallback.ts @@ -0,0 +1,12 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { parseIntOrThrow } from './parseIntOrThrow'; + +export function parseIntWithFallback(value: unknown, fallback: number): number { + try { + return parseIntOrThrow(value, 'Failed to parse'); + } catch (err) { + return fallback; + } +} From 7e629edd21922c25c38f95f26c5f0532036d01f1 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Fri, 26 Feb 2021 15:42:45 -0800 Subject: [PATCH 032/181] Move SignalProtocolStore to TypeScript --- background.html | 1 - js/signal_protocol_store.js | 1050 ----------------------- preload.js | 1 + test/index.html | 3 - ts/LibSignalStore.ts | 1233 ++++++++++++++++++++++++++++ ts/libsignal.d.ts | 2 +- ts/sql/Client.ts | 2 +- ts/sql/Interface.ts | 2 +- ts/sql/Server.ts | 2 +- ts/test-both/util/isNotNil_test.ts | 24 + ts/textsecure.d.ts | 1 + ts/util/isNotNil.ts | 9 + ts/util/lint/exceptions.json | 24 +- ts/window.d.ts | 3 + 14 files changed, 1291 insertions(+), 1066 deletions(-) delete mode 100644 js/signal_protocol_store.js create mode 100644 ts/LibSignalStore.ts create mode 100644 ts/test-both/util/isNotNil_test.ts create mode 100644 ts/util/isNotNil.ts diff --git a/background.html b/background.html index 3ced72b696..8e602dee0a 100644 --- a/background.html +++ b/background.html @@ -329,7 +329,6 @@ - diff --git a/js/signal_protocol_store.js b/js/signal_protocol_store.js deleted file mode 100644 index 75bbc41ae1..0000000000 --- a/js/signal_protocol_store.js +++ /dev/null @@ -1,1050 +0,0 @@ -// Copyright 2016-2020 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -/* global - dcodeIO, Backbone, _, libsignal, textsecure, ConversationController, stringObject */ - -/* eslint-disable no-proto */ - -// eslint-disable-next-line func-names -(function () { - const TIMESTAMP_THRESHOLD = 5 * 1000; // 5 seconds - const Direction = { - SENDING: 1, - RECEIVING: 2, - }; - - const VerifiedStatus = { - DEFAULT: 0, - VERIFIED: 1, - UNVERIFIED: 2, - }; - - function validateVerifiedStatus(status) { - if ( - status === VerifiedStatus.DEFAULT || - status === VerifiedStatus.VERIFIED || - status === VerifiedStatus.UNVERIFIED - ) { - return true; - } - return false; - } - - const StaticByteBufferProto = new dcodeIO.ByteBuffer().__proto__; - const StaticArrayBufferProto = new ArrayBuffer().__proto__; - const StaticUint8ArrayProto = new Uint8Array().__proto__; - - function isStringable(thing) { - return ( - thing === Object(thing) && - (thing.__proto__ === StaticArrayBufferProto || - thing.__proto__ === StaticUint8ArrayProto || - thing.__proto__ === StaticByteBufferProto) - ); - } - function convertToArrayBuffer(thing) { - if (thing === undefined) { - return undefined; - } - if (thing === Object(thing)) { - if (thing.__proto__ === StaticArrayBufferProto) { - return thing; - } - // TODO: Several more cases here... - } - - if (thing instanceof Array) { - // Assuming Uint16Array from curve25519 - const res = new ArrayBuffer(thing.length * 2); - const uint = new Uint16Array(res); - for (let i = 0; i < thing.length; i += 1) { - uint[i] = thing[i]; - } - return res; - } - - let str; - if (isStringable(thing)) { - str = stringObject(thing); - } else if (typeof thing === 'string') { - str = thing; - } else { - throw new Error( - `Tried to convert a non-stringable thing of type ${typeof thing} to an array buffer` - ); - } - const res = new ArrayBuffer(str.length); - const uint = new Uint8Array(res); - for (let i = 0; i < str.length; i += 1) { - uint[i] = str.charCodeAt(i); - } - return res; - } - - function equalArrayBuffers(ab1, ab2) { - if (!(ab1 instanceof ArrayBuffer && ab2 instanceof ArrayBuffer)) { - return false; - } - if (ab1.byteLength !== ab2.byteLength) { - return false; - } - let result = 0; - const ta1 = new Uint8Array(ab1); - const ta2 = new Uint8Array(ab2); - for (let i = 0; i < ab1.byteLength; i += 1) { - // eslint-disable-next-line no-bitwise - result |= ta1[i] ^ ta2[i]; - } - return result === 0; - } - - const IdentityRecord = Backbone.Model.extend({ - storeName: 'identityKeys', - validAttributes: [ - 'id', - 'publicKey', - 'firstUse', - 'timestamp', - 'verified', - 'nonblockingApproval', - ], - validate(attrs) { - const attributeNames = _.keys(attrs); - const { validAttributes } = this; - const allValid = _.all(attributeNames, attributeName => - _.contains(validAttributes, attributeName) - ); - if (!allValid) { - return new Error('Invalid identity key attribute names'); - } - const allPresent = _.all(validAttributes, attributeName => - _.contains(attributeNames, attributeName) - ); - if (!allPresent) { - return new Error('Missing identity key attributes'); - } - - if (typeof attrs.id !== 'string') { - return new Error('Invalid identity key id'); - } - if (!(attrs.publicKey instanceof ArrayBuffer)) { - return new Error('Invalid identity key publicKey'); - } - if (typeof attrs.firstUse !== 'boolean') { - return new Error('Invalid identity key firstUse'); - } - if (typeof attrs.timestamp !== 'number' || !(attrs.timestamp >= 0)) { - return new Error('Invalid identity key timestamp'); - } - if (!validateVerifiedStatus(attrs.verified)) { - return new Error('Invalid identity key verified'); - } - if (typeof attrs.nonblockingApproval !== 'boolean') { - return new Error('Invalid identity key nonblockingApproval'); - } - - return null; - }, - }); - - async function normalizeEncodedAddress(encodedAddress) { - const [identifier, deviceId] = textsecure.utils.unencodeNumber( - encodedAddress - ); - try { - const conv = ConversationController.getOrCreate(identifier, 'private'); - return `${conv.get('id')}.${deviceId}`; - } catch (e) { - window.log.error( - `could not get conversation for identifier ${identifier}` - ); - throw e; - } - } - - function SignalProtocolStore() {} - - async function _hydrateCache(object, field, itemsPromise, idField) { - const items = await itemsPromise; - - const cache = Object.create(null); - for (let i = 0, max = items.length; i < max; i += 1) { - const item = items[i]; - const id = item[idField]; - - cache[id] = item; - } - - window.log.info(`SignalProtocolStore: Finished caching ${field} data`); - // eslint-disable-next-line no-param-reassign - object[field] = cache; - } - - SignalProtocolStore.prototype = { - constructor: SignalProtocolStore, - async hydrateCaches() { - await Promise.all([ - (async () => { - const item = await window.Signal.Data.getItemById('identityKey'); - this.ourIdentityKey = item ? item.value : undefined; - })(), - (async () => { - const item = await window.Signal.Data.getItemById('registrationId'); - this.ourRegistrationId = item ? item.value : undefined; - })(), - _hydrateCache( - this, - 'identityKeys', - window.Signal.Data.getAllIdentityKeys(), - 'id' - ), - _hydrateCache( - this, - 'sessions', - await window.Signal.Data.getAllSessions(), - 'id' - ), - _hydrateCache( - this, - 'preKeys', - window.Signal.Data.getAllPreKeys(), - 'id' - ), - _hydrateCache( - this, - 'signedPreKeys', - window.Signal.Data.getAllSignedPreKeys(), - 'id' - ), - ]); - }, - - async getIdentityKeyPair() { - return this.ourIdentityKey; - }, - async getLocalRegistrationId() { - return this.ourRegistrationId; - }, - - // PreKeys - - async loadPreKey(keyId) { - const key = this.preKeys[keyId]; - if (key) { - window.log.info('Successfully fetched prekey:', keyId); - return { - pubKey: key.publicKey, - privKey: key.privateKey, - }; - } - - window.log.error('Failed to fetch prekey:', keyId); - return undefined; - }, - async storePreKey(keyId, keyPair) { - const data = { - id: keyId, - publicKey: keyPair.pubKey, - privateKey: keyPair.privKey, - }; - - this.preKeys[keyId] = data; - await window.Signal.Data.createOrUpdatePreKey(data); - }, - async removePreKey(keyId) { - try { - this.trigger('removePreKey'); - } catch (error) { - window.log.error( - 'removePreKey error triggering removePreKey:', - error && error.stack ? error.stack : error - ); - } - - delete this.preKeys[keyId]; - await window.Signal.Data.removePreKeyById(keyId); - }, - async clearPreKeyStore() { - this.preKeys = Object.create(null); - await window.Signal.Data.removeAllPreKeys(); - }, - - // Signed PreKeys - - async loadSignedPreKey(keyId) { - const key = this.signedPreKeys[keyId]; - if (key) { - window.log.info('Successfully fetched signed prekey:', key.id); - return { - pubKey: key.publicKey, - privKey: key.privateKey, - created_at: key.created_at, - keyId: key.id, - confirmed: key.confirmed, - }; - } - - window.log.error('Failed to fetch signed prekey:', keyId); - return undefined; - }, - async loadSignedPreKeys() { - if (arguments.length > 0) { - throw new Error('loadSignedPreKeys takes no arguments'); - } - - const keys = Object.values(this.signedPreKeys); - return keys.map(prekey => ({ - pubKey: prekey.publicKey, - privKey: prekey.privateKey, - created_at: prekey.created_at, - keyId: prekey.id, - confirmed: prekey.confirmed, - })); - }, - async storeSignedPreKey(keyId, keyPair, confirmed) { - const data = { - id: keyId, - publicKey: keyPair.pubKey, - privateKey: keyPair.privKey, - created_at: Date.now(), - confirmed: Boolean(confirmed), - }; - - this.signedPreKeys[keyId] = data; - await window.Signal.Data.createOrUpdateSignedPreKey(data); - }, - async removeSignedPreKey(keyId) { - delete this.signedPreKeys[keyId]; - await window.Signal.Data.removeSignedPreKeyById(keyId); - }, - async clearSignedPreKeysStore() { - this.signedPreKeys = Object.create(null); - await window.Signal.Data.removeAllSignedPreKeys(); - }, - - // Sessions - - async loadSession(encodedAddress) { - if (encodedAddress === null || encodedAddress === undefined) { - throw new Error('Tried to get session for undefined/null number'); - } - - try { - const id = await normalizeEncodedAddress(encodedAddress); - const session = this.sessions[id]; - - if (session) { - return session.record; - } - } catch (error) { - const errorString = error && error.stack ? error.stack : error; - window.log.error( - `could not load session ${encodedAddress}: ${errorString}` - ); - } - - return undefined; - }, - async storeSession(encodedAddress, record) { - if (encodedAddress === null || encodedAddress === undefined) { - throw new Error('Tried to put session for undefined/null number'); - } - const unencoded = textsecure.utils.unencodeNumber(encodedAddress); - const deviceId = parseInt(unencoded[1], 10); - - try { - const id = await normalizeEncodedAddress(encodedAddress); - const previousData = this.sessions[id]; - - const data = { - id, - conversationId: textsecure.utils.unencodeNumber(id)[0], - deviceId, - record, - }; - - // Optimistically update in-memory cache; will revert if save fails. - this.sessions[id] = data; - - try { - await window.Signal.Data.createOrUpdateSession(data); - } catch (e) { - if (previousData) { - this.sessions[id] = previousData; - } - throw e; - } - } catch (error) { - const errorString = error && error.stack ? error.stack : error; - window.log.error( - `could not store session for ${encodedAddress}: ${errorString}` - ); - } - }, - async getDeviceIds(identifier) { - if (identifier === null || identifier === undefined) { - throw new Error('Tried to get device ids for undefined/null number'); - } - - try { - const id = ConversationController.getConversationId(identifier); - const allSessions = Object.values(this.sessions); - const sessions = allSessions.filter( - session => session.conversationId === id - ); - const openSessions = await Promise.all( - sessions.map(async session => { - const sessionCipher = new libsignal.SessionCipher( - textsecure.storage.protocol, - session.id - ); - - const hasOpenSession = await sessionCipher.hasOpenSession(); - if (hasOpenSession) { - return session; - } - - return undefined; - }) - ); - - return openSessions.filter(Boolean).map(item => item.deviceId); - } catch (error) { - window.log.error( - `could not get device ids for identifier ${identifier}`, - error && error.stack ? error.stack : error - ); - } - - return []; - }, - async removeSession(encodedAddress) { - window.log.info('removeSession: deleting session for', encodedAddress); - try { - const id = await normalizeEncodedAddress(encodedAddress); - delete this.sessions[id]; - await window.Signal.Data.removeSessionById(id); - } catch (e) { - window.log.error(`could not delete session for ${encodedAddress}`); - } - }, - async removeAllSessions(identifier) { - if (identifier === null || identifier === undefined) { - throw new Error('Tried to remove sessions for undefined/null number'); - } - - window.log.info('removeAllSessions: deleting sessions for', identifier); - - const id = ConversationController.getConversationId(identifier); - - const allSessions = Object.values(this.sessions); - - for (let i = 0, max = allSessions.length; i < max; i += 1) { - const session = allSessions[i]; - if (session.conversationId === id) { - delete this.sessions[session.id]; - } - } - - await window.Signal.Data.removeSessionsByConversation(identifier); - }, - async archiveSiblingSessions(identifier) { - window.log.info( - 'archiveSiblingSessions: archiving sibling sessions for', - identifier - ); - - const address = libsignal.SignalProtocolAddress.fromString(identifier); - - const deviceIds = await this.getDeviceIds(address.getName()); - const siblings = _.without(deviceIds, address.getDeviceId()); - - await Promise.all( - siblings.map(async deviceId => { - const sibling = new libsignal.SignalProtocolAddress( - address.getName(), - deviceId - ); - window.log.info( - 'archiveSiblingSessions: closing session for', - sibling.toString() - ); - const sessionCipher = new libsignal.SessionCipher( - textsecure.storage.protocol, - sibling - ); - await sessionCipher.closeOpenSessionForDevice(); - }) - ); - }, - async archiveAllSessions(identifier) { - window.log.info( - 'archiveAllSessions: archiving all sessions for', - identifier - ); - - const deviceIds = await this.getDeviceIds(identifier); - - await Promise.all( - deviceIds.map(async deviceId => { - const address = new libsignal.SignalProtocolAddress( - identifier, - deviceId - ); - window.log.info( - 'archiveAllSessions: closing session for', - address.toString() - ); - const sessionCipher = new libsignal.SessionCipher( - textsecure.storage.protocol, - address - ); - await sessionCipher.closeOpenSessionForDevice(); - }) - ); - }, - async clearSessionStore() { - this.sessions = Object.create(null); - window.Signal.Data.removeAllSessions(); - }, - - // Identity Keys - - getIdentityRecord(identifier) { - try { - const id = ConversationController.getConversationId(identifier); - const record = this.identityKeys[id]; - - if (record) { - return record; - } - } catch (e) { - window.log.error( - `could not get identity record for identifier ${identifier}` - ); - } - - return undefined; - }, - - async isTrustedIdentity(encodedAddress, publicKey, direction) { - if (encodedAddress === null || encodedAddress === undefined) { - throw new Error('Tried to get identity key for undefined/null key'); - } - const identifier = textsecure.utils.unencodeNumber(encodedAddress)[0]; - const ourNumber = textsecure.storage.user.getNumber(); - const ourUuid = textsecure.storage.user.getUuid(); - const isOurIdentifier = - (ourNumber && identifier === ourNumber) || - (ourUuid && identifier === ourUuid); - - const identityRecord = this.getIdentityRecord(identifier); - - if (isOurIdentifier) { - if (identityRecord && identityRecord.publicKey) { - return equalArrayBuffers(identityRecord.publicKey, publicKey); - } - window.log.warn( - 'isTrustedIdentity: No local record for our own identifier. Returning true.' - ); - return true; - } - - switch (direction) { - case Direction.SENDING: - return this.isTrustedForSending(publicKey, identityRecord); - case Direction.RECEIVING: - return true; - default: - throw new Error(`Unknown direction: ${direction}`); - } - }, - isTrustedForSending(publicKey, identityRecord) { - if (!identityRecord) { - window.log.info( - 'isTrustedForSending: No previous record, returning true...' - ); - return true; - } - - const existing = identityRecord.publicKey; - - if (!existing) { - window.log.info('isTrustedForSending: Nothing here, returning true...'); - return true; - } - if (!equalArrayBuffers(existing, publicKey)) { - window.log.info("isTrustedForSending: Identity keys don't match..."); - return false; - } - if (identityRecord.verified === VerifiedStatus.UNVERIFIED) { - window.log.error('Needs unverified approval!'); - return false; - } - if (this.isNonBlockingApprovalRequired(identityRecord)) { - window.log.error('isTrustedForSending: Needs non-blocking approval!'); - return false; - } - - return true; - }, - async loadIdentityKey(identifier) { - if (identifier === null || identifier === undefined) { - throw new Error('Tried to get identity key for undefined/null key'); - } - const id = textsecure.utils.unencodeNumber(identifier)[0]; - const identityRecord = this.getIdentityRecord(id); - - if (identityRecord) { - return identityRecord.publicKey; - } - - return undefined; - }, - async _saveIdentityKey(data) { - const { id } = data; - - const previousData = this.identityKeys[id]; - - // Optimistically update in-memory cache; will revert if save fails. - this.identityKeys[id] = data; - - try { - await window.Signal.Data.createOrUpdateIdentityKey(data); - } catch (error) { - if (previousData) { - this.identityKeys[id] = previousData; - } - - throw error; - } - }, - async saveIdentity(encodedAddress, publicKey, nonblockingApproval) { - if (encodedAddress === null || encodedAddress === undefined) { - throw new Error('Tried to put identity key for undefined/null key'); - } - if (!(publicKey instanceof ArrayBuffer)) { - // eslint-disable-next-line no-param-reassign - publicKey = convertToArrayBuffer(publicKey); - } - if (typeof nonblockingApproval !== 'boolean') { - // eslint-disable-next-line no-param-reassign - nonblockingApproval = false; - } - - const identifier = textsecure.utils.unencodeNumber(encodedAddress)[0]; - const identityRecord = this.getIdentityRecord(identifier); - const id = ConversationController.getOrCreate(identifier, 'private').get( - 'id' - ); - - if (!identityRecord || !identityRecord.publicKey) { - // Lookup failed, or the current key was removed, so save this one. - window.log.info('Saving new identity...'); - await this._saveIdentityKey({ - id, - publicKey, - firstUse: true, - timestamp: Date.now(), - verified: VerifiedStatus.DEFAULT, - nonblockingApproval, - }); - - return false; - } - - const oldpublicKey = identityRecord.publicKey; - if (!equalArrayBuffers(oldpublicKey, publicKey)) { - window.log.info('Replacing existing identity...'); - const previousStatus = identityRecord.verified; - let verifiedStatus; - if ( - previousStatus === VerifiedStatus.VERIFIED || - previousStatus === VerifiedStatus.UNVERIFIED - ) { - verifiedStatus = VerifiedStatus.UNVERIFIED; - } else { - verifiedStatus = VerifiedStatus.DEFAULT; - } - - await this._saveIdentityKey({ - id, - publicKey, - firstUse: false, - timestamp: Date.now(), - verified: verifiedStatus, - nonblockingApproval, - }); - - try { - this.trigger('keychange', identifier); - } catch (error) { - window.log.error( - 'saveIdentity error triggering keychange:', - error && error.stack ? error.stack : error - ); - } - await this.archiveSiblingSessions(encodedAddress); - - return true; - } - if (this.isNonBlockingApprovalRequired(identityRecord)) { - window.log.info('Setting approval status...'); - - identityRecord.nonblockingApproval = nonblockingApproval; - await this._saveIdentityKey(identityRecord); - - return false; - } - - return false; - }, - isNonBlockingApprovalRequired(identityRecord) { - return ( - !identityRecord.firstUse && - Date.now() - identityRecord.timestamp < TIMESTAMP_THRESHOLD && - !identityRecord.nonblockingApproval - ); - }, - async saveIdentityWithAttributes(encodedAddress, attributes) { - if (encodedAddress === null || encodedAddress === undefined) { - throw new Error('Tried to put identity key for undefined/null key'); - } - - const identifier = textsecure.utils.unencodeNumber(encodedAddress)[0]; - const identityRecord = this.getIdentityRecord(identifier); - const conv = ConversationController.getOrCreate(identifier, 'private'); - const id = conv.get('id'); - - const updates = { - id, - ...identityRecord, - ...attributes, - }; - - const model = new IdentityRecord(updates); - if (model.isValid()) { - await this._saveIdentityKey(updates); - } else { - throw model.validationError; - } - }, - async setApproval(encodedAddress, nonblockingApproval) { - if (encodedAddress === null || encodedAddress === undefined) { - throw new Error('Tried to set approval for undefined/null identifier'); - } - if (typeof nonblockingApproval !== 'boolean') { - throw new Error('Invalid approval status'); - } - - const identifier = textsecure.utils.unencodeNumber(encodedAddress)[0]; - const identityRecord = this.getIdentityRecord(identifier); - - if (!identityRecord) { - throw new Error(`No identity record for ${identifier}`); - } - - identityRecord.nonblockingApproval = nonblockingApproval; - await this._saveIdentityKey(identityRecord); - }, - async setVerified(encodedAddress, verifiedStatus, publicKey) { - if (encodedAddress === null || encodedAddress === undefined) { - throw new Error('Tried to set verified for undefined/null key'); - } - if (!validateVerifiedStatus(verifiedStatus)) { - throw new Error('Invalid verified status'); - } - if (arguments.length > 2 && !(publicKey instanceof ArrayBuffer)) { - throw new Error('Invalid public key'); - } - - const identityRecord = this.getIdentityRecord(encodedAddress); - - if (!identityRecord) { - throw new Error(`No identity record for ${encodedAddress}`); - } - - if ( - !publicKey || - equalArrayBuffers(identityRecord.publicKey, publicKey) - ) { - identityRecord.verified = verifiedStatus; - - const model = new IdentityRecord(identityRecord); - if (model.isValid()) { - await this._saveIdentityKey(identityRecord); - } else { - throw identityRecord.validationError; - } - } else { - window.log.info('No identity record for specified publicKey'); - } - }, - async getVerified(identifier) { - if (identifier === null || identifier === undefined) { - throw new Error('Tried to set verified for undefined/null key'); - } - - const identityRecord = this.getIdentityRecord(identifier); - if (!identityRecord) { - throw new Error(`No identity record for ${identifier}`); - } - - const verifiedStatus = identityRecord.verified; - if (validateVerifiedStatus(verifiedStatus)) { - return verifiedStatus; - } - - return VerifiedStatus.DEFAULT; - }, - // Resolves to true if a new identity key was saved - processContactSyncVerificationState(identifier, verifiedStatus, publicKey) { - if (verifiedStatus === VerifiedStatus.UNVERIFIED) { - return this.processUnverifiedMessage( - identifier, - verifiedStatus, - publicKey - ); - } - return this.processVerifiedMessage(identifier, verifiedStatus, publicKey); - }, - // This function encapsulates the non-Java behavior, since the mobile apps don't - // currently receive contact syncs and therefore will see a verify sync with - // UNVERIFIED status - async processUnverifiedMessage(identifier, verifiedStatus, publicKey) { - if (identifier === null || identifier === undefined) { - throw new Error('Tried to set verified for undefined/null key'); - } - if (publicKey !== undefined && !(publicKey instanceof ArrayBuffer)) { - throw new Error('Invalid public key'); - } - - const identityRecord = this.getIdentityRecord(identifier); - - const isPresent = Boolean(identityRecord); - let isEqual = false; - - if (isPresent && publicKey) { - isEqual = equalArrayBuffers(publicKey, identityRecord.publicKey); - } - - if ( - isPresent && - isEqual && - identityRecord.verified !== VerifiedStatus.UNVERIFIED - ) { - await textsecure.storage.protocol.setVerified( - identifier, - verifiedStatus, - publicKey - ); - return false; - } - - if (!isPresent || !isEqual) { - await textsecure.storage.protocol.saveIdentityWithAttributes( - identifier, - { - publicKey, - verified: verifiedStatus, - firstUse: false, - timestamp: Date.now(), - nonblockingApproval: true, - } - ); - - if (isPresent && !isEqual) { - try { - this.trigger('keychange', identifier); - } catch (error) { - window.log.error( - 'processUnverifiedMessage error triggering keychange:', - error && error.stack ? error.stack : error - ); - } - - await this.archiveAllSessions(identifier); - - return true; - } - } - - // The situation which could get us here is: - // 1. had a previous key - // 2. new key is the same - // 3. desired new status is same as what we had before - return false; - }, - // This matches the Java method as of - // https://github.com/signalapp/Signal-Android/blob/d0bb68e1378f689e4d10ac6a46014164992ca4e4/src/org/thoughtcrime/securesms/util/IdentityUtil.java#L188 - async processVerifiedMessage(identifier, verifiedStatus, publicKey) { - if (identifier === null || identifier === undefined) { - throw new Error('Tried to set verified for undefined/null key'); - } - if (!validateVerifiedStatus(verifiedStatus)) { - throw new Error('Invalid verified status'); - } - if (publicKey !== undefined && !(publicKey instanceof ArrayBuffer)) { - throw new Error('Invalid public key'); - } - - const identityRecord = this.getIdentityRecord(identifier); - - const isPresent = Boolean(identityRecord); - let isEqual = false; - - if (isPresent && publicKey) { - isEqual = equalArrayBuffers(publicKey, identityRecord.publicKey); - } - - if (!isPresent && verifiedStatus === VerifiedStatus.DEFAULT) { - window.log.info('No existing record for default status'); - return false; - } - - if ( - isPresent && - isEqual && - identityRecord.verified !== VerifiedStatus.DEFAULT && - verifiedStatus === VerifiedStatus.DEFAULT - ) { - await textsecure.storage.protocol.setVerified( - identifier, - verifiedStatus, - publicKey - ); - return false; - } - - if ( - verifiedStatus === VerifiedStatus.VERIFIED && - (!isPresent || - (isPresent && !isEqual) || - (isPresent && identityRecord.verified !== VerifiedStatus.VERIFIED)) - ) { - await textsecure.storage.protocol.saveIdentityWithAttributes( - identifier, - { - publicKey, - verified: verifiedStatus, - firstUse: false, - timestamp: Date.now(), - nonblockingApproval: true, - } - ); - - if (isPresent && !isEqual) { - try { - this.trigger('keychange', identifier); - } catch (error) { - window.log.error( - 'processVerifiedMessage error triggering keychange:', - error && error.stack ? error.stack : error - ); - } - - await this.archiveAllSessions(identifier); - - // true signifies that we overwrote a previous key with a new one - return true; - } - } - - // We get here if we got a new key and the status is DEFAULT. If the - // message is out of date, we don't want to lose whatever more-secure - // state we had before. - return false; - }, - isUntrusted(identifier) { - if (identifier === null || identifier === undefined) { - throw new Error('Tried to set verified for undefined/null key'); - } - - const identityRecord = this.getIdentityRecord(identifier); - if (!identityRecord) { - throw new Error(`No identity record for ${identifier}`); - } - - if ( - Date.now() - identityRecord.timestamp < TIMESTAMP_THRESHOLD && - !identityRecord.nonblockingApproval && - !identityRecord.firstUse - ) { - return true; - } - - return false; - }, - async removeIdentityKey(identifier) { - const id = ConversationController.getConversationId(identifier); - if (id) { - delete this.identityKeys[id]; - await window.Signal.Data.removeIdentityKeyById(id); - await textsecure.storage.protocol.removeAllSessions(id); - } - }, - - // Not yet processed messages - for resiliency - getUnprocessedCount() { - return window.Signal.Data.getUnprocessedCount(); - }, - getAllUnprocessed() { - return window.Signal.Data.getAllUnprocessed(); - }, - getUnprocessedById(id) { - return window.Signal.Data.getUnprocessedById(id); - }, - addUnprocessed(data) { - // We need to pass forceSave because the data has an id already, which will cause - // an update instead of an insert. - return window.Signal.Data.saveUnprocessed(data, { - forceSave: true, - }); - }, - addMultipleUnprocessed(array) { - // We need to pass forceSave because the data has an id already, which will cause - // an update instead of an insert. - return window.Signal.Data.saveUnprocesseds(array, { - forceSave: true, - }); - }, - updateUnprocessedAttempts(id, attempts) { - return window.Signal.Data.updateUnprocessedAttempts(id, attempts); - }, - updateUnprocessedWithData(id, data) { - return window.Signal.Data.updateUnprocessedWithData(id, data); - }, - updateUnprocessedsWithData(items) { - return window.Signal.Data.updateUnprocessedsWithData(items); - }, - removeUnprocessed(idOrArray) { - return window.Signal.Data.removeUnprocessed(idOrArray); - }, - removeAllUnprocessed() { - return window.Signal.Data.removeAllUnprocessed(); - }, - async removeAllData() { - await window.Signal.Data.removeAll(); - await this.hydrateCaches(); - - window.storage.reset(); - await window.storage.fetch(); - - ConversationController.reset(); - await ConversationController.load(); - }, - async removeAllConfiguration() { - await window.Signal.Data.removeAllConfiguration(); - await this.hydrateCaches(); - - window.storage.reset(); - await window.storage.fetch(); - }, - }; - _.extend(SignalProtocolStore.prototype, Backbone.Events); - - window.SignalProtocolStore = SignalProtocolStore; - window.SignalProtocolStore.prototype.Direction = Direction; - window.SignalProtocolStore.prototype.VerifiedStatus = VerifiedStatus; -})(); diff --git a/preload.js b/preload.js index 386fcf9d40..66e8dc143c 100644 --- a/preload.js +++ b/preload.js @@ -523,6 +523,7 @@ try { require('./ts/backbone/views/whisper_view'); require('./ts/backbone/views/toast_view'); require('./ts/views/conversation_view'); + require('./ts/LibSignalStore'); require('./ts/background'); function wrapWithPromise(fn) { diff --git a/test/index.html b/test/index.html index 540786f111..f79d1d1169 100644 --- a/test/index.html +++ b/test/index.html @@ -339,7 +339,6 @@ - @@ -351,8 +350,6 @@ - - diff --git a/ts/LibSignalStore.ts b/ts/LibSignalStore.ts new file mode 100644 index 0000000000..eb34e1be2b --- /dev/null +++ b/ts/LibSignalStore.ts @@ -0,0 +1,1233 @@ +// Copyright 2016-2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +/* eslint-disable class-methods-use-this */ + +import { fromEncodedBinaryToArrayBuffer, constantTimeEqual } from './Crypto'; +import { isNotNil } from './util/isNotNil'; + +const TIMESTAMP_THRESHOLD = 5 * 1000; // 5 seconds +const Direction = { + SENDING: 1, + RECEIVING: 2, +}; + +const VerifiedStatus = { + DEFAULT: 0, + VERIFIED: 1, + UNVERIFIED: 2, +}; + +function validateVerifiedStatus(status: number): boolean { + if ( + status === VerifiedStatus.DEFAULT || + status === VerifiedStatus.VERIFIED || + status === VerifiedStatus.UNVERIFIED + ) { + return true; + } + return false; +} + +const IdentityRecord = window.Backbone.Model.extend({ + storeName: 'identityKeys', + validAttributes: [ + 'id', + 'publicKey', + 'firstUse', + 'timestamp', + 'verified', + 'nonblockingApproval', + ], + validate(attrs: IdentityKeyType) { + const attributeNames = window._.keys(attrs); + const { validAttributes } = this; + const allValid = window._.all(attributeNames, attributeName => + window._.contains(validAttributes, attributeName) + ); + if (!allValid) { + return new Error('Invalid identity key attribute names'); + } + const allPresent = window._.all(validAttributes, attributeName => + window._.contains(attributeNames, attributeName) + ); + if (!allPresent) { + return new Error('Missing identity key attributes'); + } + + if (typeof attrs.id !== 'string') { + return new Error('Invalid identity key id'); + } + if (!(attrs.publicKey instanceof ArrayBuffer)) { + return new Error('Invalid identity key publicKey'); + } + if (typeof attrs.firstUse !== 'boolean') { + return new Error('Invalid identity key firstUse'); + } + if (typeof attrs.timestamp !== 'number' || !(attrs.timestamp >= 0)) { + return new Error('Invalid identity key timestamp'); + } + if (!validateVerifiedStatus(attrs.verified)) { + return new Error('Invalid identity key verified'); + } + if (typeof attrs.nonblockingApproval !== 'boolean') { + return new Error('Invalid identity key nonblockingApproval'); + } + + return null; + }, +}); + +async function normalizeEncodedAddress( + encodedAddress: string +): Promise { + const [identifier, deviceId] = window.textsecure.utils.unencodeNumber( + encodedAddress + ); + try { + const conv = window.ConversationController.getOrCreate( + identifier, + 'private' + ); + return `${conv.get('id')}.${deviceId}`; + } catch (e) { + window.log.error(`could not get conversation for identifier ${identifier}`); + throw e; + } +} + +type HasIdType = { + id: string | number; +}; + +async function _hydrateCache( + object: SignalProtocolStore, + field: keyof SignalProtocolStore, + itemsPromise: Promise> +): Promise { + const items = await itemsPromise; + + const cache: Record = Object.create(null); + for (let i = 0, max = items.length; i < max; i += 1) { + const item = items[i]; + const { id } = item; + + cache[id] = item; + } + + window.log.info(`SignalProtocolStore: Finished caching ${field} data`); + // eslint-disable-next-line no-param-reassign, @typescript-eslint/no-explicit-any + object[field] = cache as any; +} + +type KeyPairType = { + privKey: ArrayBuffer; + pubKey: ArrayBuffer; +}; + +type IdentityKeyType = { + firstUse: boolean; + id: string; + nonblockingApproval: boolean; + publicKey: ArrayBuffer; + timestamp: number; + verified: number; +}; + +type SessionType = { + conversationId: string; + deviceId: number; + id: string; + record: string; +}; + +type SignedPreKeyType = { + confirmed: boolean; + // eslint-disable-next-line camelcase + created_at: number; + id: number; + privateKey: ArrayBuffer; + publicKey: ArrayBuffer; +}; +type OuterSignedPrekeyType = { + confirmed: boolean; + // eslint-disable-next-line camelcase + created_at: number; + keyId: number; + privKey: ArrayBuffer; + pubKey: ArrayBuffer; +}; +type PreKeyType = { + id: number; + privateKey: ArrayBuffer; + publicKey: ArrayBuffer; +}; + +type UnprocessedType = { + id: string; + timestamp: number; + version: number; + attempts: number; + envelope: string; + decrypted?: string; + source?: string; + sourceDevice: string; + serverTimestamp: number; +}; + +// We add a this parameter to avoid an 'implicit any' error on the next line +const EventsMixin = (function EventsMixin(this: unknown) { + window._.assign(this, window.Backbone.Events); + // eslint-disable-next-line @typescript-eslint/no-explicit-any +} as any) as typeof window.Backbone.EventsMixin; + +export class SignalProtocolStore extends EventsMixin { + // Enums used across the app + + Direction = Direction; + + VerifiedStatus = VerifiedStatus; + + // Cached values + + ourIdentityKey?: KeyPairType; + + ourRegistrationId?: number; + + identityKeys?: Record; + + sessions?: Record; + + signedPreKeys?: Record; + + preKeys?: Record; + + async hydrateCaches(): Promise { + await Promise.all([ + (async () => { + const item = await window.Signal.Data.getItemById('identityKey'); + this.ourIdentityKey = item ? item.value : undefined; + })(), + (async () => { + const item = await window.Signal.Data.getItemById('registrationId'); + this.ourRegistrationId = item ? item.value : undefined; + })(), + _hydrateCache( + this, + 'identityKeys', + window.Signal.Data.getAllIdentityKeys() + ), + _hydrateCache( + this, + 'sessions', + window.Signal.Data.getAllSessions() + ), + _hydrateCache( + this, + 'preKeys', + window.Signal.Data.getAllPreKeys() + ), + _hydrateCache( + this, + 'signedPreKeys', + window.Signal.Data.getAllSignedPreKeys() + ), + ]); + } + + async getIdentityKeyPair(): Promise { + return this.ourIdentityKey; + } + + async getLocalRegistrationId(): Promise { + return this.ourRegistrationId; + } + + // PreKeys + + async loadPreKey(keyId: string | number): Promise { + if (!this.preKeys) { + throw new Error('loadPreKey: this.preKeys not yet cached!'); + } + + const key = this.preKeys[keyId]; + if (key) { + window.log.info('Successfully fetched prekey:', keyId); + return { + pubKey: key.publicKey, + privKey: key.privateKey, + }; + } + + window.log.error('Failed to fetch prekey:', keyId); + return undefined; + } + + async storePreKey(keyId: number, keyPair: KeyPairType): Promise { + if (!this.preKeys) { + throw new Error('storePreKey: this.preKeys not yet cached!'); + } + + const data = { + id: keyId, + publicKey: keyPair.pubKey, + privateKey: keyPair.privKey, + }; + + this.preKeys[keyId] = data; + await window.Signal.Data.createOrUpdatePreKey(data); + } + + async removePreKey(keyId: number): Promise { + if (!this.preKeys) { + throw new Error('removePreKey: this.preKeys not yet cached!'); + } + + try { + this.trigger('removePreKey'); + } catch (error) { + window.log.error( + 'removePreKey error triggering removePreKey:', + error && error.stack ? error.stack : error + ); + } + + delete this.preKeys[keyId]; + await window.Signal.Data.removePreKeyById(keyId); + } + + async clearPreKeyStore(): Promise { + this.preKeys = Object.create(null); + await window.Signal.Data.removeAllPreKeys(); + } + + // Signed PreKeys + + async loadSignedPreKey( + keyId: number + ): Promise { + if (!this.signedPreKeys) { + throw new Error('loadSignedPreKey: this.signedPreKeys not yet cached!'); + } + + const key = this.signedPreKeys[keyId]; + if (key) { + window.log.info('Successfully fetched signed prekey:', key.id); + return { + pubKey: key.publicKey, + privKey: key.privateKey, + created_at: key.created_at, + keyId: key.id, + confirmed: key.confirmed, + }; + } + + window.log.error('Failed to fetch signed prekey:', keyId); + return undefined; + } + + async loadSignedPreKeys(): Promise> { + if (!this.signedPreKeys) { + throw new Error('loadSignedPreKeys: this.signedPreKeys not yet cached!'); + } + + if (arguments.length > 0) { + throw new Error('loadSignedPreKeys takes no arguments'); + } + + const keys = Object.values(this.signedPreKeys); + return keys.map(prekey => ({ + pubKey: prekey.publicKey, + privKey: prekey.privateKey, + created_at: prekey.created_at, + keyId: prekey.id, + confirmed: prekey.confirmed, + })); + } + + async storeSignedPreKey( + keyId: number, + keyPair: KeyPairType, + confirmed?: boolean + ): Promise { + if (!this.signedPreKeys) { + throw new Error('storeSignedPreKey: this.signedPreKeys not yet cached!'); + } + + const data = { + id: keyId, + publicKey: keyPair.pubKey, + privateKey: keyPair.privKey, + created_at: Date.now(), + confirmed: Boolean(confirmed), + }; + + this.signedPreKeys[keyId] = data; + await window.Signal.Data.createOrUpdateSignedPreKey(data); + } + + async removeSignedPreKey(keyId: number): Promise { + if (!this.signedPreKeys) { + throw new Error('removeSignedPreKey: this.signedPreKeys not yet cached!'); + } + + delete this.signedPreKeys[keyId]; + await window.Signal.Data.removeSignedPreKeyById(keyId); + } + + async clearSignedPreKeysStore(): Promise { + this.signedPreKeys = Object.create(null); + await window.Signal.Data.removeAllSignedPreKeys(); + } + + // Sessions + + async loadSession(encodedAddress: string): Promise { + if (!this.sessions) { + throw new Error('loadSession: this.sessions not yet cached!'); + } + + if (encodedAddress === null || encodedAddress === undefined) { + throw new Error('Tried to get session for undefined/null number'); + } + + try { + const id = await normalizeEncodedAddress(encodedAddress); + const session = this.sessions[id]; + + if (session) { + return session.record; + } + } catch (error) { + const errorString = error && error.stack ? error.stack : error; + window.log.error( + `could not load session ${encodedAddress}: ${errorString}` + ); + } + + return undefined; + } + + async storeSession(encodedAddress: string, record: string): Promise { + if (!this.sessions) { + throw new Error('storeSession: this.sessions not yet cached!'); + } + + if (encodedAddress === null || encodedAddress === undefined) { + throw new Error('Tried to put session for undefined/null number'); + } + const unencoded = window.textsecure.utils.unencodeNumber(encodedAddress); + const deviceId = parseInt(unencoded[1], 10); + + try { + const id = await normalizeEncodedAddress(encodedAddress); + const previousData = this.sessions[id]; + + const data = { + id, + conversationId: window.textsecure.utils.unencodeNumber(id)[0], + deviceId, + record, + }; + + // Optimistically update in-memory cache; will revert if save fails. + this.sessions[id] = data; + + try { + await window.Signal.Data.createOrUpdateSession(data); + } catch (e) { + if (previousData) { + this.sessions[id] = previousData; + } + throw e; + } + } catch (error) { + const errorString = error && error.stack ? error.stack : error; + window.log.error( + `could not store session for ${encodedAddress}: ${errorString}` + ); + } + } + + async getDeviceIds(identifier: string): Promise> { + if (!this.sessions) { + throw new Error('getDeviceIds: this.sessions not yet cached!'); + } + if (identifier === null || identifier === undefined) { + throw new Error('Tried to get device ids for undefined/null number'); + } + + try { + const id = window.ConversationController.getConversationId(identifier); + const allSessions = Object.values(this.sessions); + const sessions = allSessions.filter( + session => session.conversationId === id + ); + const openSessions = await Promise.all( + sessions.map(async session => { + const sessionCipher = new window.libsignal.SessionCipher( + window.textsecure.storage.protocol, + session.id + ); + + const hasOpenSession = await sessionCipher.hasOpenSession(); + if (hasOpenSession) { + return session; + } + + return undefined; + }) + ); + + return openSessions.filter(isNotNil).map(item => item.deviceId); + } catch (error) { + window.log.error( + `could not get device ids for identifier ${identifier}`, + error && error.stack ? error.stack : error + ); + } + + return []; + } + + async removeSession(encodedAddress: string): Promise { + if (!this.sessions) { + throw new Error('removeSession: this.sessions not yet cached!'); + } + + window.log.info('removeSession: deleting session for', encodedAddress); + try { + const id = await normalizeEncodedAddress(encodedAddress); + delete this.sessions[id]; + await window.Signal.Data.removeSessionById(id); + } catch (e) { + window.log.error(`could not delete session for ${encodedAddress}`); + } + } + + async removeAllSessions(identifier: string): Promise { + if (!this.sessions) { + throw new Error('removeAllSessions: this.sessions not yet cached!'); + } + + if (identifier === null || identifier === undefined) { + throw new Error('Tried to remove sessions for undefined/null number'); + } + + window.log.info('removeAllSessions: deleting sessions for', identifier); + + const id = window.ConversationController.getConversationId(identifier); + + const allSessions = Object.values(this.sessions); + + for (let i = 0, max = allSessions.length; i < max; i += 1) { + const session = allSessions[i]; + if (session.conversationId === id) { + delete this.sessions[session.id]; + } + } + + await window.Signal.Data.removeSessionsByConversation(identifier); + } + + async archiveSiblingSessions(identifier: string): Promise { + if (!this.sessions) { + throw new Error('archiveSiblingSessions: this.sessions not yet cached!'); + } + + window.log.info( + 'archiveSiblingSessions: archiving sibling sessions for', + identifier + ); + + const address = window.libsignal.SignalProtocolAddress.fromString( + identifier + ); + + const deviceIds = await this.getDeviceIds(address.getName()); + const siblings = window._.without(deviceIds, address.getDeviceId()); + + await Promise.all( + siblings.map(async deviceId => { + const sibling = new window.libsignal.SignalProtocolAddress( + address.getName(), + deviceId + ); + window.log.info( + 'archiveSiblingSessions: closing session for', + sibling.toString() + ); + const sessionCipher = new window.libsignal.SessionCipher( + window.textsecure.storage.protocol, + sibling + ); + await sessionCipher.closeOpenSessionForDevice(); + }) + ); + } + + async archiveAllSessions(identifier: string): Promise { + if (!this.sessions) { + throw new Error('archiveAllSessions: this.sessions not yet cached!'); + } + + window.log.info( + 'archiveAllSessions: archiving all sessions for', + identifier + ); + + const deviceIds = await this.getDeviceIds(identifier); + + await Promise.all( + deviceIds.map(async deviceId => { + const address = new window.libsignal.SignalProtocolAddress( + identifier, + deviceId + ); + window.log.info( + 'archiveAllSessions: closing session for', + address.toString() + ); + const sessionCipher = new window.libsignal.SessionCipher( + window.textsecure.storage.protocol, + address + ); + await sessionCipher.closeOpenSessionForDevice(); + }) + ); + } + + async clearSessionStore(): Promise { + this.sessions = Object.create(null); + window.Signal.Data.removeAllSessions(); + } + + // Identity Keys + + getIdentityRecord(identifier: string): IdentityKeyType | undefined { + if (!this.identityKeys) { + throw new Error('getIdentityRecord: this.identityKeys not yet cached!'); + } + + try { + const id = window.ConversationController.getConversationId(identifier); + if (!id) { + throw new Error( + `getIdentityRecord: No conversation id for identifier ${identifier}` + ); + } + + const record = this.identityKeys[id]; + + if (record) { + return record; + } + } catch (e) { + window.log.error( + `could not get identity record for identifier ${identifier}` + ); + } + + return undefined; + } + + async isTrustedIdentity( + encodedAddress: string, + publicKey: ArrayBuffer, + direction: number + ): Promise { + if (!this.identityKeys) { + throw new Error('getIdentityRecord: this.identityKeys not yet cached!'); + } + + if (encodedAddress === null || encodedAddress === undefined) { + throw new Error('Tried to get identity key for undefined/null key'); + } + const identifier = window.textsecure.utils.unencodeNumber( + encodedAddress + )[0]; + const ourNumber = window.textsecure.storage.user.getNumber(); + const ourUuid = window.textsecure.storage.user.getUuid(); + const isOurIdentifier = + (ourNumber && identifier === ourNumber) || + (ourUuid && identifier === ourUuid); + + const identityRecord = this.getIdentityRecord(identifier); + + if (isOurIdentifier) { + if (identityRecord && identityRecord.publicKey) { + return constantTimeEqual(identityRecord.publicKey, publicKey); + } + window.log.warn( + 'isTrustedIdentity: No local record for our own identifier. Returning true.' + ); + return true; + } + + switch (direction) { + case Direction.SENDING: + return this.isTrustedForSending(publicKey, identityRecord); + case Direction.RECEIVING: + return true; + default: + throw new Error(`Unknown direction: ${direction}`); + } + } + + isTrustedForSending( + publicKey: ArrayBuffer, + identityRecord?: IdentityKeyType + ): boolean { + if (!identityRecord) { + window.log.info( + 'isTrustedForSending: No previous record, returning true...' + ); + return true; + } + + const existing = identityRecord.publicKey; + + if (!existing) { + window.log.info('isTrustedForSending: Nothing here, returning true...'); + return true; + } + if (!constantTimeEqual(existing, publicKey)) { + window.log.info("isTrustedForSending: Identity keys don't match..."); + return false; + } + if (identityRecord.verified === VerifiedStatus.UNVERIFIED) { + window.log.error('Needs unverified approval!'); + return false; + } + if (this.isNonBlockingApprovalRequired(identityRecord)) { + window.log.error('isTrustedForSending: Needs non-blocking approval!'); + return false; + } + + return true; + } + + async loadIdentityKey(identifier: string): Promise { + if (identifier === null || identifier === undefined) { + throw new Error('Tried to get identity key for undefined/null key'); + } + const id = window.textsecure.utils.unencodeNumber(identifier)[0]; + const identityRecord = this.getIdentityRecord(id); + + if (identityRecord) { + return identityRecord.publicKey; + } + + return undefined; + } + + private async _saveIdentityKey(data: IdentityKeyType): Promise { + if (!this.identityKeys) { + throw new Error('_saveIdentityKey: this.identityKeys not yet cached!'); + } + + const { id } = data; + + const previousData = this.identityKeys[id]; + + // Optimistically update in-memory cache; will revert if save fails. + this.identityKeys[id] = data; + + try { + await window.Signal.Data.createOrUpdateIdentityKey(data); + } catch (error) { + if (previousData) { + this.identityKeys[id] = previousData; + } + + throw error; + } + } + + async saveIdentity( + encodedAddress: string, + publicKey: ArrayBuffer, + nonblockingApproval: boolean + ): Promise { + if (!this.identityKeys) { + throw new Error('saveIdentity: this.identityKeys not yet cached!'); + } + + if (encodedAddress === null || encodedAddress === undefined) { + throw new Error('Tried to put identity key for undefined/null key'); + } + if (!(publicKey instanceof ArrayBuffer)) { + // eslint-disable-next-line no-param-reassign + publicKey = fromEncodedBinaryToArrayBuffer(publicKey); + } + if (typeof nonblockingApproval !== 'boolean') { + // eslint-disable-next-line no-param-reassign + nonblockingApproval = false; + } + + const identifier = window.textsecure.utils.unencodeNumber( + encodedAddress + )[0]; + const identityRecord = this.getIdentityRecord(identifier); + const id = window.ConversationController.getOrCreate( + identifier, + 'private' + ).get('id'); + + if (!identityRecord || !identityRecord.publicKey) { + // Lookup failed, or the current key was removed, so save this one. + window.log.info('Saving new identity...'); + await this._saveIdentityKey({ + id, + publicKey, + firstUse: true, + timestamp: Date.now(), + verified: VerifiedStatus.DEFAULT, + nonblockingApproval, + }); + + return false; + } + + const oldpublicKey = identityRecord.publicKey; + if (!constantTimeEqual(oldpublicKey, publicKey)) { + window.log.info('Replacing existing identity...'); + const previousStatus = identityRecord.verified; + let verifiedStatus; + if ( + previousStatus === VerifiedStatus.VERIFIED || + previousStatus === VerifiedStatus.UNVERIFIED + ) { + verifiedStatus = VerifiedStatus.UNVERIFIED; + } else { + verifiedStatus = VerifiedStatus.DEFAULT; + } + + await this._saveIdentityKey({ + id, + publicKey, + firstUse: false, + timestamp: Date.now(), + verified: verifiedStatus, + nonblockingApproval, + }); + + try { + this.trigger('keychange', identifier); + } catch (error) { + window.log.error( + 'saveIdentity error triggering keychange:', + error && error.stack ? error.stack : error + ); + } + await this.archiveSiblingSessions(encodedAddress); + + return true; + } + if (this.isNonBlockingApprovalRequired(identityRecord)) { + window.log.info('Setting approval status...'); + + identityRecord.nonblockingApproval = nonblockingApproval; + await this._saveIdentityKey(identityRecord); + + return false; + } + + return false; + } + + isNonBlockingApprovalRequired(identityRecord: IdentityKeyType): boolean { + return ( + !identityRecord.firstUse && + Date.now() - identityRecord.timestamp < TIMESTAMP_THRESHOLD && + !identityRecord.nonblockingApproval + ); + } + + async saveIdentityWithAttributes( + encodedAddress: string, + attributes: IdentityKeyType + ): Promise { + if (encodedAddress === null || encodedAddress === undefined) { + throw new Error('Tried to put identity key for undefined/null key'); + } + + const identifier = window.textsecure.utils.unencodeNumber( + encodedAddress + )[0]; + const identityRecord = this.getIdentityRecord(identifier); + const conv = window.ConversationController.getOrCreate( + identifier, + 'private' + ); + const id = conv.get('id'); + + const updates = { + ...identityRecord, + ...attributes, + id, + }; + + const model = new IdentityRecord(updates); + if (model.isValid()) { + await this._saveIdentityKey(updates); + } else { + throw model.validationError; + } + } + + async setApproval( + encodedAddress: string, + nonblockingApproval: boolean + ): Promise { + if (encodedAddress === null || encodedAddress === undefined) { + throw new Error('Tried to set approval for undefined/null identifier'); + } + if (typeof nonblockingApproval !== 'boolean') { + throw new Error('Invalid approval status'); + } + + const identifier = window.textsecure.utils.unencodeNumber( + encodedAddress + )[0]; + const identityRecord = this.getIdentityRecord(identifier); + + if (!identityRecord) { + throw new Error(`No identity record for ${identifier}`); + } + + identityRecord.nonblockingApproval = nonblockingApproval; + await this._saveIdentityKey(identityRecord); + } + + async setVerified( + encodedAddress: string, + verifiedStatus: number, + publicKey: ArrayBuffer + ): Promise { + if (encodedAddress === null || encodedAddress === undefined) { + throw new Error('Tried to set verified for undefined/null key'); + } + if (!validateVerifiedStatus(verifiedStatus)) { + throw new Error('Invalid verified status'); + } + if (arguments.length > 2 && !(publicKey instanceof ArrayBuffer)) { + throw new Error('Invalid public key'); + } + + const identityRecord = this.getIdentityRecord(encodedAddress); + + if (!identityRecord) { + throw new Error(`No identity record for ${encodedAddress}`); + } + + if (!publicKey || constantTimeEqual(identityRecord.publicKey, publicKey)) { + identityRecord.verified = verifiedStatus; + + const model = new IdentityRecord(identityRecord); + if (model.isValid()) { + await this._saveIdentityKey(identityRecord); + } else if (model.validationError) { + throw model.validationError; + } else { + throw new Error('setVerified: identity record data was invalid'); + } + } else { + window.log.info('No identity record for specified publicKey'); + } + } + + async getVerified(identifier: string): Promise { + if (identifier === null || identifier === undefined) { + throw new Error('Tried to set verified for undefined/null key'); + } + + const identityRecord = this.getIdentityRecord(identifier); + if (!identityRecord) { + throw new Error(`No identity record for ${identifier}`); + } + + const verifiedStatus = identityRecord.verified; + if (validateVerifiedStatus(verifiedStatus)) { + return verifiedStatus; + } + + return VerifiedStatus.DEFAULT; + } + + // Resolves to true if a new identity key was saved + processContactSyncVerificationState( + identifier: string, + verifiedStatus: number, + publicKey: ArrayBuffer + ): Promise { + if (verifiedStatus === VerifiedStatus.UNVERIFIED) { + return this.processUnverifiedMessage( + identifier, + verifiedStatus, + publicKey + ); + } + return this.processVerifiedMessage(identifier, verifiedStatus, publicKey); + } + + // This function encapsulates the non-Java behavior, since the mobile apps don't + // currently receive contact syncs and therefore will see a verify sync with + // UNVERIFIED status + async processUnverifiedMessage( + identifier: string, + verifiedStatus: number, + publicKey?: ArrayBuffer + ): Promise { + if (identifier === null || identifier === undefined) { + throw new Error('Tried to set verified for undefined/null key'); + } + if (publicKey !== undefined && !(publicKey instanceof ArrayBuffer)) { + throw new Error('Invalid public key'); + } + + const identityRecord = this.getIdentityRecord(identifier); + + let isEqual = false; + + if (identityRecord && publicKey) { + isEqual = constantTimeEqual(publicKey, identityRecord.publicKey); + } + + if ( + identityRecord && + isEqual && + identityRecord.verified !== VerifiedStatus.UNVERIFIED + ) { + await window.textsecure.storage.protocol.setVerified( + identifier, + verifiedStatus, + publicKey + ); + return false; + } + + if (publicKey && (!identityRecord || !isEqual)) { + await window.textsecure.storage.protocol.saveIdentityWithAttributes( + identifier, + { + publicKey, + verified: verifiedStatus, + firstUse: false, + timestamp: Date.now(), + nonblockingApproval: true, + } + ); + + if (identityRecord && !isEqual) { + try { + this.trigger('keychange', identifier); + } catch (error) { + window.log.error( + 'processUnverifiedMessage error triggering keychange:', + error && error.stack ? error.stack : error + ); + } + + await this.archiveAllSessions(identifier); + + return true; + } + } + + // The situation which could get us here is: + // 1. had a previous key + // 2. new key is the same + // 3. desired new status is same as what we had before + // 4. no publicKey was passed into this function + return false; + } + + // This matches the Java method as of + // https://github.com/signalapp/Signal-Android/blob/d0bb68e1378f689e4d10ac6a46014164992ca4e4/src/org/thoughtcrime/securesms/util/IdentityUtil.java#L188 + async processVerifiedMessage( + identifier: string, + verifiedStatus: number, + publicKey: ArrayBuffer + ): Promise { + if (identifier === null || identifier === undefined) { + throw new Error('Tried to set verified for undefined/null key'); + } + if (!validateVerifiedStatus(verifiedStatus)) { + throw new Error('Invalid verified status'); + } + if (publicKey !== undefined && !(publicKey instanceof ArrayBuffer)) { + throw new Error('Invalid public key'); + } + + const identityRecord = this.getIdentityRecord(identifier); + + let isEqual = false; + + if (identityRecord && publicKey) { + isEqual = constantTimeEqual(publicKey, identityRecord.publicKey); + } + + if (!identityRecord && verifiedStatus === VerifiedStatus.DEFAULT) { + window.log.info('No existing record for default status'); + return false; + } + + if ( + identityRecord && + isEqual && + identityRecord.verified !== VerifiedStatus.DEFAULT && + verifiedStatus === VerifiedStatus.DEFAULT + ) { + await window.textsecure.storage.protocol.setVerified( + identifier, + verifiedStatus, + publicKey + ); + return false; + } + + if ( + verifiedStatus === VerifiedStatus.VERIFIED && + (!identityRecord || + (identityRecord && !isEqual) || + (identityRecord && identityRecord.verified !== VerifiedStatus.VERIFIED)) + ) { + await window.textsecure.storage.protocol.saveIdentityWithAttributes( + identifier, + { + publicKey, + verified: verifiedStatus, + firstUse: false, + timestamp: Date.now(), + nonblockingApproval: true, + } + ); + + if (identityRecord && !isEqual) { + try { + this.trigger('keychange', identifier); + } catch (error) { + window.log.error( + 'processVerifiedMessage error triggering keychange:', + error && error.stack ? error.stack : error + ); + } + + await this.archiveAllSessions(identifier); + + // true signifies that we overwrote a previous key with a new one + return true; + } + } + + // We get here if we got a new key and the status is DEFAULT. If the + // message is out of date, we don't want to lose whatever more-secure + // state we had before. + return false; + } + + isUntrusted(identifier: string): boolean { + if (identifier === null || identifier === undefined) { + throw new Error('Tried to set verified for undefined/null key'); + } + + const identityRecord = this.getIdentityRecord(identifier); + if (!identityRecord) { + throw new Error(`No identity record for ${identifier}`); + } + + if ( + Date.now() - identityRecord.timestamp < TIMESTAMP_THRESHOLD && + !identityRecord.nonblockingApproval && + !identityRecord.firstUse + ) { + return true; + } + + return false; + } + + async removeIdentityKey(identifier: string): Promise { + if (!this.identityKeys) { + throw new Error('removeIdentityKey: this.identityKeys not yet cached!'); + } + + const id = window.ConversationController.getConversationId(identifier); + if (id) { + delete this.identityKeys[id]; + await window.Signal.Data.removeIdentityKeyById(id); + await window.textsecure.storage.protocol.removeAllSessions(id); + } + } + + // Not yet processed messages - for resiliency + getUnprocessedCount(): Promise { + return window.Signal.Data.getUnprocessedCount(); + } + + getAllUnprocessed(): Promise> { + return window.Signal.Data.getAllUnprocessed(); + } + + getUnprocessedById(id: string): Promise { + return window.Signal.Data.getUnprocessedById(id); + } + + addUnprocessed(data: UnprocessedType): Promise { + // We need to pass forceSave because the data has an id already, which will cause + // an update instead of an insert. + return window.Signal.Data.saveUnprocessed(data, { + forceSave: true, + }); + } + + addMultipleUnprocessed(array: Array): Promise { + // We need to pass forceSave because the data has an id already, which will cause + // an update instead of an insert. + return window.Signal.Data.saveUnprocesseds(array, { + forceSave: true, + }); + } + + updateUnprocessedAttempts(id: string, attempts: number): Promise { + return window.Signal.Data.updateUnprocessedAttempts(id, attempts); + } + + updateUnprocessedWithData(id: string, data: UnprocessedType): Promise { + return window.Signal.Data.updateUnprocessedWithData(id, data); + } + + updateUnprocessedsWithData(items: Array): Promise { + return window.Signal.Data.updateUnprocessedsWithData(items); + } + + removeUnprocessed(idOrArray: string | Array): Promise { + return window.Signal.Data.removeUnprocessed(idOrArray); + } + + removeAllUnprocessed(): Promise { + return window.Signal.Data.removeAllUnprocessed(); + } + + async removeAllData(): Promise { + await window.Signal.Data.removeAll(); + await this.hydrateCaches(); + + window.storage.reset(); + await window.storage.fetch(); + + window.ConversationController.reset(); + await window.ConversationController.load(); + } + + async removeAllConfiguration(): Promise { + await window.Signal.Data.removeAllConfiguration(); + await this.hydrateCaches(); + + window.storage.reset(); + await window.storage.fetch(); + } +} + +window.SignalProtocolStore = SignalProtocolStore; diff --git a/ts/libsignal.d.ts b/ts/libsignal.d.ts index e348c8ecc0..a9f581b99b 100644 --- a/ts/libsignal.d.ts +++ b/ts/libsignal.d.ts @@ -201,7 +201,7 @@ declare class SessionBuilderClass { export declare class SessionCipherClass { constructor( storage: StorageType, - remoteAddress: SignalProtocolAddressClass, + remoteAddress: SignalProtocolAddressClass | string, options?: { messageKeysLimit?: number | boolean } ); closeOpenSessionForDevice: () => Promise; diff --git a/ts/sql/Client.ts b/ts/sql/Client.ts index 14cdc41b90..728dd33adc 100644 --- a/ts/sql/Client.ts +++ b/ts/sql/Client.ts @@ -1226,7 +1226,7 @@ async function updateUnprocessedsWithData(array: Array) { await channels.updateUnprocessedsWithData(array); } -async function removeUnprocessed(id: string) { +async function removeUnprocessed(id: string | Array) { await channels.removeUnprocessed(id); } diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 4693255631..848f37ea35 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -129,7 +129,7 @@ export type DataInterface = { arrayOfUnprocessed: Array, options?: { forceSave?: boolean } ) => Promise; - removeUnprocessed: (id: string) => Promise; + removeUnprocessed: (id: string | Array) => Promise; removeAllUnprocessed: () => Promise; getNextAttachmentDownloadJobs: ( diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index f849659029..97b8bacd69 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -3410,7 +3410,7 @@ async function getAllUnprocessed() { return rows; } -async function removeUnprocessed(id: string) { +async function removeUnprocessed(id: string | Array) { const db = getInstance(); if (!Array.isArray(id)) { diff --git a/ts/test-both/util/isNotNil_test.ts b/ts/test-both/util/isNotNil_test.ts new file mode 100644 index 0000000000..1cdcf8343a --- /dev/null +++ b/ts/test-both/util/isNotNil_test.ts @@ -0,0 +1,24 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import { isNotNil } from '../../util/isNotNil'; + +describe('isNotNil', () => { + it('returns false if provided null value', () => { + assert.isFalse(isNotNil(null)); + }); + + it('returns false is provided undefined value', () => { + assert.isFalse(isNotNil(undefined)); + }); + + it('returns false is provided any other value', () => { + assert.isTrue(isNotNil(0)); + assert.isTrue(isNotNil(4)); + assert.isTrue(isNotNil('')); + assert.isTrue(isNotNil('string value')); + assert.isTrue(isNotNil({})); + }); +}); diff --git a/ts/textsecure.d.ts b/ts/textsecure.d.ts index 4543432b55..5e94fc1fd9 100644 --- a/ts/textsecure.d.ts +++ b/ts/textsecure.d.ts @@ -147,6 +147,7 @@ export type StorageProtocolType = StorageType & { publicKey?: ArrayBuffer ) => Promise; removeSignedPreKey: (keyId: number) => Promise; + removeAllSessions: (identifier: string) => Promise; removeAllData: () => Promise; on: (key: string, callback: () => void) => WhatIsThis; removeAllConfiguration: () => Promise; diff --git a/ts/util/isNotNil.ts b/ts/util/isNotNil.ts new file mode 100644 index 0000000000..c60598407b --- /dev/null +++ b/ts/util/isNotNil.ts @@ -0,0 +1,9 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export function isNotNil(value: T | null | undefined): value is T { + if (value === null || value === undefined) { + return false; + } + return true; +} diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 1872069bda..fd5779a120 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -281,14 +281,6 @@ "updated": "2020-08-21T11:29:29.636Z", "reasonDetail": "Interacting with already-existing DOM nodes" }, - { - "rule": "jQuery-load(", - "path": "js/signal_protocol_store.js", - "line": " await ConversationController.load();", - "lineNumber": 1035, - "reasonCategory": "falseMatch", - "updated": "2020-06-12T14:20:09.936Z" - }, { "rule": "DOM-innerHTML", "path": "js/views/app_view.js", @@ -14326,6 +14318,22 @@ "reasonCategory": "falseMatch", "updated": "2020-07-21T18:34:59.251Z" }, + { + "rule": "jQuery-load(", + "path": "ts/LibSignalStore.js", + "line": " await window.ConversationController.load();", + "lineNumber": 810, + "reasonCategory": "falseMatch", + "updated": "2021-02-27T00:48:49.313Z" + }, + { + "rule": "jQuery-load(", + "path": "ts/LibSignalStore.ts", + "line": " await window.ConversationController.load();", + "lineNumber": 1221, + "reasonCategory": "falseMatch", + "updated": "2021-02-27T00:48:49.313Z" + }, { "rule": "DOM-innerHTML", "path": "ts/backbone/views/Lightbox.js", diff --git a/ts/window.d.ts b/ts/window.d.ts index 5879559d7b..d2b953b39b 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -93,6 +93,7 @@ import { Quote } from './components/conversation/Quote'; import { StagedLinkPreview } from './components/conversation/StagedLinkPreview'; import { MIMEType } from './types/MIME'; import { ElectronLocaleType } from './util/mapToSupportLocale'; +import { SignalProtocolStore } from './LibSignalStore'; export { Long } from 'long'; @@ -238,6 +239,7 @@ declare global { removeBlockedGroup: (group: string) => void; removeBlockedNumber: (number: string) => void; removeBlockedUuid: (uuid: string) => void; + reset: () => void; }; systemTheme: WhatIsThis; textsecure: TextSecureType; @@ -512,6 +514,7 @@ declare global { ConversationController: ConversationController; Events: WhatIsThis; MessageController: MessageControllerType; + SignalProtocolStore: typeof SignalProtocolStore; WebAPI: WebAPIConnectType; Whisper: WhisperType; From c9ffb7c01476dd8de4da5e991a606b4428f42a91 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Fri, 26 Feb 2021 16:00:37 -0800 Subject: [PATCH 033/181] Move SecretSessionCipher to TypeScript --- js/modules/metadata/CiphertextMessage.js | 16 - js/modules/signal.js | 2 - test/index.html | 2 - ts/libsignal.d.ts | 1 + ts/metadata/CiphertextMessage.ts | 14 + .../metadata/SecretSessionCipher.ts | 449 ++++++++++++------ .../metadata/SecretSessionCipher_test.ts | 245 +++++----- ts/textsecure.d.ts | 96 +++- ts/textsecure/MessageReceiver.ts | 14 +- ts/textsecure/OutgoingMessage.ts | 10 +- ts/textsecure/SendMessage.ts | 3 +- 11 files changed, 569 insertions(+), 283 deletions(-) delete mode 100644 js/modules/metadata/CiphertextMessage.js create mode 100644 ts/metadata/CiphertextMessage.ts rename js/modules/metadata/SecretSessionCipher.js => ts/metadata/SecretSessionCipher.ts (55%) rename test/metadata/SecretSessionCipher_test.js => ts/test-electron/metadata/SecretSessionCipher_test.ts (65%) diff --git a/js/modules/metadata/CiphertextMessage.js b/js/modules/metadata/CiphertextMessage.js deleted file mode 100644 index d38bdd0771..0000000000 --- a/js/modules/metadata/CiphertextMessage.js +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright 2018-2020 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -module.exports = { - CURRENT_VERSION: 3, - - // This matches Envelope.Type.CIPHERTEXT - WHISPER_TYPE: 1, - // This matches Envelope.Type.PREKEY_BUNDLE - PREKEY_TYPE: 3, - - SENDERKEY_TYPE: 4, - SENDERKEY_DISTRIBUTION_TYPE: 5, - - ENCRYPTED_MESSAGE_OVERHEAD: 53, -}; diff --git a/js/modules/signal.js b/js/modules/signal.js index 1daad5214e..ddc13530a5 100644 --- a/js/modules/signal.js +++ b/js/modules/signal.js @@ -21,7 +21,6 @@ const Stickers = require('./stickers'); const Settings = require('./settings'); const RemoteConfig = require('../../ts/RemoteConfig'); const Util = require('../../ts/util'); -const Metadata = require('./metadata/SecretSessionCipher'); const RefreshSenderCertificate = require('./refresh_sender_certificate'); const LinkPreviews = require('./link_previews'); const AttachmentDownloads = require('./attachment_downloads'); @@ -428,7 +427,6 @@ exports.setup = (options = {}) => { GroupChange, IndexedDB, LinkPreviews, - Metadata, Migrations, Notifications, OS, diff --git a/test/index.html b/test/index.html index f79d1d1169..7c179b89d0 100644 --- a/test/index.html +++ b/test/index.html @@ -360,8 +360,6 @@ - - diff --git a/ts/libsignal.d.ts b/ts/libsignal.d.ts index a9f581b99b..09894b9a43 100644 --- a/ts/libsignal.d.ts +++ b/ts/libsignal.d.ts @@ -223,6 +223,7 @@ export declare class SessionCipherClass { body: string; }>; getRecord: () => Promise; + getSessionVersion: () => Promise; getRemoteRegistrationId: () => Promise; hasOpenSession: () => Promise; } diff --git a/ts/metadata/CiphertextMessage.ts b/ts/metadata/CiphertextMessage.ts new file mode 100644 index 0000000000..9f7e84b22f --- /dev/null +++ b/ts/metadata/CiphertextMessage.ts @@ -0,0 +1,14 @@ +// Copyright 2018-2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export const CURRENT_VERSION = 3; + +// This matches Envelope.Type.CIPHERTEXT +export const WHISPER_TYPE = 1; +// This matches Envelope.Type.PREKEY_BUNDLE +export const PREKEY_TYPE = 3; + +export const SENDERKEY_TYPE = 4; +export const SENDERKEY_DISTRIBUTION_TYPE = 5; + +export const ENCRYPTED_MESSAGE_OVERHEAD = 53; diff --git a/js/modules/metadata/SecretSessionCipher.js b/ts/metadata/SecretSessionCipher.ts similarity index 55% rename from js/modules/metadata/SecretSessionCipher.js rename to ts/metadata/SecretSessionCipher.ts index b4c4684757..a092d0e5bb 100644 --- a/js/modules/metadata/SecretSessionCipher.js +++ b/ts/metadata/SecretSessionCipher.ts @@ -1,12 +1,10 @@ -// Copyright 2018-2020 Signal Messenger, LLC +// Copyright 2018-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -/* global libsignal, textsecure */ +/* eslint-disable class-methods-use-this */ -/* eslint-disable no-bitwise */ - -const CiphertextMessage = require('./CiphertextMessage'); -const { +import * as CiphertextMessage from './CiphertextMessage'; +import { bytesFromString, concatenateBytes, constantTimeEqual, @@ -20,44 +18,111 @@ const { intsToByteHighAndLow, splitBytes, trimBytes, -} = require('../../../ts/Crypto'); +} from '../Crypto'; -const REVOKED_CERTIFICATES = []; - -function SecretSessionCipher(storage, options) { - this.storage = storage; - - // We do this on construction because libsignal won't be available when this file loads - const { SessionCipher } = libsignal; - this.SessionCipher = SessionCipher; - - this.options = options || {}; -} +import { SignalProtocolAddressClass } from '../libsignal.d'; +const REVOKED_CERTIFICATES: Array = []; const CIPHERTEXT_VERSION = 1; const UNIDENTIFIED_DELIVERY_PREFIX = 'UnidentifiedDelivery'; +type MeType = { + number?: string; + uuid?: string; + deviceId: number; +}; + +type ValidatorType = { + validate( + certificate: SenderCertificateType, + validationTime: number + ): Promise; +}; + +export type SerializedCertificateType = { + serialized: ArrayBuffer; +}; + +type ServerCertificateType = { + id: number; + key: ArrayBuffer; +}; + +type ServerCertificateWrapperType = { + certificate: ArrayBuffer; + signature: ArrayBuffer; +}; + +type SenderCertificateType = { + sender?: string; + senderUuid?: string; + senderDevice: number; + expires: number; + identityKey: ArrayBuffer; + signer: ServerCertificateType; +}; + +type SenderCertificateWrapperType = { + certificate: ArrayBuffer; + signature: ArrayBuffer; +}; + +type MessageType = { + ephemeralPublic: ArrayBuffer; + encryptedStatic: ArrayBuffer; + encryptedMessage: ArrayBuffer; +}; + +type InnerMessageType = { + type: number; + senderCertificate: SenderCertificateWrapperType; + content: ArrayBuffer; +}; + +export type ExplodedServerCertificateType = ServerCertificateType & + ServerCertificateWrapperType & + SerializedCertificateType; + +export type ExplodedSenderCertificateType = SenderCertificateType & + SenderCertificateWrapperType & + SerializedCertificateType & { + signer: ExplodedServerCertificateType; + }; + +type ExplodedMessageType = MessageType & + SerializedCertificateType & { version: number }; + +type ExplodedInnerMessageType = InnerMessageType & + SerializedCertificateType & { + senderCertificate: ExplodedSenderCertificateType; + }; + // public CertificateValidator(ECPublicKey trustRoot) -function createCertificateValidator(trustRoot) { +export function createCertificateValidator( + trustRoot: ArrayBuffer +): ValidatorType { return { // public void validate(SenderCertificate certificate, long validationTime) - async validate(certificate, validationTime) { + async validate( + certificate: ExplodedSenderCertificateType, + validationTime: number + ): Promise { const serverCertificate = certificate.signer; - await libsignal.Curve.async.verifySignature( + await window.libsignal.Curve.async.verifySignature( trustRoot, serverCertificate.certificate, serverCertificate.signature ); - const serverCertId = serverCertificate.certificate.id; + const serverCertId = serverCertificate.id; if (REVOKED_CERTIFICATES.includes(serverCertId)) { throw new Error( `Server certificate id ${serverCertId} has been revoked` ); } - await libsignal.Curve.async.verifySignature( + await window.libsignal.Curve.async.verifySignature( serverCertificate.key, certificate.certificate, certificate.signature @@ -70,24 +135,28 @@ function createCertificateValidator(trustRoot) { }; } -function _decodePoint(serialized, offset = 0) { +function _decodePoint(serialized: ArrayBuffer, offset = 0): ArrayBuffer { const view = offset > 0 ? getViewOfArrayBuffer(serialized, offset, serialized.byteLength) : serialized; - return libsignal.Curve.validatePubKeyFormat(view); + return window.libsignal.Curve.validatePubKeyFormat(view); } // public ServerCertificate(byte[] serialized) -function _createServerCertificateFromBuffer(serialized) { - const wrapper = textsecure.protobuf.ServerCertificate.decode(serialized); +export function _createServerCertificateFromBuffer( + serialized: ArrayBuffer +): ExplodedServerCertificateType { + const wrapper = window.textsecure.protobuf.ServerCertificate.decode( + serialized + ); if (!wrapper.certificate || !wrapper.signature) { throw new Error('Missing fields'); } - const certificate = textsecure.protobuf.ServerCertificate.Certificate.decode( + const certificate = window.textsecure.protobuf.ServerCertificate.Certificate.decode( wrapper.certificate.toArrayBuffer() ); @@ -106,54 +175,70 @@ function _createServerCertificateFromBuffer(serialized) { } // public SenderCertificate(byte[] serialized) -function _createSenderCertificateFromBuffer(serialized) { - const wrapper = textsecure.protobuf.SenderCertificate.decode(serialized); +export function _createSenderCertificateFromBuffer( + serialized: ArrayBuffer +): ExplodedSenderCertificateType { + const wrapper = window.textsecure.protobuf.SenderCertificate.decode( + serialized + ); - if (!wrapper.signature || !wrapper.certificate) { + const { signature, certificate } = wrapper; + + if (!signature || !certificate) { throw new Error('Missing fields'); } - const certificate = textsecure.protobuf.SenderCertificate.Certificate.decode( + const senderCertificate = window.textsecure.protobuf.SenderCertificate.Certificate.decode( wrapper.certificate.toArrayBuffer() ); + const { + signer, + identityKey, + senderDevice, + expires, + sender, + senderUuid, + } = senderCertificate; + if ( - !certificate.signer || - !certificate.identityKey || - !certificate.senderDevice || - !certificate.expires || - !(certificate.sender || certificate.senderUuid) + !signer || + !identityKey || + !senderDevice || + !expires || + !(sender || senderUuid) ) { throw new Error('Missing fields'); } return { - sender: certificate.sender, - senderUuid: certificate.senderUuid, - senderDevice: certificate.senderDevice, - expires: certificate.expires.toNumber(), - identityKey: certificate.identityKey.toArrayBuffer(), - signer: _createServerCertificateFromBuffer( - certificate.signer.toArrayBuffer() - ), + sender, + senderUuid, + senderDevice, + expires: expires.toNumber(), + identityKey: identityKey.toArrayBuffer(), + signer: _createServerCertificateFromBuffer(signer.toArrayBuffer()), - certificate: wrapper.certificate.toArrayBuffer(), - signature: wrapper.signature.toArrayBuffer(), + certificate: certificate.toArrayBuffer(), + signature: signature.toArrayBuffer(), serialized, }; } // public UnidentifiedSenderMessage(byte[] serialized) -function _createUnidentifiedSenderMessageFromBuffer(serialized) { - const version = highBitsToInt(serialized[0]); +function _createUnidentifiedSenderMessageFromBuffer( + serialized: ArrayBuffer +): ExplodedMessageType { + const uintArray = new Uint8Array(serialized); + const version = highBitsToInt(uintArray[0]); if (version > CIPHERTEXT_VERSION) { - throw new Error(`Unknown version: ${this.version}`); + throw new Error(`Unknown version: ${version}`); } const view = getViewOfArrayBuffer(serialized, 1, serialized.byteLength); - const unidentifiedSenderMessage = textsecure.protobuf.UnidentifiedSenderMessage.decode( + const unidentifiedSenderMessage = window.textsecure.protobuf.UnidentifiedSenderMessage.decode( view ); @@ -179,20 +264,20 @@ function _createUnidentifiedSenderMessageFromBuffer(serialized) { // public UnidentifiedSenderMessage( // ECPublicKey ephemeral, byte[] encryptedStatic, byte[] encryptedMessage) { function _createUnidentifiedSenderMessage( - ephemeralPublic, - encryptedStatic, - encryptedMessage -) { + ephemeralPublic: ArrayBuffer, + encryptedStatic: ArrayBuffer, + encryptedMessage: ArrayBuffer +): ExplodedMessageType { const versionBytes = new Uint8Array([ intsToByteHighAndLow(CIPHERTEXT_VERSION, CIPHERTEXT_VERSION), ]); - const unidentifiedSenderMessage = new textsecure.protobuf.UnidentifiedSenderMessage(); + const unidentifiedSenderMessage = new window.textsecure.protobuf.UnidentifiedSenderMessage(); unidentifiedSenderMessage.encryptedMessage = encryptedMessage; unidentifiedSenderMessage.encryptedStatic = encryptedStatic; unidentifiedSenderMessage.ephemeralPublic = ephemeralPublic; - const messageBytes = unidentifiedSenderMessage.encode().toArrayBuffer(); + const messageBytes = unidentifiedSenderMessage.toArrayBuffer(); return { version: CIPHERTEXT_VERSION, @@ -206,10 +291,13 @@ function _createUnidentifiedSenderMessage( } // public UnidentifiedSenderMessageContent(byte[] serialized) -function _createUnidentifiedSenderMessageContentFromBuffer(serialized) { - const TypeEnum = textsecure.protobuf.UnidentifiedSenderMessage.Message.Type; +function _createUnidentifiedSenderMessageContentFromBuffer( + serialized: ArrayBuffer +): ExplodedInnerMessageType { + const TypeEnum = + window.textsecure.protobuf.UnidentifiedSenderMessage.Message.Type; - const message = textsecure.protobuf.UnidentifiedSenderMessage.Message.decode( + const message = window.textsecure.protobuf.UnidentifiedSenderMessage.Message.decode( serialized ); @@ -241,8 +329,9 @@ function _createUnidentifiedSenderMessageContentFromBuffer(serialized) { } // private int getProtoType(int type) -function _getProtoMessageType(type) { - const TypeEnum = textsecure.protobuf.UnidentifiedSenderMessage.Message.Type; +function _getProtoMessageType(type: number): number { + const TypeEnum = + window.textsecure.protobuf.UnidentifiedSenderMessage.Message.Type; switch (type) { case CiphertextMessage.WHISPER_TYPE: @@ -257,39 +346,53 @@ function _getProtoMessageType(type) { // public UnidentifiedSenderMessageContent( // int type, SenderCertificate senderCertificate, byte[] content) function _createUnidentifiedSenderMessageContent( - type, - senderCertificate, - content -) { - const innerMessage = new textsecure.protobuf.UnidentifiedSenderMessage.Message(); + type: number, + senderCertificate: SerializedCertificateType, + content: ArrayBuffer +): ArrayBuffer { + const innerMessage = new window.textsecure.protobuf.UnidentifiedSenderMessage.Message(); innerMessage.type = _getProtoMessageType(type); - innerMessage.senderCertificate = textsecure.protobuf.SenderCertificate.decode( + innerMessage.senderCertificate = window.textsecure.protobuf.SenderCertificate.decode( senderCertificate.serialized ); innerMessage.content = content; - return { - type, - senderCertificate, - content, - - serialized: innerMessage.encode().toArrayBuffer(), - }; + return innerMessage.toArrayBuffer(); } -SecretSessionCipher.prototype = { +export class SecretSessionCipher { + storage: typeof window.textsecure.storage.protocol; + + options: { messageKeysLimit?: number | boolean }; + + SessionCipher: typeof window.libsignal.SessionCipher; + + constructor( + storage: typeof window.textsecure.storage.protocol, + options?: { messageKeysLimit?: number | boolean } + ) { + this.storage = storage; + + // Do this on construction because libsignal won't be available when this file loads + const { SessionCipher } = window.libsignal; + this.SessionCipher = SessionCipher; + + this.options = options || {}; + } + // public byte[] encrypt( // SignalProtocolAddress destinationAddress, // SenderCertificate senderCertificate, // byte[] paddedPlaintext // ) - async encrypt(destinationAddress, senderCertificate, paddedPlaintext) { + async encrypt( + destinationAddress: SignalProtocolAddressClass, + senderCertificate: SerializedCertificateType, + paddedPlaintext: ArrayBuffer + ): Promise { // Capture this.xxx variables to replicate Java's implicit this syntax const { SessionCipher } = this; const signalProtocolStore = this.storage; - const _calculateEphemeralKeys = this._calculateEphemeralKeys.bind(this); - const _encryptWithSecretKeys = this._encryptWithSecretKeys.bind(this); - const _calculateStaticKeys = this._calculateStaticKeys.bind(this); const sessionCipher = new SessionCipher( signalProtocolStore, @@ -299,22 +402,31 @@ SecretSessionCipher.prototype = { const message = await sessionCipher.encrypt(paddedPlaintext); const ourIdentity = await signalProtocolStore.getIdentityKeyPair(); - const theirIdentity = fromEncodedBinaryToArrayBuffer( - await signalProtocolStore.loadIdentityKey(destinationAddress.getName()) + const theirIdentityData = await signalProtocolStore.loadIdentityKey( + destinationAddress.getName() ); + if (!theirIdentityData) { + throw new Error( + 'SecretSessionCipher.encrypt: No identity data for recipient!' + ); + } + const theirIdentity = + typeof theirIdentityData === 'string' + ? fromEncodedBinaryToArrayBuffer(theirIdentityData) + : theirIdentityData; - const ephemeral = await libsignal.Curve.async.generateKeyPair(); + const ephemeral = await window.libsignal.Curve.async.generateKeyPair(); const ephemeralSalt = concatenateBytes( bytesFromString(UNIDENTIFIED_DELIVERY_PREFIX), theirIdentity, ephemeral.pubKey ); - const ephemeralKeys = await _calculateEphemeralKeys( + const ephemeralKeys = await this._calculateEphemeralKeys( theirIdentity, ephemeral.privKey, ephemeralSalt ); - const staticKeyCiphertext = await _encryptWithSecretKeys( + const staticKeyCiphertext = await this._encryptWithSecretKeys( ephemeralKeys.cipherKey, ephemeralKeys.macKey, ourIdentity.pubKey @@ -324,20 +436,20 @@ SecretSessionCipher.prototype = { ephemeralKeys.chainKey, staticKeyCiphertext ); - const staticKeys = await _calculateStaticKeys( + const staticKeys = await this._calculateStaticKeys( theirIdentity, ourIdentity.privKey, staticSalt ); - const content = _createUnidentifiedSenderMessageContent( + const serializedMessage = _createUnidentifiedSenderMessageContent( message.type, senderCertificate, fromEncodedBinaryToArrayBuffer(message.body) ); - const messageBytes = await _encryptWithSecretKeys( + const messageBytes = await this._encryptWithSecretKeys( staticKeys.cipherKey, staticKeys.macKey, - content.serialized + serializedMessage ); const unidentifiedSenderMessage = _createUnidentifiedSenderMessage( @@ -347,20 +459,22 @@ SecretSessionCipher.prototype = { ); return unidentifiedSenderMessage.serialized; - }, + } // public Pair decrypt( // CertificateValidator validator, byte[] ciphertext, long timestamp) - async decrypt(validator, ciphertext, timestamp, me = {}) { - // Capture this.xxx variables to replicate Java's implicit this syntax + async decrypt( + validator: ValidatorType, + ciphertext: ArrayBuffer, + timestamp: number, + me?: MeType + ): Promise<{ + isMe?: boolean; + sender?: SignalProtocolAddressClass; + senderUuid?: SignalProtocolAddressClass; + content?: ArrayBuffer; + }> { const signalProtocolStore = this.storage; - const _calculateEphemeralKeys = this._calculateEphemeralKeys.bind(this); - const _calculateStaticKeys = this._calculateStaticKeys.bind(this); - const _decryptWithUnidentifiedSenderMessage = this._decryptWithUnidentifiedSenderMessage.bind( - this - ); - const _decryptWithSecretKeys = this._decryptWithSecretKeys.bind(this); - const ourIdentity = await signalProtocolStore.getIdentityKeyPair(); const wrapper = _createUnidentifiedSenderMessageFromBuffer(ciphertext); const ephemeralSalt = concatenateBytes( @@ -368,12 +482,12 @@ SecretSessionCipher.prototype = { ourIdentity.pubKey, wrapper.ephemeralPublic ); - const ephemeralKeys = await _calculateEphemeralKeys( + const ephemeralKeys = await this._calculateEphemeralKeys( wrapper.ephemeralPublic, ourIdentity.privKey, ephemeralSalt ); - const staticKeyBytes = await _decryptWithSecretKeys( + const staticKeyBytes = await this._decryptWithSecretKeys( ephemeralKeys.cipherKey, ephemeralKeys.macKey, wrapper.encryptedStatic @@ -384,12 +498,12 @@ SecretSessionCipher.prototype = { ephemeralKeys.chainKey, wrapper.encryptedStatic ); - const staticKeys = await _calculateStaticKeys( + const staticKeys = await this._calculateStaticKeys( staticKey, ourIdentity.privKey, staticSalt ); - const messageBytes = await _decryptWithSecretKeys( + const messageBytes = await this._decryptWithSecretKeys( staticKeys.cipherKey, staticKeys.macKey, wrapper.encryptedMessage @@ -410,6 +524,7 @@ SecretSessionCipher.prototype = { const { sender, senderUuid, senderDevice } = content.senderCertificate; if ( + me && ((sender && me.number && sender === me.number) || (senderUuid && me.uuid && senderUuid === me.uuid)) && senderDevice === me.deviceId @@ -418,20 +533,21 @@ SecretSessionCipher.prototype = { isMe: true, }; } - const addressE164 = - sender && new libsignal.SignalProtocolAddress(sender, senderDevice); - const addressUuid = - senderUuid && - new libsignal.SignalProtocolAddress( - senderUuid.toLowerCase(), - senderDevice - ); + const addressE164 = sender + ? new window.libsignal.SignalProtocolAddress(sender, senderDevice) + : undefined; + const addressUuid = senderUuid + ? new window.libsignal.SignalProtocolAddress( + senderUuid.toLowerCase(), + senderDevice + ) + : undefined; try { return { sender: addressE164, senderUuid: addressUuid, - content: await _decryptWithUnidentifiedSenderMessage(content), + content: await this._decryptWithUnidentifiedSenderMessage(content), }; } catch (error) { if (!error) { @@ -444,10 +560,12 @@ SecretSessionCipher.prototype = { throw error; } - }, + } // public int getSessionVersion(SignalProtocolAddress remoteAddress) { - getSessionVersion(remoteAddress) { + getSessionVersion( + remoteAddress: SignalProtocolAddressClass + ): Promise { const { SessionCipher } = this; const signalProtocolStore = this.storage; @@ -458,10 +576,12 @@ SecretSessionCipher.prototype = { ); return cipher.getSessionVersion(); - }, + } // public int getRemoteRegistrationId(SignalProtocolAddress remoteAddress) { - getRemoteRegistrationId(remoteAddress) { + getRemoteRegistrationId( + remoteAddress: SignalProtocolAddressClass + ): Promise { const { SessionCipher } = this; const signalProtocolStore = this.storage; @@ -472,10 +592,12 @@ SecretSessionCipher.prototype = { ); return cipher.getRemoteRegistrationId(); - }, + } // Used by outgoing_message.js - closeOpenSessionForDevice(remoteAddress) { + closeOpenSessionForDevice( + remoteAddress: SignalProtocolAddressClass + ): Promise { const { SessionCipher } = this; const signalProtocolStore = this.storage; @@ -486,19 +608,27 @@ SecretSessionCipher.prototype = { ); return cipher.closeOpenSessionForDevice(); - }, + } // private EphemeralKeys calculateEphemeralKeys( // ECPublicKey ephemeralPublic, ECPrivateKey ephemeralPrivate, byte[] salt) - async _calculateEphemeralKeys(ephemeralPublic, ephemeralPrivate, salt) { - const ephemeralSecret = await libsignal.Curve.async.calculateAgreement( + private async _calculateEphemeralKeys( + ephemeralPublic: ArrayBuffer, + ephemeralPrivate: ArrayBuffer, + salt: ArrayBuffer + ): Promise<{ + chainKey: ArrayBuffer; + cipherKey: ArrayBuffer; + macKey: ArrayBuffer; + }> { + const ephemeralSecret = await window.libsignal.Curve.async.calculateAgreement( ephemeralPublic, ephemeralPrivate ); - const ephemeralDerivedParts = await libsignal.HKDF.deriveSecrets( + const ephemeralDerivedParts = await window.libsignal.HKDF.deriveSecrets( ephemeralSecret, salt, - new ArrayBuffer() + new ArrayBuffer(0) ); // private EphemeralKeys(byte[] chainKey, byte[] cipherKey, byte[] macKey) @@ -507,19 +637,23 @@ SecretSessionCipher.prototype = { cipherKey: ephemeralDerivedParts[1], macKey: ephemeralDerivedParts[2], }; - }, + } // private StaticKeys calculateStaticKeys( // ECPublicKey staticPublic, ECPrivateKey staticPrivate, byte[] salt) - async _calculateStaticKeys(staticPublic, staticPrivate, salt) { - const staticSecret = await libsignal.Curve.async.calculateAgreement( + private async _calculateStaticKeys( + staticPublic: ArrayBuffer, + staticPrivate: ArrayBuffer, + salt: ArrayBuffer + ): Promise<{ cipherKey: ArrayBuffer; macKey: ArrayBuffer }> { + const staticSecret = await window.libsignal.Curve.async.calculateAgreement( staticPublic, staticPrivate ); - const staticDerivedParts = await libsignal.HKDF.deriveSecrets( + const staticDerivedParts = await window.libsignal.HKDF.deriveSecrets( staticSecret, salt, - new ArrayBuffer() + new ArrayBuffer(0) ); // private StaticKeys(byte[] cipherKey, byte[] macKey) @@ -527,39 +661,59 @@ SecretSessionCipher.prototype = { cipherKey: staticDerivedParts[1], macKey: staticDerivedParts[2], }; - }, + } // private byte[] decrypt(UnidentifiedSenderMessageContent message) - _decryptWithUnidentifiedSenderMessage(message) { + private _decryptWithUnidentifiedSenderMessage( + message: ExplodedInnerMessageType + ): Promise { const { SessionCipher } = this; const signalProtocolStore = this.storage; - const sender = new libsignal.SignalProtocolAddress( - message.senderCertificate.senderUuid || message.senderCertificate.sender, - message.senderCertificate.senderDevice + if (!message.senderCertificate) { + throw new Error( + '_decryptWithUnidentifiedSenderMessage: Message had no senderCertificate' + ); + } + + const { senderUuid, sender, senderDevice } = message.senderCertificate; + const target = senderUuid || sender; + if (!senderDevice || !target) { + throw new Error( + '_decryptWithUnidentifiedSenderMessage: Missing sender information in senderCertificate' + ); + } + + const address = new window.libsignal.SignalProtocolAddress( + target, + senderDevice ); switch (message.type) { case CiphertextMessage.WHISPER_TYPE: return new SessionCipher( signalProtocolStore, - sender, + address, this.options ).decryptWhisperMessage(message.content); case CiphertextMessage.PREKEY_TYPE: return new SessionCipher( signalProtocolStore, - sender, + address, this.options ).decryptPreKeyWhisperMessage(message.content); default: throw new Error(`Unknown type: ${message.type}`); } - }, + } // private byte[] encrypt( // SecretKeySpec cipherKey, SecretKeySpec macKey, byte[] plaintext) - async _encryptWithSecretKeys(cipherKey, macKey, plaintext) { + private async _encryptWithSecretKeys( + cipherKey: ArrayBuffer, + macKey: ArrayBuffer, + plaintext: ArrayBuffer + ): Promise { // Cipher const cipher = Cipher.getInstance('AES/CTR/NoPadding'); // cipher.init(Cipher.ENCRYPT_MODE, cipherKey, new IvParameterSpec(new byte[16])); @@ -574,11 +728,15 @@ SecretSessionCipher.prototype = { const ourMac = trimBytes(ourFullMac, 10); return concatenateBytes(ciphertext, ourMac); - }, + } // private byte[] decrypt( // SecretKeySpec cipherKey, SecretKeySpec macKey, byte[] ciphertext) - async _decryptWithSecretKeys(cipherKey, macKey, ciphertext) { + private async _decryptWithSecretKeys( + cipherKey: ArrayBuffer, + macKey: ArrayBuffer, + ciphertext: ArrayBuffer + ): Promise { if (ciphertext.byteLength < 10) { throw new Error('Ciphertext not long enough for MAC!'); } @@ -598,7 +756,7 @@ SecretSessionCipher.prototype = { const theirMac = ciphertextParts[1]; if (!constantTimeEqual(ourMac, theirMac)) { - throw new Error('Bad mac!'); + throw new Error('SecretSessionCipher/_decryptWithSecretKeys: Bad MAC!'); } // Cipher const cipher = Cipher.getInstance('AES/CTR/NoPadding'); @@ -606,12 +764,5 @@ SecretSessionCipher.prototype = { // return cipher.doFinal(ciphertextParts[0]); return decryptAesCtr(cipherKey, ciphertextParts[0], getZeroes(16)); - }, -}; - -module.exports = { - SecretSessionCipher, - createCertificateValidator, - _createServerCertificateFromBuffer, - _createSenderCertificateFromBuffer, -}; + } +} diff --git a/test/metadata/SecretSessionCipher_test.js b/ts/test-electron/metadata/SecretSessionCipher_test.ts similarity index 65% rename from test/metadata/SecretSessionCipher_test.js rename to ts/test-electron/metadata/SecretSessionCipher_test.ts index 58b62b8f2b..a107082d2b 100644 --- a/test/metadata/SecretSessionCipher_test.js +++ b/ts/test-electron/metadata/SecretSessionCipher_test.ts @@ -1,46 +1,48 @@ -// Copyright 2018-2020 Signal Messenger, LLC +// Copyright 2018-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -/* global libsignal, textsecure */ +/* eslint-disable @typescript-eslint/no-explicit-any */ -'use strict'; +import { assert } from 'chai'; -const { +import { + ExplodedSenderCertificateType, SecretSessionCipher, createCertificateValidator, _createSenderCertificateFromBuffer, _createServerCertificateFromBuffer, -} = window.Signal.Metadata; -const { +} from '../../metadata/SecretSessionCipher'; +import { bytesFromString, stringFromBytes, arrayBufferToBase64, -} = window.Signal.Crypto; +} from '../../Crypto'; +import { KeyPairType } from '../../libsignal.d'; -function InMemorySignalProtocolStore() { - this.store = {}; -} - -function toString(thing) { +function toString(thing: string | ArrayBuffer): string { if (typeof thing === 'string') { return thing; } return arrayBufferToBase64(thing); } -InMemorySignalProtocolStore.prototype = { - Direction: { +class InMemorySignalProtocolStore { + store: Record = {}; + + Direction = { SENDING: 1, RECEIVING: 2, - }, + }; - getIdentityKeyPair() { + getIdentityKeyPair(): Promise<{ privKey: ArrayBuffer; pubKey: ArrayBuffer }> { return Promise.resolve(this.get('identityKey')); - }, - getLocalRegistrationId() { + } + + getLocalRegistrationId(): Promise { return Promise.resolve(this.get('registrationId')); - }, - put(key, value) { + } + + put(key: string, value: any): void { if ( key === undefined || value === undefined || @@ -50,8 +52,9 @@ InMemorySignalProtocolStore.prototype = { throw new Error('Tried to store undefined/null'); } this.store[key] = value; - }, - get(key, defaultValue) { + } + + get(key: string, defaultValue?: any): any { if (key === null || key === undefined) { throw new Error('Tried to get value for undefined/null key'); } @@ -60,15 +63,19 @@ InMemorySignalProtocolStore.prototype = { } return defaultValue; - }, - remove(key) { + } + + remove(key: string): void { if (key === null || key === undefined) { throw new Error('Tried to remove value for undefined/null key'); } delete this.store[key]; - }, + } - isTrustedIdentity(identifier, identityKey) { + isTrustedIdentity( + identifier: string, + identityKey: ArrayBuffer + ): Promise { if (identifier === null || identifier === undefined) { throw new Error('tried to check identity key for undefined/null key'); } @@ -80,18 +87,22 @@ InMemorySignalProtocolStore.prototype = { return Promise.resolve(true); } return Promise.resolve(toString(identityKey) === toString(trusted)); - }, - loadIdentityKey(identifier) { + } + + loadIdentityKey(identifier: string): any { if (identifier === null || identifier === undefined) { throw new Error('Tried to get identity key for undefined/null key'); } return Promise.resolve(this.get(`identityKey${identifier}`)); - }, - saveIdentity(identifier, identityKey) { + } + + saveIdentity(identifier: string, identityKey: ArrayBuffer): any { if (identifier === null || identifier === undefined) { throw new Error('Tried to put identity key for undefined/null key'); } - const address = libsignal.SignalProtocolAddress.fromString(identifier); + const address = window.libsignal.SignalProtocolAddress.fromString( + identifier + ); const existing = this.get(`identityKey${address.getName()}`); this.put(`identityKey${address.getName()}`, identityKey); @@ -101,48 +112,55 @@ InMemorySignalProtocolStore.prototype = { } return Promise.resolve(false); - }, + } /* Returns a prekeypair object or undefined */ - loadPreKey(keyId) { + loadPreKey(keyId: number): any { let res = this.get(`25519KeypreKey${keyId}`); if (res !== undefined) { res = { pubKey: res.pubKey, privKey: res.privKey }; } return Promise.resolve(res); - }, - storePreKey(keyId, keyPair) { + } + + storePreKey(keyId: number, keyPair: any): Promise { return Promise.resolve(this.put(`25519KeypreKey${keyId}`, keyPair)); - }, - removePreKey(keyId) { + } + + removePreKey(keyId: number): Promise { return Promise.resolve(this.remove(`25519KeypreKey${keyId}`)); - }, + } /* Returns a signed keypair object or undefined */ - loadSignedPreKey(keyId) { + loadSignedPreKey(keyId: number): any { let res = this.get(`25519KeysignedKey${keyId}`); if (res !== undefined) { res = { pubKey: res.pubKey, privKey: res.privKey }; } return Promise.resolve(res); - }, - storeSignedPreKey(keyId, keyPair) { - return Promise.resolve(this.put(`25519KeysignedKey${keyId}`, keyPair)); - }, - removeSignedPreKey(keyId) { - return Promise.resolve(this.remove(`25519KeysignedKey${keyId}`)); - }, + } - loadSession(identifier) { + storeSignedPreKey(keyId: number, keyPair: any): Promise { + return Promise.resolve(this.put(`25519KeysignedKey${keyId}`, keyPair)); + } + + removeSignedPreKey(keyId: number): Promise { + return Promise.resolve(this.remove(`25519KeysignedKey${keyId}`)); + } + + loadSession(identifier: string): Promise { return Promise.resolve(this.get(`session${identifier}`)); - }, - storeSession(identifier, record) { + } + + storeSession(identifier: string, record: any): Promise { return Promise.resolve(this.put(`session${identifier}`, record)); - }, - removeSession(identifier) { + } + + removeSession(identifier: string): Promise { return Promise.resolve(this.remove(`session${identifier}`)); - }, - removeAllSessions(identifier) { + } + + removeAllSessions(identifier: string): Promise { // eslint-disable-next-line no-restricted-syntax for (const id in this.store) { if (id.startsWith(`session${identifier}`)) { @@ -150,8 +168,8 @@ InMemorySignalProtocolStore.prototype = { } } return Promise.resolve(); - }, -}; + } +} describe('SecretSessionCipher', () => { it('successfully roundtrips', async function thisNeeded() { @@ -164,7 +182,7 @@ describe('SecretSessionCipher', () => { const aliceIdentityKey = await aliceStore.getIdentityKeyPair(); - const trustRoot = await libsignal.Curve.async.generateKeyPair(); + const trustRoot = await window.libsignal.Curve.async.generateKeyPair(); const senderCertificate = await _createSenderCertificateFor( trustRoot, '+14151111111', @@ -172,15 +190,15 @@ describe('SecretSessionCipher', () => { aliceIdentityKey.pubKey, 31337 ); - const aliceCipher = new SecretSessionCipher(aliceStore); + const aliceCipher = new SecretSessionCipher(aliceStore as any); const ciphertext = await aliceCipher.encrypt( - new libsignal.SignalProtocolAddress('+14152222222', 1), + new window.libsignal.SignalProtocolAddress('+14152222222', 1), { serialized: senderCertificate.serialized }, bytesFromString('smert za smert') ); - const bobCipher = new SecretSessionCipher(bobStore); + const bobCipher = new SecretSessionCipher(bobStore as any); const decryptResult = await bobCipher.decrypt( createCertificateValidator(trustRoot.pubKey), @@ -188,6 +206,13 @@ describe('SecretSessionCipher', () => { 31335 ); + if (!decryptResult.content) { + throw new Error('decryptResult.content is null!'); + } + if (!decryptResult.sender) { + throw new Error('decryptResult.sender is null!'); + } + assert.strictEqual( stringFromBytes(decryptResult.content), 'smert za smert' @@ -205,8 +230,8 @@ describe('SecretSessionCipher', () => { const aliceIdentityKey = await aliceStore.getIdentityKeyPair(); - const trustRoot = await libsignal.Curve.async.generateKeyPair(); - const falseTrustRoot = await libsignal.Curve.async.generateKeyPair(); + const trustRoot = await window.libsignal.Curve.async.generateKeyPair(); + const falseTrustRoot = await window.libsignal.Curve.async.generateKeyPair(); const senderCertificate = await _createSenderCertificateFor( falseTrustRoot, '+14151111111', @@ -214,15 +239,15 @@ describe('SecretSessionCipher', () => { aliceIdentityKey.pubKey, 31337 ); - const aliceCipher = new SecretSessionCipher(aliceStore); + const aliceCipher = new SecretSessionCipher(aliceStore as any); const ciphertext = await aliceCipher.encrypt( - new libsignal.SignalProtocolAddress('+14152222222', 1), + new window.libsignal.SignalProtocolAddress('+14152222222', 1), { serialized: senderCertificate.serialized }, bytesFromString('и вот я') ); - const bobCipher = new SecretSessionCipher(bobStore); + const bobCipher = new SecretSessionCipher(bobStore as any); try { await bobCipher.decrypt( @@ -246,7 +271,7 @@ describe('SecretSessionCipher', () => { const aliceIdentityKey = await aliceStore.getIdentityKeyPair(); - const trustRoot = await libsignal.Curve.async.generateKeyPair(); + const trustRoot = await window.libsignal.Curve.async.generateKeyPair(); const senderCertificate = await _createSenderCertificateFor( trustRoot, '+14151111111', @@ -254,15 +279,15 @@ describe('SecretSessionCipher', () => { aliceIdentityKey.pubKey, 31337 ); - const aliceCipher = new SecretSessionCipher(aliceStore); + const aliceCipher = new SecretSessionCipher(aliceStore as any); const ciphertext = await aliceCipher.encrypt( - new libsignal.SignalProtocolAddress('+14152222222', 1), + new window.libsignal.SignalProtocolAddress('+14152222222', 1), { serialized: senderCertificate.serialized }, bytesFromString('и вот я') ); - const bobCipher = new SecretSessionCipher(bobStore); + const bobCipher = new SecretSessionCipher(bobStore as any); try { await bobCipher.decrypt( @@ -284,8 +309,8 @@ describe('SecretSessionCipher', () => { await _initializeSessions(aliceStore, bobStore); - const trustRoot = await libsignal.Curve.async.generateKeyPair(); - const randomKeyPair = await libsignal.Curve.async.generateKeyPair(); + const trustRoot = await window.libsignal.Curve.async.generateKeyPair(); + const randomKeyPair = await window.libsignal.Curve.async.generateKeyPair(); const senderCertificate = await _createSenderCertificateFor( trustRoot, '+14151111111', @@ -293,15 +318,15 @@ describe('SecretSessionCipher', () => { randomKeyPair.pubKey, 31337 ); - const aliceCipher = new SecretSessionCipher(aliceStore); + const aliceCipher = new SecretSessionCipher(aliceStore as any); const ciphertext = await aliceCipher.encrypt( - new libsignal.SignalProtocolAddress('+14152222222', 1), + new window.libsignal.SignalProtocolAddress('+14152222222', 1), { serialized: senderCertificate.serialized }, bytesFromString('smert za smert') ); - const bobCipher = new SecretSessionCipher(bobStore); + const bobCipher = new SecretSessionCipher(bobStore as any); try { await bobCipher.decrypt( @@ -326,93 +351,99 @@ describe('SecretSessionCipher', () => { // long expires // ) async function _createSenderCertificateFor( - trustRoot, - sender, - deviceId, - identityKey, - expires - ) { - const serverKey = await libsignal.Curve.async.generateKeyPair(); + trustRoot: KeyPairType, + sender: string, + deviceId: number, + identityKey: ArrayBuffer, + expires: number + ): Promise { + const serverKey = await window.libsignal.Curve.async.generateKeyPair(); - const serverCertificateCertificateProto = new textsecure.protobuf.ServerCertificate.Certificate(); + const serverCertificateCertificateProto = new window.textsecure.protobuf.ServerCertificate.Certificate(); serverCertificateCertificateProto.id = 1; serverCertificateCertificateProto.key = serverKey.pubKey; - const serverCertificateCertificateBytes = serverCertificateCertificateProto - .encode() - .toArrayBuffer(); + const serverCertificateCertificateBytes = serverCertificateCertificateProto.toArrayBuffer(); - const serverCertificateSignature = await libsignal.Curve.async.calculateSignature( + const serverCertificateSignature = await window.libsignal.Curve.async.calculateSignature( trustRoot.privKey, serverCertificateCertificateBytes ); - const serverCertificateProto = new textsecure.protobuf.ServerCertificate(); + const serverCertificateProto = new window.textsecure.protobuf.ServerCertificate(); serverCertificateProto.certificate = serverCertificateCertificateBytes; serverCertificateProto.signature = serverCertificateSignature; const serverCertificate = _createServerCertificateFromBuffer( - serverCertificateProto.encode().toArrayBuffer() + serverCertificateProto.toArrayBuffer() ); - const senderCertificateCertificateProto = new textsecure.protobuf.SenderCertificate.Certificate(); + const senderCertificateCertificateProto = new window.textsecure.protobuf.SenderCertificate.Certificate(); senderCertificateCertificateProto.sender = sender; senderCertificateCertificateProto.senderDevice = deviceId; senderCertificateCertificateProto.identityKey = identityKey; senderCertificateCertificateProto.expires = expires; - senderCertificateCertificateProto.signer = textsecure.protobuf.ServerCertificate.decode( + senderCertificateCertificateProto.signer = window.textsecure.protobuf.ServerCertificate.decode( serverCertificate.serialized ); - const senderCertificateBytes = senderCertificateCertificateProto - .encode() - .toArrayBuffer(); + const senderCertificateBytes = senderCertificateCertificateProto.toArrayBuffer(); - const senderCertificateSignature = await libsignal.Curve.async.calculateSignature( + const senderCertificateSignature = await window.libsignal.Curve.async.calculateSignature( serverKey.privKey, senderCertificateBytes ); - const senderCertificateProto = new textsecure.protobuf.SenderCertificate(); + const senderCertificateProto = new window.textsecure.protobuf.SenderCertificate(); senderCertificateProto.certificate = senderCertificateBytes; senderCertificateProto.signature = senderCertificateSignature; return _createSenderCertificateFromBuffer( - senderCertificateProto.encode().toArrayBuffer() + senderCertificateProto.toArrayBuffer() ); } // private void _initializeSessions( // SignalProtocolStore aliceStore, SignalProtocolStore bobStore) - async function _initializeSessions(aliceStore, bobStore) { - const aliceAddress = new libsignal.SignalProtocolAddress('+14152222222', 1); + async function _initializeSessions( + aliceStore: InMemorySignalProtocolStore, + bobStore: InMemorySignalProtocolStore + ): Promise { + const aliceAddress = new window.libsignal.SignalProtocolAddress( + '+14152222222', + 1 + ); await aliceStore.put( 'identityKey', - await libsignal.Curve.generateKeyPair() + await window.libsignal.Curve.generateKeyPair() + ); + await bobStore.put( + 'identityKey', + await window.libsignal.Curve.generateKeyPair() ); - await bobStore.put('identityKey', await libsignal.Curve.generateKeyPair()); await aliceStore.put('registrationId', 57); await bobStore.put('registrationId', 58); - const bobPreKey = await libsignal.Curve.async.generateKeyPair(); + const bobPreKey = await window.libsignal.Curve.async.generateKeyPair(); const bobIdentityKey = await bobStore.getIdentityKeyPair(); - const bobSignedPreKey = await libsignal.KeyHelper.generateSignedPreKey( + const bobSignedPreKey = await window.libsignal.KeyHelper.generateSignedPreKey( bobIdentityKey, 2 ); const bobBundle = { + deviceId: 3, identityKey: bobIdentityKey.pubKey, registrationId: 1, - preKey: { - keyId: 1, - publicKey: bobPreKey.pubKey, - }, signedPreKey: { keyId: 2, publicKey: bobSignedPreKey.keyPair.pubKey, signature: bobSignedPreKey.signature, }, + preKey: { + keyId: 1, + publicKey: bobPreKey.pubKey, + }, }; - const aliceSessionBuilder = new libsignal.SessionBuilder( - aliceStore, + const aliceSessionBuilder = new window.libsignal.SessionBuilder( + aliceStore as any, aliceAddress ); await aliceSessionBuilder.processPreKey(bobBundle); diff --git a/ts/textsecure.d.ts b/ts/textsecure.d.ts index 5e94fc1fd9..33a6c607d5 100644 --- a/ts/textsecure.d.ts +++ b/ts/textsecure.d.ts @@ -216,6 +216,12 @@ type SubProtocolProtobufTypes = { WebSocketResponseMessage: typeof WebSocketResponseMessageClass; }; +type UnidentifiedDeliveryTypes = { + ServerCertificate: typeof ServerCertificateClass; + SenderCertificate: typeof SenderCertificateClass; + UnidentifiedSenderMessage: typeof UnidentifiedSenderMessageClass; +}; + type ProtobufCollectionType = { onLoad: (callback: () => unknown) => void; } & DeviceMessagesProtobufTypes & @@ -223,7 +229,8 @@ type ProtobufCollectionType = { GroupsProtobufTypes & SignalServiceProtobufTypes & SignalStorageProtobufTypes & - SubProtocolProtobufTypes; + SubProtocolProtobufTypes & + UnidentifiedDeliveryTypes; // Note: there are a lot of places in the code that overwrite a field like this // with a type that the app can use. Being more rigorous with these @@ -1354,3 +1361,90 @@ export declare class WebSocketResponseMessageClass { } export { CallingMessageClass }; + +// UnidentifiedDelivery.proto + +export declare class ServerCertificateClass { + static decode: ( + data: ArrayBuffer | ByteBufferClass, + encoding?: string + ) => ServerCertificateClass; + toArrayBuffer: () => ArrayBuffer; + + certificate?: ProtoBinaryType; + signature?: ProtoBinaryType; +} + +export declare namespace ServerCertificateClass { + class Certificate { + static decode: ( + data: ArrayBuffer | ByteBufferClass, + encoding?: string + ) => Certificate; + toArrayBuffer: () => ArrayBuffer; + + id?: number; + key?: ProtoBinaryType; + } +} + +export declare class SenderCertificateClass { + static decode: ( + data: ArrayBuffer | ByteBufferClass, + encoding?: string + ) => SenderCertificateClass; + toArrayBuffer: () => ArrayBuffer; + + certificate?: ProtoBinaryType; + signature?: ProtoBinaryType; +} + +export declare namespace SenderCertificateClass { + class Certificate { + static decode: ( + data: ArrayBuffer | ByteBufferClass, + encoding?: string + ) => Certificate; + toArrayBuffer: () => ArrayBuffer; + + sender?: string; + senderUuid?: string; + senderDevice?: number; + expires?: ProtoBigNumberType; + identityKey?: ProtoBinaryType; + signer?: SenderCertificateClass; + } +} + +export declare class UnidentifiedSenderMessageClass { + static decode: ( + data: ArrayBuffer | ByteBufferClass, + encoding?: string + ) => UnidentifiedSenderMessageClass; + toArrayBuffer: () => ArrayBuffer; + + ephemeralPublic?: ProtoBinaryType; + encryptedStatic?: ProtoBinaryType; + encryptedMessage?: ProtoBinaryType; +} + +export declare namespace UnidentifiedSenderMessageClass { + class Message { + static decode: ( + data: ArrayBuffer | ByteBufferClass, + encoding?: string + ) => Message; + toArrayBuffer: () => ArrayBuffer; + + type?: number; + senderCertificate?: SenderCertificateClass; + content?: ProtoBinaryType; + } +} + +export declare namespace UnidentifiedSenderMessageClass.Message { + class Type { + static PREKEY_MESSAGE: number; + static MESSAGE: number; + } +} diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index 4cb5593155..bf38c49424 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -27,6 +27,10 @@ import Crypto from './Crypto'; import { deriveMasterKeyFromGroupV1 } from '../Crypto'; import { ContactBuffer, GroupBuffer } from './ContactsParser'; import { IncomingIdentityKeyError } from './Errors'; +import { + createCertificateValidator, + SecretSessionCipher, +} from '../metadata/SecretSessionCipher'; import { AttachmentPointerClass, @@ -946,7 +950,7 @@ class MessageReceiverInner extends EventTarget { address, options ); - const secretSessionCipher = new window.Signal.Metadata.SecretSessionCipher( + const secretSessionCipher = new SecretSessionCipher( window.textsecure.storage.protocol, options ); @@ -979,7 +983,7 @@ class MessageReceiverInner extends EventTarget { window.log.info('received unidentified sender message'); promise = secretSessionCipher .decrypt( - window.Signal.Metadata.createCertificateValidator(serverTrustRoot), + createCertificateValidator(serverTrustRoot), ciphertext.toArrayBuffer(), Math.min(envelope.serverTimestamp || Date.now(), Date.now()), me @@ -1028,6 +1032,12 @@ class MessageReceiverInner extends EventTarget { originalSource || originalSourceUuid ); + if (!content) { + throw new Error( + 'MessageReceiver.decrypt: Content returned was falsey!' + ); + } + // Return just the content because that matches the signature of the other // decrypt methods used above. return this.unpad(content); diff --git a/ts/textsecure/OutgoingMessage.ts b/ts/textsecure/OutgoingMessage.ts index 11f9813bc7..bb372fd2fb 100644 --- a/ts/textsecure/OutgoingMessage.ts +++ b/ts/textsecure/OutgoingMessage.ts @@ -26,6 +26,10 @@ import { UnregisteredUserError, } from './Errors'; import { isValidNumber } from '../types/PhoneNumber'; +import { + SecretSessionCipher, + SerializedCertificateType, +} from '../metadata/SecretSessionCipher'; type OutgoingMessageOptionsType = SendOptionsType & { online?: boolean; @@ -58,7 +62,7 @@ export default class OutgoingMessage { sendMetadata?: SendMetadataType; - senderCertificate?: ArrayBuffer; + senderCertificate?: SerializedCertificateType; online?: boolean; @@ -384,8 +388,8 @@ export default class OutgoingMessage { options.messageKeysLimit = false; } - if (sealedSender) { - const secretSessionCipher = new window.Signal.Metadata.SecretSessionCipher( + if (sealedSender && senderCertificate) { + const secretSessionCipher = new SecretSessionCipher( window.textsecure.storage.protocol ); ciphers[address.getDeviceId()] = secretSessionCipher; diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index 9b5c7e48da..05caaf9dfc 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -46,6 +46,7 @@ import { LinkPreviewImage, LinkPreviewMetadata, } from '../linkPreviews/linkPreviewFetch'; +import { SerializedCertificateType } from '../metadata/SecretSessionCipher'; function stringToArrayBuffer(str: string): ArrayBuffer { if (typeof str !== 'string') { @@ -66,7 +67,7 @@ export type SendMetadataType = { }; export type SendOptionsType = { - senderCertificate?: ArrayBuffer; + senderCertificate?: SerializedCertificateType; sendMetadata?: SendMetadataType; online?: boolean; }; From 9e2411ce300e327e33d0e1034c5d662246deeb67 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Thu, 4 Mar 2021 10:06:49 -0800 Subject: [PATCH 034/181] Remove getIsConversationEmptySelector in favor of messageCount --- ts/models/conversations.ts | 1 + ts/state/ducks/conversations.ts | 1 + ts/state/selectors/conversations.ts | 14 ---- ts/state/smart/CompositionArea.tsx | 15 ++-- ts/state/smart/ConversationHeader.tsx | 15 ++-- .../state/selectors/conversations_test.ts | 69 ------------------- 6 files changed, 18 insertions(+), 97 deletions(-) diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 4fd817e53e..549a74980c 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -1336,6 +1336,7 @@ export class ConversationModel extends window.Backbone.Model< markedUnread: this.get('markedUnread')!, membersCount: this.getMembersCount(), memberships: this.getMemberships(), + messageCount: this.get('messageCount') || 0, pendingMemberships: this.getPendingMemberships(), pendingApprovalMemberships: this.getPendingApprovalMemberships(), messageRequestsEnabled, diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index b6767fa7aa..93280c47f4 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -91,6 +91,7 @@ export type ConversationType = { markedUnread?: boolean; phoneNumber?: string; membersCount?: number; + messageCount?: number; accessControlAddFromInviteLink?: number; accessControlAttributes?: number; accessControlMembers?: number; diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index 22db86a242..7a2227e883 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -181,20 +181,6 @@ export const getMessagesByConversation = createSelector( } ); -export const getIsConversationEmptySelector = createSelector( - getMessagesByConversation, - (messagesByConversation: MessagesByConversationType) => ( - conversationId: string - ): boolean => { - const messages = getOwn(messagesByConversation, conversationId); - if (!messages) { - assert(false, 'Could not find conversation with this ID'); - return true; - } - return messages.messageIds.length === 0; - } -); - const collator = new Intl.Collator(); // Note: we will probably want to put i18n and regionCode back when we are formatting diff --git a/ts/state/smart/CompositionArea.tsx b/ts/state/smart/CompositionArea.tsx index 1bed153beb..43b9722b3a 100644 --- a/ts/state/smart/CompositionArea.tsx +++ b/ts/state/smart/CompositionArea.tsx @@ -10,10 +10,7 @@ import { StateType } from '../reducer'; import { isShortName } from '../../components/emoji/lib'; import { getIntl } from '../selectors/user'; -import { - getConversationSelector, - getIsConversationEmptySelector, -} from '../selectors/conversations'; +import { getConversationSelector } from '../selectors/conversations'; import { getBlessedStickerPacks, getInstalledStickerPacks, @@ -81,10 +78,14 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => { // Message Requests ...conversation, conversationType: conversation.type, - isMissingMandatoryProfileSharing: + isMissingMandatoryProfileSharing: Boolean( !conversation.profileSharing && - window.Signal.RemoteConfig.isEnabled('desktop.mandatoryProfileSharing') && - !getIsConversationEmptySelector(state)(id), + window.Signal.RemoteConfig.isEnabled( + 'desktop.mandatoryProfileSharing' + ) && + conversation.messageCount && + conversation.messageCount > 0 + ), }; }; diff --git a/ts/state/smart/ConversationHeader.tsx b/ts/state/smart/ConversationHeader.tsx index 53905d1da3..304d7c235a 100644 --- a/ts/state/smart/ConversationHeader.tsx +++ b/ts/state/smart/ConversationHeader.tsx @@ -7,10 +7,7 @@ import { ConversationHeader, OutgoingCallButtonStyle, } from '../../components/conversation/ConversationHeader'; -import { - getConversationSelector, - getIsConversationEmptySelector, -} from '../selectors/conversations'; +import { getConversationSelector } from '../selectors/conversations'; import { StateType } from '../reducer'; import { CallMode } from '../../types/Calling'; import { @@ -110,10 +107,14 @@ const mapStateToProps = (state: StateType, ownProps: OwnProps) => { 'groupVersion', ]), conversationTitle: state.conversations.selectedConversationTitle, - isMissingMandatoryProfileSharing: + isMissingMandatoryProfileSharing: Boolean( !conversation.profileSharing && - window.Signal.RemoteConfig.isEnabled('desktop.mandatoryProfileSharing') && - !getIsConversationEmptySelector(state)(id), + window.Signal.RemoteConfig.isEnabled( + 'desktop.mandatoryProfileSharing' + ) && + conversation.messageCount && + conversation.messageCount > 0 + ), i18n: getIntl(state), showBackButton: state.conversations.selectedConversationPanelDepth > 0, outgoingCallButtonStyle: getOutgoingCallButtonStyle(conversation, state), diff --git a/ts/test-both/state/selectors/conversations_test.ts b/ts/test-both/state/selectors/conversations_test.ts index 7afa0bcce6..06328ca12f 100644 --- a/ts/test-both/state/selectors/conversations_test.ts +++ b/ts/test-both/state/selectors/conversations_test.ts @@ -23,7 +23,6 @@ import { getComposerStep, getConversationSelector, getInvitedContactsForNewlyCreatedGroup, - getIsConversationEmptySelector, getMaximumGroupSizeModalState, getPlaceholderContact, getRecommendedGroupSizeModalState, @@ -257,74 +256,6 @@ 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('#getComposerStep', () => { it("returns undefined if the composer isn't open", () => { const state = getEmptyRootState(); From 44dfd280170ea6b1823be7f8f2d732a48baea57f Mon Sep 17 00:00:00 2001 From: Josh Perez <60019601+josh-signal@users.noreply.github.com> Date: Thu, 4 Mar 2021 14:34:04 -0500 Subject: [PATCH 035/181] Fix message retry and search results with mentions --- ts/components/ConversationList.stories.tsx | 2 + ts/components/LeftPane.stories.tsx | 2 + .../MessageBodyHighlight.stories.tsx | 18 ++++ .../conversationList/MessageBodyHighlight.tsx | 56 ++++++++--- .../MessageSearchResult.stories.tsx | 96 +++++++++++++++++++ .../conversationList/MessageSearchResult.tsx | 83 ++++++++++++++-- ts/models/messages.ts | 1 + ts/state/ducks/search.ts | 3 + ts/state/selectors/search.ts | 2 + ts/test-both/state/selectors/search_test.ts | 12 +++ 10 files changed, 254 insertions(+), 21 deletions(-) diff --git a/ts/components/ConversationList.stories.tsx b/ts/components/ConversationList.stories.tsx index 6f6d99ec40..6b6252513c 100644 --- a/ts/components/ConversationList.stories.tsx +++ b/ts/components/ConversationList.stories.tsx @@ -65,6 +65,8 @@ const createProps = (rows: ReadonlyArray): PropsType => ({ onClickContactCheckbox: action('onClickContactCheckbox'), renderMessageSearchResult: (id: string, style: React.CSSProperties) => ( = {}): PropsType => ({ renderMainHeader: () =>
, renderMessageSearchResult: (id: string, style: React.CSSProperties) => ( = {}): Props => ({ + bodyRanges: overrideProps.bodyRanges || [], i18n, text: text('text', overrideProps.text || ''), }); @@ -47,6 +48,23 @@ story.add('Two Replacements', () => { return ; }); +story.add('Two Replacements with an @mention', () => { + const props = createProps({ + bodyRanges: [ + { + length: 1, + mentionUuid: '0ca40892-7b1a-11eb-9439-0242ac130002', + replacementText: 'Jin Sakai', + start: 52, + }, + ], + text: + 'Begin <>Inside #1<> \uFFFC@52 This is between the two <>Inside #2<> End.', + }); + + return ; +}); + story.add('Emoji + Newlines + URLs', () => { const props = createProps({ text: diff --git a/ts/components/conversationList/MessageBodyHighlight.tsx b/ts/components/conversationList/MessageBodyHighlight.tsx index 43729cbcf5..af3d727eab 100644 --- a/ts/components/conversationList/MessageBodyHighlight.tsx +++ b/ts/components/conversationList/MessageBodyHighlight.tsx @@ -4,25 +4,27 @@ import React, { ReactNode } from 'react'; import { MESSAGE_TEXT_CLASS_NAME } from './BaseConversationListItem'; +import { AtMentionify } from '../conversation/AtMentionify'; import { MessageBody } from '../conversation/MessageBody'; import { Emojify } from '../conversation/Emojify'; import { AddNewLines } from '../conversation/AddNewLines'; import { SizeClassType } from '../emoji/lib'; -import { LocalizerType, RenderTextCallbackType } from '../../types/Util'; +import { + BodyRangesType, + LocalizerType, + RenderTextCallbackType, +} from '../../types/Util'; const CLASS_NAME = `${MESSAGE_TEXT_CLASS_NAME}__message-search-result-contents`; export type Props = { + bodyRanges: BodyRangesType; text: string; i18n: LocalizerType; }; -const renderNewLines: RenderTextCallbackType = ({ text, key }) => ( - -); - const renderEmoji = ({ text, key, @@ -44,18 +46,42 @@ const renderEmoji = ({ ); export class MessageBodyHighlight extends React.Component { + private readonly renderNewLines: RenderTextCallbackType = ({ + text: textWithNewLines, + key, + }) => { + const { bodyRanges } = this.props; + return ( + ( + + )} + /> + ); + }; + private renderContents(): ReactNode { - const { text, i18n } = this.props; + const { bodyRanges, text, i18n } = this.props; const results: Array = []; const FIND_BEGIN_END = /<>(.+?)<>/g; - let match = FIND_BEGIN_END.exec(text); + const processedText = AtMentionify.preprocessMentions(text, bodyRanges); + + let match = FIND_BEGIN_END.exec(processedText); let last = 0; let count = 1; if (!match) { return ( - + ); } @@ -63,7 +89,7 @@ export class MessageBodyHighlight extends React.Component { while (match) { if (last < match.index) { - const beforeText = text.slice(last, match.index); + const beforeText = processedText.slice(last, match.index); count += 1; results.push( renderEmoji({ @@ -71,7 +97,7 @@ export class MessageBodyHighlight extends React.Component { sizeClass, key: count, i18n, - renderNonEmoji: renderNewLines, + renderNonEmoji: this.renderNewLines, }) ); } @@ -85,24 +111,24 @@ export class MessageBodyHighlight extends React.Component { sizeClass, key: count, i18n, - renderNonEmoji: renderNewLines, + renderNonEmoji: this.renderNewLines, })} ); last = FIND_BEGIN_END.lastIndex; - match = FIND_BEGIN_END.exec(text); + match = FIND_BEGIN_END.exec(processedText); } - if (last < text.length) { + if (last < processedText.length) { count += 1; results.push( renderEmoji({ - text: text.slice(last), + text: processedText.slice(last), sizeClass, key: count, i18n, - renderNonEmoji: renderNewLines, + renderNonEmoji: this.renderNewLines, }) ); } diff --git a/ts/components/conversationList/MessageSearchResult.stories.tsx b/ts/components/conversationList/MessageSearchResult.stories.tsx index cde32a194a..5144a5cf11 100644 --- a/ts/components/conversationList/MessageSearchResult.stories.tsx +++ b/ts/components/conversationList/MessageSearchResult.stories.tsx @@ -43,6 +43,8 @@ const createProps = (overrideProps: Partial = {}): PropsType => ({ 'snippet', overrideProps.snippet || "What's <>going<> on?" ), + body: text('body', overrideProps.body || "What's going on?"), + bodyRanges: overrideProps.bodyRanges || [], from: overrideProps.from as PropsType['from'], to: overrideProps.to as PropsType['to'], isSelected: boolean('isSelected', overrideProps.isSelected || false), @@ -141,3 +143,97 @@ story.add('Empty (should be invalid)', () => { return ; }); + +story.add('@mention', () => { + const props = createProps({ + body: + 'moss banana twine sound lake zoo brain count vacuum work stairs try power forget hair dry diary years no results \uFFFC elephant sorry umbrella potato igloo kangaroo home Georgia bayonet vector orange forge diary zebra turtle rise front \uFFFC', + bodyRanges: [ + { + length: 1, + mentionUuid: '7d007e95-771d-43ad-9191-eaa86c773cb8', + replacementText: 'Shoe', + start: 113, + }, + { + length: 1, + mentionUuid: '7d007e95-771d-43ad-9191-eaa86c773cb8', + replacementText: 'Shoe', + start: 237, + }, + ], + from: someone, + to: me, + snippet: + '...forget hair dry diary years no <>results<> \uFFFC <>elephant<> sorry umbrella potato igloo kangaroo home Georgia...', + }); + + return ; +}); + +story.add('@mention regexp', () => { + const props = createProps({ + body: + '\uFFFC This is a (long) /text/ ^$ that is ... specially **crafted** to (test) our regexp escaping mechanism! Making sure that the code we write works in all sorts of scenarios', + bodyRanges: [ + { + length: 1, + mentionUuid: '7d007e95-771d-43ad-9191-eaa86c773cb8', + replacementText: 'RegExp', + start: 0, + }, + ], + from: someone, + to: me, + snippet: + '\uFFFC This is a (long) /text/ ^$ that is ... <>specially<> **crafted** to (test) our regexp escaping mechanism...', + }); + + return ; +}); + +story.add('@mention no-matches', () => { + const props = createProps({ + body: '\uFFFC hello', + bodyRanges: [ + { + length: 1, + mentionUuid: '7d007e95-771d-43ad-9191-eaa86c773cb8', + replacementText: 'Neo', + start: 0, + }, + ], + from: someone, + to: me, + snippet: '\uFFFC hello', + }); + + return ; +}); + +story.add('@mention no-matches', () => { + const props = createProps({ + body: + 'moss banana twine sound lake zoo brain count vacuum work stairs try power forget hair dry diary years no results \uFFFC elephant sorry umbrella potato igloo kangaroo home Georgia bayonet vector orange forge diary zebra turtle rise front \uFFFC', + bodyRanges: [ + { + length: 1, + mentionUuid: '7d007e95-771d-43ad-9191-eaa86c773cb8', + replacementText: 'Shoe', + start: 113, + }, + { + length: 1, + mentionUuid: '7d007e95-771d-43ad-9191-eaa86c773cb8', + replacementText: 'Shoe', + start: 237, + }, + ], + from: someone, + to: me, + snippet: + '...forget hair dry diary years no results \uFFFC elephant sorry umbrella potato igloo kangaroo home Georgia...', + }); + + return ; +}); diff --git a/ts/components/conversationList/MessageSearchResult.tsx b/ts/components/conversationList/MessageSearchResult.tsx index 4b603bdd46..ba98edb1f3 100644 --- a/ts/components/conversationList/MessageSearchResult.tsx +++ b/ts/components/conversationList/MessageSearchResult.tsx @@ -7,11 +7,13 @@ import React, { FunctionComponent, ReactNode, } from 'react'; +import { escapeRegExp } from 'lodash'; import { MessageBodyHighlight } from './MessageBodyHighlight'; import { ContactName } from '../conversation/ContactName'; -import { LocalizerType } from '../../types/Util'; +import { assert } from '../../util/assert'; +import { BodyRangesType, LocalizerType } from '../../types/Util'; import { ColorType } from '../../types/Colors'; import { BaseConversationListItem } from './BaseConversationListItem'; @@ -24,6 +26,8 @@ export type PropsDataType = { sentAt?: number; snippet: string; + body: string; + bodyRanges: BodyRangesType; from: { phoneNumber?: string; @@ -78,17 +82,77 @@ const renderPerson = ( /> ); +// This function exists because bodyRanges tells us the character position +// where the at-mention starts at according to the full body text. The snippet +// we get back is a portion of the text and we don't know where it starts. This +// function will find the relevant bodyRanges that apply to the snippet and +// then update the proper start position of each body range. +function getFilteredBodyRanges( + snippet: string, + body: string, + bodyRanges: BodyRangesType +): BodyRangesType { + // Find where the snippet starts in the full text + const stripped = snippet + .replace(/<>/g, '') + .replace(/<>/g, '') + .replace(/^.../, '') + .replace(/...$/, ''); + const rx = new RegExp(escapeRegExp(stripped)); + const match = rx.exec(body); + + assert(Boolean(match), `No match found for "${snippet}" inside "${body}"`); + + const delta = match ? match.index + snippet.length : 0; + + // Filters out the @mentions that are present inside the snippet + const filteredBodyRanges = bodyRanges.filter(bodyRange => { + return bodyRange.start < delta; + }); + + const snippetBodyRanges = []; + const MENTIONS_REGEX = /\uFFFC/g; + + let bodyRangeMatch = MENTIONS_REGEX.exec(snippet); + let i = 0; + + // Find the start position within the snippet so these can later be + // encoded and rendered correctly. + while (bodyRangeMatch) { + const bodyRange = filteredBodyRanges[i]; + + if (bodyRange) { + snippetBodyRanges.push({ + ...bodyRange, + start: bodyRangeMatch.index, + }); + } else { + assert( + false, + `Body range does not exist? Count: ${i}, Length: ${filteredBodyRanges.length}` + ); + } + + bodyRangeMatch = MENTIONS_REGEX.exec(snippet); + i += 1; + } + + return snippetBodyRanges; +} + export const MessageSearchResult: FunctionComponent = React.memo( ({ - id, + body, + bodyRanges, conversationId, from, - to, - sentAt, i18n, + id, openConversationInternal, - style, + sentAt, snippet, + style, + to, }) => { const onClickItem = useCallback(() => { openConversationInternal({ conversationId, messageId: id }); @@ -113,7 +177,14 @@ export const MessageSearchResult: FunctionComponent = React.memo( ); } - const messageText = ; + const snippetBodyRanges = getFilteredBodyRanges(snippet, body, bodyRanges); + const messageText = ( + + ); return ( { preview: previewWithData, sticker: stickerWithData, expireTimer: this.get('expireTimer'), + mentions: this.get('bodyRanges'), profileKey, groupV2, group: groupV2 diff --git a/ts/state/ducks/search.ts b/ts/state/ducks/search.ts index b6073483ac..3a27d9c283 100644 --- a/ts/state/ducks/search.ts +++ b/ts/state/ducks/search.ts @@ -7,6 +7,7 @@ import { normalize } from '../../types/PhoneNumber'; import { cleanSearchTerm } from '../../util/cleanSearchTerm'; import dataInterface from '../../sql/Client'; import { makeLookup } from '../../util/makeLookup'; +import { BodyRangesType } from '../../types/Util'; import { ConversationUnloadedActionType, @@ -28,6 +29,8 @@ const { export type MessageSearchResultType = MessageType & { snippet: string; + body: string; + bodyRanges: BodyRangesType; }; export type MessageSearchResultLookupType = { diff --git a/ts/state/selectors/search.ts b/ts/state/selectors/search.ts index 51085eb797..ba6cf03374 100644 --- a/ts/state/selectors/search.ts +++ b/ts/state/selectors/search.ts @@ -125,6 +125,8 @@ export function _messageSearchResultSelector( conversationId: message.conversationId, sentAt: message.sent_at, snippet: message.snippet, + bodyRanges: message.bodyRanges, + body: message.body, isSelected: Boolean(selectedMessageId && message.id === selectedMessageId), isSearchingInConversation: Boolean(searchConversationId), diff --git a/ts/test-both/state/selectors/search_test.ts b/ts/test-both/state/selectors/search_test.ts index 07a4bf1a74..ac3a7fe11b 100644 --- a/ts/test-both/state/selectors/search_test.ts +++ b/ts/test-both/state/selectors/search_test.ts @@ -44,6 +44,8 @@ describe('both/state/selectors/search', () => { function getDefaultSearchMessage(id: string): MessageSearchResultType { return { ...getDefaultMessage(id), + body: 'foo bar', + bodyRanges: [], snippet: 'foo bar', }; } @@ -77,6 +79,8 @@ describe('both/state/selectors/search', () => { ...getDefaultMessage(id), type: 'keychange' as const, snippet: 'snippet', + body: 'snippet', + bodyRanges: [], }, }, }, @@ -114,6 +118,8 @@ describe('both/state/selectors/search', () => { sourceUuid: fromId, conversationId: toId, snippet: 'snippet', + body: 'snippet', + bodyRanges: [], }, }, }, @@ -129,6 +135,8 @@ describe('both/state/selectors/search', () => { conversationId: toId, sentAt: undefined, snippet: 'snippet', + body: 'snippet', + bodyRanges: [], isSelected: false, isSearchingInConversation: false, @@ -165,6 +173,8 @@ describe('both/state/selectors/search', () => { type: 'outgoing' as const, conversationId: toId, snippet: 'snippet', + body: 'snippet', + bodyRanges: [], }, }, }, @@ -180,6 +190,8 @@ describe('both/state/selectors/search', () => { conversationId: toId, sentAt: undefined, snippet: 'snippet', + body: 'snippet', + bodyRanges: [], isSelected: false, isSearchingInConversation: false, From c73e35b1b6f1f716ff63964e2e9ec55b5f981e98 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Thu, 4 Mar 2021 12:01:34 -0800 Subject: [PATCH 036/181] Support for translating Desktop sessions to libsignal-client sessions --- protos/LibSignal-Client.proto | 107 ++ test/setup-test-node.js | 4 + ts/test-both/util/sessionTranslation_test.ts | 985 +++++++++++++++++++ ts/util/index.ts | 6 + ts/util/lint/exceptions.json | 74 +- ts/util/lint/linter.ts | 1 + ts/util/sessionTranslation.ts | 409 ++++++++ 7 files changed, 1513 insertions(+), 73 deletions(-) create mode 100644 protos/LibSignal-Client.proto create mode 100644 ts/test-both/util/sessionTranslation_test.ts create mode 100644 ts/util/sessionTranslation.ts diff --git a/protos/LibSignal-Client.proto b/protos/LibSignal-Client.proto new file mode 100644 index 0000000000..634a29eba7 --- /dev/null +++ b/protos/LibSignal-Client.proto @@ -0,0 +1,107 @@ +syntax = "proto3"; + +// +// Copyright 2020-2021 Signal Messenger, LLC. +// SPDX-License-Identifier: AGPL-3.0-only +// + +package signal.proto.storage; + +message SessionStructure { + message Chain { + bytes sender_ratchet_key = 1; + bytes sender_ratchet_key_private = 2; + + message ChainKey { + uint32 index = 1; + bytes key = 2; + } + + ChainKey chain_key = 3; + + message MessageKey { + uint32 index = 1; + bytes cipher_key = 2; + bytes mac_key = 3; + bytes iv = 4; + } + + repeated MessageKey message_keys = 4; + } + + message PendingPreKey { + uint32 pre_key_id = 1; + int32 signed_pre_key_id = 3; + bytes base_key = 2; + } + + uint32 session_version = 1; + bytes local_identity_public = 2; + bytes remote_identity_public = 3; + + bytes root_key = 4; + uint32 previous_counter = 5; + + Chain sender_chain = 6; + // The order is significant; keys at the end are "older" and will get trimmed. + repeated Chain receiver_chains = 7; + + PendingPreKey pending_pre_key = 9; + + uint32 remote_registration_id = 10; + uint32 local_registration_id = 11; + + bool needs_refresh = 12; + bytes alice_base_key = 13; +} + +message RecordStructure { + SessionStructure current_session = 1; + // The order is significant; sessions at the end are "older" and will get trimmed. + repeated SessionStructure previous_sessions = 2; +} + +message PreKeyRecordStructure { + uint32 id = 1; + bytes public_key = 2; + bytes private_key = 3; +} + +message SignedPreKeyRecordStructure { + uint32 id = 1; + bytes public_key = 2; + bytes private_key = 3; + bytes signature = 4; + fixed64 timestamp = 5; +} + +message IdentityKeyPairStructure { + bytes public_key = 1; + bytes private_key = 2; +} + +message SenderKeyStateStructure { + message SenderChainKey { + uint32 iteration = 1; + bytes seed = 2; + } + + message SenderMessageKey { + uint32 iteration = 1; + bytes seed = 2; + } + + message SenderSigningKey { + bytes public = 1; + bytes private = 2; + } + + uint32 sender_key_id = 1; + SenderChainKey sender_chain_key = 2; + SenderSigningKey sender_signing_key = 3; + repeated SenderMessageKey sender_message_keys = 4; +} + +message SenderKeyRecordStructure { + repeated SenderKeyStateStructure sender_key_states = 1; +} \ No newline at end of file diff --git a/test/setup-test-node.js b/test/setup-test-node.js index 43b0d1b86c..0441fa01d1 100644 --- a/test/setup-test-node.js +++ b/test/setup-test-node.js @@ -3,6 +3,7 @@ /* eslint-disable no-console */ +const ByteBuffer = require('../components/bytebuffer/dist/ByteBufferAB.js'); const { setEnvironment, Environment } = require('../ts/environment'); before(() => { @@ -17,6 +18,9 @@ global.window = { error: (...args) => console.error(...args), }, i18n: key => `i18n(${key})`, + dcodeIO: { + ByteBuffer, + }, }; // For ducks/network.getEmptyState() diff --git a/ts/test-both/util/sessionTranslation_test.ts b/ts/test-both/util/sessionTranslation_test.ts new file mode 100644 index 0000000000..61b5c8fa72 --- /dev/null +++ b/ts/test-both/util/sessionTranslation_test.ts @@ -0,0 +1,985 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { assert } from 'chai'; + +import { + LocalUserDataType, + sessionRecordToProtobuf, +} from '../../util/sessionTranslation'; +import { base64ToArrayBuffer } from '../../Crypto'; + +const getRecordCopy = (record: any): any => JSON.parse(JSON.stringify(record)); + +describe('sessionTranslation', () => { + let ourData: LocalUserDataType; + + beforeEach(() => { + ourData = { + identityKeyPublic: base64ToArrayBuffer( + 'Baioqfzc/5JD6b+GNqapPouf6eHK7xr9ynLJHnvl+444' + ), + registrationId: 3554, + }; + }); + + it('Throws if given an empty object', () => { + const record: any = {}; + assert.throws( + () => sessionRecordToProtobuf(record, ourData), + 'toProtobuf: Record had no sessions!' + ); + }); + + it('Generates expected protobuf with minimal record', () => { + const record: any = { + sessions: { + '\u0005W‡¿“\u0000lÈ\nyª\u000eümB0\u0017j„.Û£³-s\u0016č(O_M': { + registrationId: 4243, + currentRatchet: { + rootKey: + 'Ë\u00035/üœÚšg\u0003Xeûú\u0010—\u0000ü\u0002¶»o5\u001cƒ—­¥\u0004Ðÿ«', + lastRemoteEphemeralKey: + '\u0005\n7\u001cmT…b!è\u000eÍ\u0007\u0016m4g³\u0005‘üœIYŒê\b\u0011ÏÎPs', + previousCounter: 2, + ephemeralKeyPair: { + privKey: 'ä—ãÅ«ªŠàøí)ˆá\u0005Á"ŒsJM.¨¡\u0012r(N\f9Ô\b', + pubKey: '\u0005+\u00134–«1\u0000\u0013l *ãKçnºÖó³íTSŸ&Œ{ù ͂>1', + }, + }, + indexInfo: { + remoteIdentityKey: '\u0005¨¨©üÏäúo፩êO¢çúxr»Æ¿rœ²GžùiT@', + closed: -1, + baseKey: '\u0005W‡¿“\u0000lÈ\nyª\u000eümB0\u0017j„.Û£³-s\u0016č(O_M', + baseKeyType: 2, + }, + oldRatchetList: [], + '\u0005\n7\u001cmT…b!è\u000eÍ\u0007\u0016m4g³\u0005‘üœIYŒê\b\u0011ÏÎPs': { + messageKeys: { + '0': 'Îgó¯‘2àvñ‘X_õ\u0014–Ç\u0000öl\u001f4J>ŒÐÏ{`-Ü5¦', + '4': 'c¿<µâ¼Xµƒ!Ù¯µ®[—n<žìîúcoå©n\u0013"l]Ð', + }, + chainKey: { + counter: 5, + key: 'Z{òÙ8سAÝdSZ†k\n×\u001cô¡\u001b[YÒ¶ƒ\u0016a°\u0004<', + }, + chainType: 2, + }, + '\u0005+\u00134–«1\u0000\u0013l *ãKçnºÖó³íTSŸ&Œ{ù ͂>1': { + messageKeys: {}, + chainKey: { + counter: -1, + key: + "èB?7\u000f¯\u001e\u0010¨\u0007}:“?¹\u0010$\\ë~ª\u0000gM0՘'£\u0005", + }, + chainType: 1, + }, + }, + }, + version: 'v1', + }; + + const expected = { + currentSession: { + sessionVersion: 1, + localIdentityPublic: 'Baioqfzc/5JD6b+GNqapPouf6eHK7xr9ynLJHnvl+444', + remoteIdentityPublic: 'BaioqfzP5JD6b+GNqepPouf6eHK7xr9ynLJHnvlpgVRA', + rootKey: 'ywM1L/yc2ppnA1hl+/oQlwD8Ara7bzUcg5etpQTQ/6s=', + previousCounter: 2, + senderChain: { + senderRatchetKey: 'BSsTNJarMQATbCAq40vnbrrW87PtVFOfJox7+SDNgj4x', + senderRatchetKeyPrivate: + '5JfjxauqiuD47SmI4QXBIoxzSk0uqKEScp0oTgw51Ag=', + chainKey: { + index: -1, + key: '6EI/Nw+vHhCoB499OpM/kLkQJFzrfqoAZ00w1ZgnowU=', + }, + }, + receiverChains: [ + { + senderRatchetKey: 'BQo3HG1UhWIh6A7NBxZtNGezBZH8nElZjOqNCBHPzlBz', + chainKey: { + index: 5, + key: 'Wnvy2TjYs0HdZFNahmsKw5cc9KEbW1nSwraDFmGwBDw=', + }, + messageKeys: [ + { + index: 0, + cipherKey: 'xVreEbT7Vtrxs85JyGBj6Y+UWftQz4H72F5kWV4cxqM=', + iv: 'TcRanSxZVWbuIq0xDRGnEw==', + macKey: '5fW9aIKXhtwWp/5alNJUIXInZbztf2ywzQSpYrXoQ3A=', + }, + { + index: 4, + cipherKey: 'A99HjM4pUugsQ5+2v48FGTGEhZPoW6wzW9MqSc11QQ4=', + iv: 'bE8Ei2Rkaoz4SKRwdG4+tQ==', + macKey: 'TOTdbAf0bCHOzcQ3lBaIm3yqmpEqvvldD0qTuDFmkAI=', + }, + ], + }, + ], + remoteRegistrationId: 4243, + localRegistrationId: 3554, + aliceBaseKey: 'BVeHv5MAbMgKeaoO/G1CMBdqhC7bo7Mtc4EWxI0oT19N', + }, + }; + + const recordCopy = getRecordCopy(record); + + const actual = sessionRecordToProtobuf(record, ourData); + + assert.deepEqual(expected, actual.toJSON()); + + // We want to ensure that conversion doesn't modify incoming data + assert.deepEqual(record, recordCopy); + }); + + it('Generates expected protobuf with many old receiver chains', () => { + const record: any = { + sessions: { + '\u0005W‡¿“\u0000lÈ\nyª\u000eümB0\u0017j„.Û£³-s\u0016č(O_M': { + registrationId: 4243, + currentRatchet: { + rootKey: + 'Ë\u00035/üœÚšg\u0003Xeûú\u0010—\u0000ü\u0002¶»o5\u001cƒ—­¥\u0004Ðÿ«', + lastRemoteEphemeralKey: + '\u0005\n7\u001cmT…b!è\u000eÍ\u0007\u0016m4g³\u0005‘üœIYŒê\b\u0011ÏÎPs', + previousCounter: 2, + ephemeralKeyPair: { + privKey: 'ä—ãÅ«ªŠàøí)ˆá\u0005Á"ŒsJM.¨¡\u0012r(N\f9Ô\b', + pubKey: '\u0005+\u00134–«1\u0000\u0013l *ãKçnºÖó³íTSŸ&Œ{ù ͂>1', + }, + }, + indexInfo: { + remoteIdentityKey: '\u0005¨¨©üÏäúo፩êO¢çúxr»Æ¿rœ²GžùiT@', + closed: -1, + baseKey: '\u0005W‡¿“\u0000lÈ\nyª\u000eümB0\u0017j„.Û£³-s\u0016č(O_M', + baseKeyType: 2, + }, + oldRatchetList: [ + { + added: 1605579954962, + ephemeralKey: + '\u00050»­\n¨ÊA‘ä\u0006¢Ç´d\u0002\u00129}%î}ΩTc}8€¼\u0011n\\', + }, + { + added: 1605580408250, + ephemeralKey: + '\u0005^Ä\nò›À¢\u0000\u000f­A\\6+Ó\u001a÷&×$¸¬ÑÔ|‘x‚ƒÄÈ?þv~íkx â¬.ðo™òDg\u001eß.\r', + }, + { + added: 1606766530935, + ephemeralKey: + '\u0005\u0014@ž½M†,à\bóó™…}¨`i¿\u0000©I\u0001ôG\u001f”:Ù{ó\u0005 ', + }, + { + added: 1608326293655, + ephemeralKey: '\u0005µÒ\u0014?È¢+ÑR÷ç?3šDºƒ\\@0‹†\u0004®+-\bŽr\t', + }, + { + added: 1609871105317, + ephemeralKey: + '\u0005„±@íN"Í\u0019HS{$ï\u0017”[Ñ\\\u001a*;>P\u0000\u001f\u000eHNaù)', + }, + { + added: 1611707063523, + ephemeralKey: '\u0005Þg”Åkéƒ\u0001\u0013—¡ÿûNXÈ(9\u0006¤’w˜®/عRi‹JI', + }, + { + added: 1612211156372, + ephemeralKey: '\u0005:[ÛOˆ–pd¯ ÂÙç\u0010Oއw{}ý\bw–9Àߝ=“\u0014Z', + }, + ], + '\u00050»­\n¨ÊA‘ä\u0006¢Ç´d\u0002\u00129}%î}ΩTc}8€¼\u0011n\\': { + messageKeys: {}, + chainKey: { + counter: 0, + }, + chainType: 2, + }, + '\u0005^Ä\nò›À¢\u0000\u000f­A\\6+Ó\u001a÷&×$¸¬ÑÔ|‘x‚ƒÄÈ?þv~íkx â¬.ðo™òDg\u001eß.\r': { + messageKeys: { + '4': '©}j›¿Š¼\u0014q\tŠ¥”Á”ñ\u0003: ÷ÞrƒñûÔµ%Æ\u001a', + }, + chainKey: { + counter: 6, + }, + chainType: 2, + }, + '\u0005\u0014@ž½M†,à\bóó™…}¨`i¿\u0000©I\u0001ôG\u001f”:Ù{ó\u0005 ': { + messageKeys: {}, + chainKey: { + counter: 0, + }, + chainType: 2, + }, + '\u0005µÒ\u0014?È¢+ÑR÷ç?3šDºƒ\\@0‹†\u0004®+-\bŽr\t': { + messageKeys: {}, + chainKey: { + counter: 2, + }, + chainType: 2, + }, + '\u0005„±@íN"Í\u0019HS{$ï\u0017”[Ñ\\\u001a*;>P\u0000\u001f\u000eHNaù)': { + messageKeys: { + '0': "1kÏ\u001cí+«<º‚\b'VÌ!×¼«PÃ[üáy;l'ƒ€€Ž", + '2': 'ö\u00047%L-…Wm)†›\u001d£ääíNô.Ô8…ÃÉ4r´ó^2', + '3': '¨¿¦›7T]\u001c\u001c“à4:x\u0019¿\u0002YÉÀ\u001bâjr¸»¤¢0,*', + '5': '™¥\u0006·q“gó4þ\u0011®ˆU4F\u001cl©\bŒäô…»ÊÇÆŽ[', + }, + chainKey: { + counter: 5, + }, + chainType: 2, + }, + '\u0005Þg”Åkéƒ\u0001\u0013—¡ÿûNXÈ(9\u0006¤’w˜®/عRi‹JI': { + messageKeys: { + '0': "]'8ŽWÄ\u0007…n˜º­Ö{ÿ7]ôäÄ!é\u000btA@°b¢)\u001ar", + '2': '­ÄfGÇjÖxÅö:×RÔi)M\u0019©IE+¨`þKá—;£Û½', + '3': '¦Õhýø`€Ö“PéPs;\u001e\u000bE}¨¿–õ\u0003uªøå\u00062(×G', + '9': 'Ï^—<‘Õú̃\u0001i´;ït¼\u001aÑ?ï\u0014lãàÆ¸ƒ\u001a8“/m', + }, + chainKey: { + counter: 11, + }, + chainType: 2, + }, + '\u0005:[ÛOˆ–pd¯ ÂÙç\u0010Oއw{}ý\bw–9Àߝ=“\u0014Z': { + messageKeys: { + '0': '!\u00115\\W~|¯oa2\u001e\u0004Vž8Ï¡d}\u001b\u001a8^QÖfvÕ"‹', + }, + chainKey: { + counter: 1, + }, + chainType: 2, + }, + '\u0005\n7\u001cmT…b!è\u000eÍ\u0007\u0016m4g³\u0005‘üœIYŒê\b\u0011ÏÎPs': { + messageKeys: { + '0': 'Îgó¯‘2àvñ‘X_õ\u0014–Ç\u0000öl\u001f4J>ŒÐÏ{`-Ü5¦', + '4': 'c¿<µâ¼Xµƒ!Ù¯µ®[—n<žìîúcoå©n\u0013"l]Ð', + }, + chainKey: { + counter: 5, + key: 'Z{òÙ8سAÝdSZ†k\n×\u001cô¡\u001b[YÒ¶ƒ\u0016a°\u0004<', + }, + chainType: 2, + }, + '\u0005+\u00134–«1\u0000\u0013l *ãKçnºÖó³íTSŸ&Œ{ù ͂>1': { + messageKeys: {}, + chainKey: { + counter: -1, + key: + "èB?7\u000f¯\u001e\u0010¨\u0007}:“?¹\u0010$\\ë~ª\u0000gM0՘'£\u0005", + }, + chainType: 1, + }, + }, + }, + version: 'v1', + }; + + const expected = { + currentSession: { + sessionVersion: 1, + localIdentityPublic: 'Baioqfzc/5JD6b+GNqapPouf6eHK7xr9ynLJHnvl+444', + remoteIdentityPublic: 'BaioqfzP5JD6b+GNqepPouf6eHK7xr9ynLJHnvlpgVRA', + rootKey: 'ywM1L/yc2ppnA1hl+/oQlwD8Ara7bzUcg5etpQTQ/6s=', + previousCounter: 2, + senderChain: { + senderRatchetKey: 'BSsTNJarMQATbCAq40vnbrrW87PtVFOfJox7+SDNgj4x', + senderRatchetKeyPrivate: + '5JfjxauqiuD47SmI4QXBIoxzSk0uqKEScp0oTgw51Ag=', + chainKey: { + index: -1, + key: '6EI/Nw+vHhCoB499OpM/kLkQJFzrfqoAZ00w1ZgnowU=', + }, + }, + receiverChains: [ + { + senderRatchetKey: 'BQo3HG1UhWIh6A7NBxZtNGezBZH8nElZjOqNCBHPzlBz', + chainKey: { + index: 5, + key: 'Wnvy2TjYs0HdZFNahmsKw5cc9KEbW1nSwraDFmGwBDw=', + }, + messageKeys: [ + { + index: 0, + cipherKey: 'xVreEbT7Vtrxs85JyGBj6Y+UWftQz4H72F5kWV4cxqM=', + iv: 'TcRanSxZVWbuIq0xDRGnEw==', + macKey: '5fW9aIKXhtwWp/5alNJUIXInZbztf2ywzQSpYrXoQ3A=', + }, + { + index: 4, + cipherKey: 'A99HjM4pUugsQ5+2v48FGTGEhZPoW6wzW9MqSc11QQ4=', + iv: 'bE8Ei2Rkaoz4SKRwdG4+tQ==', + macKey: 'TOTdbAf0bCHOzcQ3lBaIm3yqmpEqvvldD0qTuDFmkAI=', + }, + ], + }, + { + senderRatchetKey: 'BTpb20+IlnBkryDC2ecQT96Hd3t9/Qh3ljnA3509kxRa', + chainKey: { + index: 1, + }, + messageKeys: [ + { + index: 0, + cipherKey: 'aAbSz5jOagUTgQKo3aqExcl8hyZANrY+HvrLc/OgoQI=', + iv: 'JcyLzw0fL67Kd4tfGJ2OUQ==', + macKey: 'dt+RXeaeIx+ASrKSk7D4guwTE1IUYl3LiLG9aI4sZm8=', + }, + ], + }, + { + senderRatchetKey: 'Bd5nlMVr6YMBE5eh//tOWMgoOQakkneYri/YuVJpi0pJ', + chainKey: { + index: 11, + }, + messageKeys: [ + { + index: 0, + cipherKey: 'pjcY/7MoRGtGHwNN/E8KqoKCx/5mdKp0VCmrmkBAj+M=', + iv: 'eBpAEoDj94NsI0vsf+4Hrw==', + macKey: 'P7Jz2KkOXC7B0mLkz7JaU/d0vdaYZjAfuKJ86xXB19U=', + }, + { + index: 2, + cipherKey: 'EGDj0sc/1TMtSycYDCrpZdl6UCzCzDuMwlAvVVAs2OQ=', + iv: 'A+1OA9M2Z8gGlARtA231RA==', + macKey: 'oQ/PQxJDD52qrkShSy6hD3fASEfhWnlmY3qsSPuOY/o=', + }, + { + index: 3, + cipherKey: 'WM3UUILGdECXjO8jZbBVYrPAnzRM8RdiU+PSAyHUT5U=', + iv: 'CWuQIuIyGqApA6MQgnDR5Q==', + macKey: 'hg+/xrOKFzn2eK1BnJ5C+ERsFgaWAOaBxQTc4q3b/g8=', + }, + { + index: 9, + cipherKey: 'T0cBaGAseFz+s2njVr4sqbFf1pUH5PoPvdMBoizIT+Y=', + iv: 'hkT2kqgqhlORAjBI7ZDsig==', + macKey: 'uE/Dd4WSQWkYNRgolcQtOd+HpaHP5wGogMzErkZj+AQ=', + }, + ], + }, + { + senderRatchetKey: 'BYSxQO1OIs0ZSFN7JI/vF5Rb0VwaKjs+UAAfDkhOYfkp', + chainKey: { + index: 5, + }, + messageKeys: [ + { + index: 0, + cipherKey: 'ni6XhRCoLFud2Zk1zoel4he8znDG/t+TWVBASO35GlQ=', + iv: 'rKy/sxLmQ4j2DSxbDZTO5A==', + macKey: 'MKxs29AmNOnp6zZOsIbrmSqcVXYJL01kuvIaqwjRNvQ=', + }, + { + index: 2, + cipherKey: 'Pp7GOD72vfjvb3qx7qm1YVoZKPqnyXC2uqCt89ZA/yc=', + iv: 'NuDf5iM0lD/o0YzjHZo4mA==', + macKey: 'JkBZiaxmwFr1xh/zzTQE6mlUIVJmSIrqSIQVlaoTz7M=', + }, + { + index: 3, + cipherKey: 'zORWRvJEUe2F4UnBwe2YRqPS4GzUFE1lWptcqMzWf2U=', + iv: 'Og7jF9JJhiLtPD8W2OgTnw==', + macKey: 'Lxbcl9fL9x5Javtdz7tOV7Bbr8ar3rWxSIsi1Focv9w=', + }, + { + index: 5, + cipherKey: 'T/TZNw04+ZfB0s2ltOT9qbzRPnCFn7VvxqHHAvORFx0=', + iv: 'DpOAK77ErIr2QFTsRnfOew==', + macKey: 'k/fxafepBiA0dQOTpohL+EKm2+1jpFwRigVWt02U/Jg=', + }, + ], + }, + { + senderRatchetKey: 'BbXSFD/IoivRUvfnPzOaRLqDXEAwi4YEristfwiOj3IJ', + chainKey: { + index: 2, + }, + }, + { + senderRatchetKey: 'BRRAnr1NhizgCPPzmYV9qGBpvwCpSQH0Rx+UOtl78wUg', + chainKey: { + index: 0, + }, + }, + { + senderRatchetKey: 'BZvOKPA+kXiCg8TIP/52fu1reCDirC7wb5nyRGce3y4N', + chainKey: { + index: 6, + }, + messageKeys: [ + { + index: 4, + cipherKey: 'PB44plPzHam/o2LZnyjo8HLRuAvp3uE6ixO5+GUCUsA=', + iv: 'JBbgRb10X/dDsn0GKg69dA==', + macKey: 'jKV1Rmlb0HATZHndLDIMONPgOXqT3kwE1QEstxXVe+o=', + }, + ], + }, + { + senderRatchetKey: 'Ba9q9bHjMHfbUNDCU8+0O7cmEcIluq+wk3/d2f7q+ThG', + chainKey: { + index: 3, + }, + messageKeys: [ + { + index: 0, + cipherKey: '4buOJSqRFIpWwo4pXYwQTCTxas4+amBLpZ/CuEWXbPg=', + iv: '9uD8ECO/fxtK28OvlCFXuQ==', + macKey: 'LI0ZSdX7k+cd5bTgs6XEYYIWY+2cxhWI97vAGFpoZIc=', + }, + { + index: 1, + cipherKey: 'oNbFxcy2eebUQhoD+NLf12fgkXzhn4EU0Pgqn1bVKOs=', + iv: 'o1mm4rCN6Q0J1hA7I5jjgA==', + macKey: 'dfHB14sCIdun+RaKnAoyaQPC6qRDMewjqOIDZGmn3Es=', + }, + { + index: 2, + cipherKey: '/aU3zX2IdA91GAcB+7H57yzRe+6CgZ61tlW4M/rkCJI=', + iv: 'v8VJF467QDD1ZCr1JD8pbQ==', + macKey: 'MjK5iYjhZtQTJ4Eu3+qGOdYxn0G23EGRtTcusbzy9OA=', + }, + ], + }, + { + senderRatchetKey: 'BTwX5SmcUeBG7mwyOZ3YgxyXIN0ktzuEdWTfBUmPfGYG', + chainKey: { + index: 1, + }, + }, + { + senderRatchetKey: 'BV7ECvKbwKIAD61BXDYr0xr3JtckuKzR1Hw8cVPWGtlo', + chainKey: { + index: 2, + }, + }, + { + senderRatchetKey: 'BTC7rQqoykGR5Aaix7RkAhI5fSXufc6pVGN9OIC8EW5c', + chainKey: { + index: 0, + }, + }, + ], + remoteRegistrationId: 4243, + localRegistrationId: 3554, + aliceBaseKey: 'BVeHv5MAbMgKeaoO/G1CMBdqhC7bo7Mtc4EWxI0oT19N', + }, + }; + + const recordCopy = getRecordCopy(record); + + const actual = sessionRecordToProtobuf(record, ourData); + + assert.deepEqual(expected, actual.toJSON()); + + // We want to ensure that conversion doesn't modify incoming data + assert.deepEqual(record, recordCopy); + }); + + it('Generates expected protobuf with pending prekey', () => { + const record: any = { + sessions: { + '\u0005W‡¿“\u0000lÈ\nyª\u000eümB0\u0017j„.Û£³-s\u0016č(O_M': { + registrationId: 4243, + currentRatchet: { + rootKey: + 'Ë\u00035/üœÚšg\u0003Xeûú\u0010—\u0000ü\u0002¶»o5\u001cƒ—­¥\u0004Ðÿ«', + lastRemoteEphemeralKey: + '\u0005\n7\u001cmT…b!è\u000eÍ\u0007\u0016m4g³\u0005‘üœIYŒê\b\u0011ÏÎPs', + previousCounter: 2, + ephemeralKeyPair: { + privKey: 'ä—ãÅ«ªŠàøí)ˆá\u0005Á"ŒsJM.¨¡\u0012r(N\f9Ô\b', + pubKey: '\u0005+\u00134–«1\u0000\u0013l *ãKçnºÖó³íTSŸ&Œ{ù ͂>1', + }, + }, + indexInfo: { + remoteIdentityKey: '\u0005¨¨©üÏäúo፩êO¢çúxr»Æ¿rœ²GžùiT@', + closed: -1, + baseKey: '\u0005W‡¿“\u0000lÈ\nyª\u000eümB0\u0017j„.Û£³-s\u0016č(O_M', + baseKeyType: 2, + }, + pendingPreKey: { + baseKey: '\u0005ui©üÏäúo፩êO¢çúxr»Æ¿rœ²GžùiT@', + signedKeyId: 38, + preKeyId: 2, + }, + oldRatchetList: [], + '\u0005\n7\u001cmT…b!è\u000eÍ\u0007\u0016m4g³\u0005‘üœIYŒê\b\u0011ÏÎPs': { + messageKeys: { + '0': 'Îgó¯‘2àvñ‘X_õ\u0014–Ç\u0000öl\u001f4J>ŒÐÏ{`-Ü5¦', + '4': 'c¿<µâ¼Xµƒ!Ù¯µ®[—n<žìîúcoå©n\u0013"l]Ð', + }, + chainKey: { + counter: 5, + key: 'Z{òÙ8سAÝdSZ†k\n×\u001cô¡\u001b[YÒ¶ƒ\u0016a°\u0004<', + }, + chainType: 2, + }, + '\u0005+\u00134–«1\u0000\u0013l *ãKçnºÖó³íTSŸ&Œ{ù ͂>1': { + messageKeys: {}, + chainKey: { + counter: -1, + key: + "èB?7\u000f¯\u001e\u0010¨\u0007}:“?¹\u0010$\\ë~ª\u0000gM0՘'£\u0005", + }, + chainType: 1, + }, + }, + }, + version: 'v1', + }; + + const expected = { + currentSession: { + sessionVersion: 1, + localIdentityPublic: 'Baioqfzc/5JD6b+GNqapPouf6eHK7xr9ynLJHnvl+444', + remoteIdentityPublic: 'BaioqfzP5JD6b+GNqepPouf6eHK7xr9ynLJHnvlpgVRA', + rootKey: 'ywM1L/yc2ppnA1hl+/oQlwD8Ara7bzUcg5etpQTQ/6s=', + previousCounter: 2, + senderChain: { + senderRatchetKey: 'BSsTNJarMQATbCAq40vnbrrW87PtVFOfJox7+SDNgj4x', + senderRatchetKeyPrivate: + '5JfjxauqiuD47SmI4QXBIoxzSk0uqKEScp0oTgw51Ag=', + chainKey: { + index: -1, + key: '6EI/Nw+vHhCoB499OpM/kLkQJFzrfqoAZ00w1ZgnowU=', + }, + }, + receiverChains: [ + { + senderRatchetKey: 'BQo3HG1UhWIh6A7NBxZtNGezBZH8nElZjOqNCBHPzlBz', + chainKey: { + index: 5, + key: 'Wnvy2TjYs0HdZFNahmsKw5cc9KEbW1nSwraDFmGwBDw=', + }, + messageKeys: [ + { + index: 0, + cipherKey: 'xVreEbT7Vtrxs85JyGBj6Y+UWftQz4H72F5kWV4cxqM=', + iv: 'TcRanSxZVWbuIq0xDRGnEw==', + macKey: '5fW9aIKXhtwWp/5alNJUIXInZbztf2ywzQSpYrXoQ3A=', + }, + { + index: 4, + cipherKey: 'A99HjM4pUugsQ5+2v48FGTGEhZPoW6wzW9MqSc11QQ4=', + iv: 'bE8Ei2Rkaoz4SKRwdG4+tQ==', + macKey: 'TOTdbAf0bCHOzcQ3lBaIm3yqmpEqvvldD0qTuDFmkAI=', + }, + ], + }, + ], + pendingPreKey: { + preKeyId: 2, + baseKey: 'BXVpqfzP5JD6b+GNqepPouf6eHK7xr9ynLJHnvlpgVRA', + signedPreKeyId: 38, + }, + remoteRegistrationId: 4243, + localRegistrationId: 3554, + aliceBaseKey: 'BVeHv5MAbMgKeaoO/G1CMBdqhC7bo7Mtc4EWxI0oT19N', + }, + }; + + const recordCopy = getRecordCopy(record); + + const actual = sessionRecordToProtobuf(record, ourData); + + assert.deepEqual(expected, actual.toJSON()); + + // We want to ensure that conversion doesn't modify incoming data + assert.deepEqual(record, recordCopy); + }); + + it('Generates expected protobuf with multiple sessions', () => { + const record: any = { + sessions: { + '\u0005W‡¿“\u0000lÈ\nyª\u000eümB0\u0017j„.Û£³-s\u0016č(O_M': { + registrationId: 4243, + currentRatchet: { + rootKey: + 'Ë\u00035/üœÚšg\u0003Xeûú\u0010—\u0000ü\u0002¶»o5\u001cƒ—­¥\u0004Ðÿ«', + lastRemoteEphemeralKey: + '\u0005\n7\u001cmT…b!è\u000eÍ\u0007\u0016m4g³\u0005‘üœIYŒê\b\u0011ÏÎPs', + previousCounter: 2, + ephemeralKeyPair: { + privKey: 'ä—ãÅ«ªŠàøí)ˆá\u0005Á"ŒsJM.¨¡\u0012r(N\f9Ô\b', + pubKey: '\u0005+\u00134–«1\u0000\u0013l *ãKçnºÖó³íTSŸ&Œ{ù ͂>1', + }, + }, + indexInfo: { + remoteIdentityKey: '\u0005¨¨©üÏäúo፩êO¢çúxr»Æ¿rœ²GžùiT@', + closed: -1, + baseKey: '\u0005W‡¿“\u0000lÈ\nyª\u000eümB0\u0017j„.Û£³-s\u0016č(O_M', + baseKeyType: 2, + }, + oldRatchetList: [], + '\u0005\n7\u001cmT…b!è\u000eÍ\u0007\u0016m4g³\u0005‘üœIYŒê\b\u0011ÏÎPs': { + messageKeys: { + '0': 'Îgó¯‘2àvñ‘X_õ\u0014–Ç\u0000öl\u001f4J>ŒÐÏ{`-Ü5¦', + '4': 'c¿<µâ¼Xµƒ!Ù¯µ®[—n<žìîúcoå©n\u0013"l]Ð', + }, + chainKey: { + counter: 5, + key: 'Z{òÙ8سAÝdSZ†k\n×\u001cô¡\u001b[YÒ¶ƒ\u0016a°\u0004<', + }, + chainType: 2, + }, + '\u0005+\u00134–«1\u0000\u0013l *ãKçnºÖó³íTSŸ&Œ{ù ͂>1': { + messageKeys: {}, + chainKey: { + counter: -1, + key: + "èB?7\u000f¯\u001e\u0010¨\u0007}:“?¹\u0010$\\ë~ª\u0000gM0՘'£\u0005", + }, + chainType: 1, + }, + }, + '\u0005BD¿Z\u0000lÈ\nyª\u000eümB0\u0017j„.Û£³-s\u0016č(O_M': { + registrationId: 3432, + currentRatchet: { + rootKey: + 'Ë\u00035/üœÚšg\u0003Xeûú\u0010—\u0000ü\u0002¶»o5\u001cƒ—­¥\u0004Ðÿ«', + lastRemoteEphemeralKey: + '\u0005\n7\u001cmT…b!è\u000eÍ\u0007\u0016m4g³\u0005‘üœIYŒê\b\u0011ÏÎPs', + previousCounter: 2, + ephemeralKeyPair: { + privKey: 'ä—ãÅ«ªŠàøí)ˆá\u0005Á"ŒsJM.¨¡\u0012r(N\f9Ô\b', + pubKey: '\u0005+\u00134–«1\u0000\u0013l *ãKçnºÖó³íTSŸ&Œ{ù ͂>1', + }, + }, + indexInfo: { + remoteIdentityKey: '\u0005¨¨©üÏäúo፩êO¢çúxr»Æ¿rœ²GžùiT@', + closed: 1605579954962, + baseKey: '\u0005BD¿Z\u0000lÈ\nyª\u000eümB0\u0017j„.Û£³-s\u0016č(O_M', + baseKeyType: 2, + }, + oldRatchetList: [], + '\u0005\n7\u001cmT…b!è\u000eÍ\u0007\u0016m4g³\u0005‘üœIYŒê\b\u0011ÏÎPs': { + messageKeys: { + '2': 'Îgó¯‘2àvñ‘X_õ\u0014–Ç\u0000öl\u001f4J>ŒÐÏ{`-Ü5¦', + '3': 'c¿<µâ¼Xµƒ!Ù¯µ®[—n<žìîúcoå©n\u0013"l]Ð', + }, + chainKey: { + counter: 5, + key: 'Z{òÙ8سAÝdSZ†k\n×\u001cô¡\u001b[YÒ¶ƒ\u0016a°\u0004<', + }, + chainType: 2, + }, + '\u0005+\u00134–«1\u0000\u0013l *ãKçnºÖó³íTSŸ&Œ{ù ͂>1': { + messageKeys: {}, + chainKey: { + counter: -1, + key: + "èB?7\u000f¯\u001e\u0010¨\u0007}:“?¹\u0010$\\ë~ª\u0000gM0՘'£\u0005", + }, + chainType: 1, + }, + }, + '\u0005AN¿C\u0000lÈ\nyª\u000eümB0\u0017j„.Û£³-s\u0016č(O_M': { + registrationId: 2312, + currentRatchet: { + rootKey: + 'Ë\u00035/üœÚšg\u0003Xeûú\u0010—\u0000ü\u0002¶»o5\u001cƒ—­¥\u0004Ðÿ«', + lastRemoteEphemeralKey: + '\u0005\n7\u001cmT…b!è\u000eÍ\u0007\u0016m4g³\u0005‘üœIYŒê\b\u0011ÏÎPs', + previousCounter: 2, + ephemeralKeyPair: { + privKey: 'ä—ãÅ«ªŠàøí)ˆá\u0005Á"ŒsJM.¨¡\u0012r(N\f9Ô\b', + pubKey: '\u0005+\u00134–«1\u0000\u0013l *ãKçnºÖó³íTSŸ&Œ{ù ͂>1', + }, + }, + indexInfo: { + remoteIdentityKey: '\u0005¨¨©üÏäúo፩êO¢çúxr»Æ¿rœ²GžùiT@', + closed: 1605580407000, + baseKey: '\u0005AN¿C\u0000lÈ\nyª\u000eümB0\u0017j„.Û£³-s\u0016č(O_M', + baseKeyType: 2, + }, + oldRatchetList: [], + '\u0005\n7\u001cmT…b!è\u000eÍ\u0007\u0016m4g³\u0005‘üœIYŒê\b\u0011ÏÎPs': { + messageKeys: { + '1': 'Îgó¯‘2àvñ‘X_õ\u0014–Ç\u0000öl\u001f4J>ŒÐÏ{`-Ü5¦', + '5': 'c¿<µâ¼Xµƒ!Ù¯µ®[—n<žìîúcoå©n\u0013"l]Ð', + }, + chainKey: { + counter: 5, + key: 'Z{òÙ8سAÝdSZ†k\n×\u001cô¡\u001b[YÒ¶ƒ\u0016a°\u0004<', + }, + chainType: 2, + }, + '\u0005+\u00134–«1\u0000\u0013l *ãKçnºÖó³íTSŸ&Œ{ù ͂>1': { + messageKeys: {}, + chainKey: { + counter: -1, + key: + "èB?7\u000f¯\u001e\u0010¨\u0007}:“?¹\u0010$\\ë~ª\u0000gM0՘'£\u0005", + }, + chainType: 1, + }, + }, + }, + version: 'v1', + }; + + const expected = { + currentSession: { + sessionVersion: 1, + localIdentityPublic: 'Baioqfzc/5JD6b+GNqapPouf6eHK7xr9ynLJHnvl+444', + remoteIdentityPublic: 'BaioqfzP5JD6b+GNqepPouf6eHK7xr9ynLJHnvlpgVRA', + rootKey: 'ywM1L/yc2ppnA1hl+/oQlwD8Ara7bzUcg5etpQTQ/6s=', + previousCounter: 2, + senderChain: { + senderRatchetKey: 'BSsTNJarMQATbCAq40vnbrrW87PtVFOfJox7+SDNgj4x', + senderRatchetKeyPrivate: + '5JfjxauqiuD47SmI4QXBIoxzSk0uqKEScp0oTgw51Ag=', + chainKey: { + index: -1, + key: '6EI/Nw+vHhCoB499OpM/kLkQJFzrfqoAZ00w1ZgnowU=', + }, + }, + receiverChains: [ + { + senderRatchetKey: 'BQo3HG1UhWIh6A7NBxZtNGezBZH8nElZjOqNCBHPzlBz', + chainKey: { + index: 5, + key: 'Wnvy2TjYs0HdZFNahmsKw5cc9KEbW1nSwraDFmGwBDw=', + }, + messageKeys: [ + { + index: 0, + cipherKey: 'xVreEbT7Vtrxs85JyGBj6Y+UWftQz4H72F5kWV4cxqM=', + iv: 'TcRanSxZVWbuIq0xDRGnEw==', + macKey: '5fW9aIKXhtwWp/5alNJUIXInZbztf2ywzQSpYrXoQ3A=', + }, + { + index: 4, + cipherKey: 'A99HjM4pUugsQ5+2v48FGTGEhZPoW6wzW9MqSc11QQ4=', + iv: 'bE8Ei2Rkaoz4SKRwdG4+tQ==', + macKey: 'TOTdbAf0bCHOzcQ3lBaIm3yqmpEqvvldD0qTuDFmkAI=', + }, + ], + }, + ], + remoteRegistrationId: 4243, + localRegistrationId: 3554, + aliceBaseKey: 'BVeHv5MAbMgKeaoO/G1CMBdqhC7bo7Mtc4EWxI0oT19N', + }, + previousSessions: [ + { + sessionVersion: 1, + localIdentityPublic: 'Baioqfzc/5JD6b+GNqapPouf6eHK7xr9ynLJHnvl+444', + remoteIdentityPublic: 'BaioqfzP5JD6b+GNqepPouf6eHK7xr9ynLJHnvlpgVRA', + rootKey: 'ywM1L/yc2ppnA1hl+/oQlwD8Ara7bzUcg5etpQTQ/6s=', + previousCounter: 2, + senderChain: { + senderRatchetKey: 'BSsTNJarMQATbCAq40vnbrrW87PtVFOfJox7+SDNgj4x', + senderRatchetKeyPrivate: + '5JfjxauqiuD47SmI4QXBIoxzSk0uqKEScp0oTgw51Ag=', + chainKey: { + index: -1, + key: '6EI/Nw+vHhCoB499OpM/kLkQJFzrfqoAZ00w1ZgnowU=', + }, + }, + receiverChains: [ + { + senderRatchetKey: 'BQo3HG1UhWIh6A7NBxZtNGezBZH8nElZjOqNCBHPzlBz', + chainKey: { + index: 5, + key: 'Wnvy2TjYs0HdZFNahmsKw5cc9KEbW1nSwraDFmGwBDw=', + }, + messageKeys: [ + { + index: 1, + cipherKey: 'xVreEbT7Vtrxs85JyGBj6Y+UWftQz4H72F5kWV4cxqM=', + iv: 'TcRanSxZVWbuIq0xDRGnEw==', + macKey: '5fW9aIKXhtwWp/5alNJUIXInZbztf2ywzQSpYrXoQ3A=', + }, + { + index: 5, + cipherKey: 'A99HjM4pUugsQ5+2v48FGTGEhZPoW6wzW9MqSc11QQ4=', + iv: 'bE8Ei2Rkaoz4SKRwdG4+tQ==', + macKey: 'TOTdbAf0bCHOzcQ3lBaIm3yqmpEqvvldD0qTuDFmkAI=', + }, + ], + }, + ], + remoteRegistrationId: 2312, + localRegistrationId: 3554, + aliceBaseKey: 'BUFOv0MAbMgKeaoO/G1CMBdqhC7bo7Mtc4EWxI0oT19N', + }, + { + sessionVersion: 1, + localIdentityPublic: 'Baioqfzc/5JD6b+GNqapPouf6eHK7xr9ynLJHnvl+444', + remoteIdentityPublic: 'BaioqfzP5JD6b+GNqepPouf6eHK7xr9ynLJHnvlpgVRA', + rootKey: 'ywM1L/yc2ppnA1hl+/oQlwD8Ara7bzUcg5etpQTQ/6s=', + previousCounter: 2, + senderChain: { + senderRatchetKey: 'BSsTNJarMQATbCAq40vnbrrW87PtVFOfJox7+SDNgj4x', + senderRatchetKeyPrivate: + '5JfjxauqiuD47SmI4QXBIoxzSk0uqKEScp0oTgw51Ag=', + chainKey: { + index: -1, + key: '6EI/Nw+vHhCoB499OpM/kLkQJFzrfqoAZ00w1ZgnowU=', + }, + }, + receiverChains: [ + { + senderRatchetKey: 'BQo3HG1UhWIh6A7NBxZtNGezBZH8nElZjOqNCBHPzlBz', + chainKey: { + index: 5, + key: 'Wnvy2TjYs0HdZFNahmsKw5cc9KEbW1nSwraDFmGwBDw=', + }, + messageKeys: [ + { + index: 2, + cipherKey: 'xVreEbT7Vtrxs85JyGBj6Y+UWftQz4H72F5kWV4cxqM=', + iv: 'TcRanSxZVWbuIq0xDRGnEw==', + macKey: '5fW9aIKXhtwWp/5alNJUIXInZbztf2ywzQSpYrXoQ3A=', + }, + { + index: 3, + cipherKey: 'A99HjM4pUugsQ5+2v48FGTGEhZPoW6wzW9MqSc11QQ4=', + iv: 'bE8Ei2Rkaoz4SKRwdG4+tQ==', + macKey: 'TOTdbAf0bCHOzcQ3lBaIm3yqmpEqvvldD0qTuDFmkAI=', + }, + ], + }, + ], + remoteRegistrationId: 3432, + localRegistrationId: 3554, + aliceBaseKey: 'BUJEv1oAbMgKeaoO/G1CMBdqhC7bo7Mtc4EWxI0oT19N', + }, + ], + }; + + const recordCopy = getRecordCopy(record); + + const actual = sessionRecordToProtobuf(record, ourData); + + assert.deepEqual(expected, actual.toJSON()); + + // We want to ensure that conversion doesn't modify incoming data + assert.deepEqual(record, recordCopy); + }); + + it('Generates expected protobuf with just-initialized session', () => { + const record: any = { + sessions: { + '\u00055>=eV¹\u0019ۉn¾¯—#ß¶_=\u0013.Nî\u001a¥%…-]ù_\n': { + registrationId: 3188, + currentRatchet: { + rootKey: '\u001b1Ÿ6ŒÊæðʨ¾>}Ú©ˆÄH¸sNÓ:ˆÈF¹³QÖi', + lastRemoteEphemeralKey: + '\u0005KÆ\\û«\u0003Ñ\u0005ÚûU±iú\u0012iˆÃ\u0011]¼åUà\u001f¯òÉ~&\u0003', + previousCounter: 0, + ephemeralKeyPair: { + privKey: + " -&\t]$\u0015P\u001fù\u000e\u001c\u001e'y…\u001eïËîEÑ+éa†ª± :wM", + pubKey: '\u0005\u0014¦çœ\u0002ò\u001aÆå\u001a{—Ø1´èn‚žn•Ç(ÛK©8PË"h', + }, + }, + indexInfo: { + remoteIdentityKey: '\u0005\u0019Ú䍧\u0006×d˜â°ˆu§õ`EËTe%H¢!&Ù8cˆz*', + closed: -1, + baseKey: '\u00055>=eV¹\u0019ۉn¾¯—#ß¶_=\u0013.Nî\u001a¥%…-]ù_\n', + baseKeyType: 1, + }, + oldRatchetList: [], + '\u0005\u0014¦çœ\u0002ò\u001aÆå\u001a{—Ø1´èn‚žn•Ç(ÛK©8PË"h': { + messageKeys: {}, + chainKey: { + counter: 0, + key: '¶^Do/jî\u000fU諈ª\u0011Œxnõ\u0011Æò}Ðó*äÇÊÂ\u0000', + }, + chainType: 1, + }, + pendingPreKey: { + signedKeyId: 2995, + baseKey: '\u00055>=eV¹\u0019ۉn¾¯—#ß¶_=\u0013.Nî\u001a¥%…-]ù_\n', + preKeyId: 386, + }, + }, + }, + version: 'v1', + }; + + const expected = { + currentSession: { + aliceBaseKey: 'BTU+PWVWuRnbiW6+ja+XI9+2Xz0TLk7uGqUlhS1d+V8K', + localIdentityPublic: 'Baioqfzc/5JD6b+GNqapPouf6eHK7xr9ynLJHnvl+444', + localRegistrationId: 3554, + pendingPreKey: { + baseKey: 'BTU+PWVWuRnbiW6+ja+XI9+2Xz0TLk7uGqUlhS1d+V8K', + preKeyId: 386, + signedPreKeyId: 2995, + }, + previousCounter: 0, + remoteIdentityPublic: 'BRmB2uSNpwbXZJjisIh1p/VgRctUZSVIoiEm2ThjiHoq', + remoteRegistrationId: 3188, + rootKey: 'GzGfNozK5vDKqL4+fdqpiMRIuHNOndM6iMhGubNR1mk=', + senderChain: { + chainKey: { + index: 0, + key: 'tl5Eby9q7n8PVeiriKoRjHhu9Y0RxvJ90PMq5MfKwgA=', + }, + senderRatchetKey: 'BRSm55wC8hrG5Rp7l9gxtOhugp5ulcco20upOFCPyyJo', + senderRatchetKeyPrivate: + 'IC0mCV0kFVAf+Q4cHid5hR7vy+5F0SvpYYaqsSA6d00=', + }, + sessionVersion: 1, + }, + }; + + const recordCopy = getRecordCopy(record); + + const actual = sessionRecordToProtobuf(record, ourData); + + assert.deepEqual(expected, actual.toJSON()); + + // We want to ensure that conversion doesn't modify incoming data + assert.deepEqual(record, recordCopy); + }); +}); diff --git a/ts/util/index.ts b/ts/util/index.ts index eab75b1aee..c550e7e21b 100644 --- a/ts/util/index.ts +++ b/ts/util/index.ts @@ -22,6 +22,10 @@ import { sleep } from './sleep'; import { longRunningTaskWrapper } from './longRunningTaskWrapper'; import { toWebSafeBase64, fromWebSafeBase64 } from './webSafeBase64'; import { mapToSupportLocale } from './mapToSupportLocale'; +import { + sessionRecordToProtobuf, + sessionStructureToArrayBuffer, +} from './sessionTranslation'; import * as zkgroup from './zkgroup'; export { @@ -45,6 +49,8 @@ export { missingCaseError, parseRemoteClientExpiration, Registration, + sessionRecordToProtobuf, + sessionStructureToArrayBuffer, sleep, toWebSafeBase64, zkgroup, diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index fd5779a120..64ae6ce97a 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -15241,78 +15241,6 @@ "reasonCategory": "falseMatch", "updated": "2020-02-07T19:52:28.522Z" }, - { - "rule": "jQuery-before(", - "path": "ts/test-electron/models/messages_test.js", - "line": " before(async () => {", - "lineNumber": 47, - "reasonCategory": "testCode", - "updated": "2020-10-21T00:45:53.649Z", - "reasonDetail": "Test code and a false positive." - }, - { - "rule": "jQuery-load(", - "path": "ts/test-electron/models/messages_test.js", - "line": " await window.ConversationController.load();", - "lineNumber": 49, - "reasonCategory": "testCode", - "updated": "2020-10-21T00:45:53.649Z", - "reasonDetail": "Test code and a false positive." - }, - { - "rule": "jQuery-after(", - "path": "ts/test-electron/models/messages_test.js", - "line": " after(async () => {", - "lineNumber": 53, - "reasonCategory": "testCode", - "updated": "2020-10-21T00:45:53.649Z", - "reasonDetail": "Test code and a false positive." - }, - { - "rule": "jQuery-before(", - "path": "ts/test-electron/models/messages_test.ts", - "line": " before(async () => {", - "lineNumber": 29, - "reasonCategory": "testCode", - "updated": "2020-10-21T00:45:53.649Z", - "reasonDetail": "Test code and a false positive." - }, - { - "rule": "jQuery-load(", - "path": "ts/test-electron/models/messages_test.ts", - "line": " await window.ConversationController.load();", - "lineNumber": 31, - "reasonCategory": "testCode", - "updated": "2020-10-21T00:45:53.649Z", - "reasonDetail": "Test code and a false positive." - }, - { - "rule": "jQuery-after(", - "path": "ts/test-electron/models/messages_test.ts", - "line": " after(async () => {", - "lineNumber": 36, - "reasonCategory": "testCode", - "updated": "2020-10-21T00:45:53.649Z", - "reasonDetail": "Test code and a false positive." - }, - { - "rule": "jQuery-before(", - "path": "ts/test-node/util/windowsZoneIdentifier_test.js", - "line": " before(function thisNeeded() {", - "lineNumber": 33, - "reasonCategory": "testCode", - "updated": "2020-09-02T18:59:59.432Z", - "reasonDetail": "This is test code (and isn't jQuery code)." - }, - { - "rule": "jQuery-before(", - "path": "ts/test-node/util/windowsZoneIdentifier_test.ts", - "line": " before(function thisNeeded() {", - "lineNumber": 15, - "reasonCategory": "testCode", - "updated": "2020-09-02T18:59:59.432Z", - "reasonDetail": "This is test code (and isn't jQuery code)." - }, { "rule": "jQuery-append(", "path": "ts/textsecure/ContactsParser.js", @@ -15466,4 +15394,4 @@ "updated": "2021-01-08T15:46:32.143Z", "reasonDetail": "Doesn't manipulate the DOM. This is just a function." } -] \ No newline at end of file +] diff --git a/ts/util/lint/linter.ts b/ts/util/lint/linter.ts index c80847a2ad..217b0c89ac 100644 --- a/ts/util/lint/linter.ts +++ b/ts/util/lint/linter.ts @@ -72,6 +72,7 @@ const excludedFilesRegexps = [ '^libtextsecure/test/test.js', '^sticker-creator/dist/bundle.js', '^test/test.js', + '^ts/test[^/]*/.+', // From libsignal-protocol-javascript project '^libtextsecure/libsignal-protocol.js', diff --git a/ts/util/sessionTranslation.ts b/ts/util/sessionTranslation.ts new file mode 100644 index 0000000000..c5a4ac970e --- /dev/null +++ b/ts/util/sessionTranslation.ts @@ -0,0 +1,409 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { get, isFinite, isInteger, isString } from 'lodash'; +import { HKDF } from 'libsignal-client'; + +import { signal } from '../protobuf/compiled'; +import { + bytesFromString, + fromEncodedBinaryToArrayBuffer, + typedArrayToArrayBuffer, +} from '../Crypto'; + +const { RecordStructure, SessionStructure } = signal.proto.storage; +const { Chain } = SessionStructure; + +type KeyPairType = { + privKey?: string; + pubKey?: string; +}; + +type OldRatchetType = { + added?: number; + ephemeralKey?: string; +}; + +type SessionType = { + registrationId?: number; + currentRatchet?: { + rootKey?: string; + lastRemoteEphemeralKey?: string; + previousCounter?: number; + ephemeralKeyPair?: KeyPairType; + }; + indexInfo?: { + remoteIdentityKey?: string; + closed?: number; + baseKey?: string; + baseKeyType?: number; + }; + pendingPreKey?: { + baseKey?: string; + signedPreKeyId?: number; + // The first two are required; this one is optional + preKeyId?: number; + }; + oldRatchetList?: Array; + + // Note: ChainTypes are stored here, keyed by their baseKey. Typescript + /// doesn't allow that kind of combination definition (known keys and + // indexer), so we force session to `any` below whenever we access it like + // `session[baseKey]`. +}; + +type MessageKeyGroup = { + [key: string]: string; +}; + +type ChainType = { + messageKeys?: MessageKeyGroup; + chainKey?: { + counter?: number; + key?: string; + }; + chainType: number; +}; + +type SessionListType = { + [key: string]: SessionType; +}; + +type SessionRecordType = { + sessions?: SessionListType; + version?: 'v1'; +}; + +export type LocalUserDataType = { + identityKeyPublic: ArrayBuffer; + registrationId: number; +}; + +export function sessionStructureToArrayBuffer( + recordStructure: signal.proto.storage.RecordStructure +): ArrayBuffer { + return typedArrayToArrayBuffer( + signal.proto.storage.RecordStructure.encode(recordStructure).finish() + ); +} + +export function sessionRecordToProtobuf( + record: SessionRecordType, + ourData: LocalUserDataType +): signal.proto.storage.RecordStructure { + const proto = new RecordStructure(); + + proto.previousSessions = []; + + const sessionGroup = record.sessions || {}; + const sessions = Object.values(sessionGroup); + + const first = sessions.find(session => { + return session?.indexInfo?.closed === -1; + }); + + if (first) { + proto.currentSession = toProtobufSession(first, ourData); + } + + sessions.sort((left, right) => { + // Descending - we want recently-closed sessions to be first + return (right?.indexInfo?.closed || 0) - (left?.indexInfo?.closed || 0); + }); + const onlyClosed = sessions.filter( + session => session?.indexInfo?.closed !== -1 + ); + + if (onlyClosed.length < sessions.length - 1) { + throw new Error('toProtobuf: More than one open session!'); + } + + proto.previousSessions = []; + onlyClosed.forEach(session => { + proto.previousSessions.push(toProtobufSession(session, ourData)); + }); + + if (!proto.currentSession && proto.previousSessions.length === 0) { + throw new Error('toProtobuf: Record had no sessions!'); + } + + return proto; +} + +function toProtobufSession( + session: SessionType, + ourData: LocalUserDataType +): signal.proto.storage.SessionStructure { + const proto = new SessionStructure(); + + // Core Fields + + proto.aliceBaseKey = binaryToUint8Array(session, 'indexInfo.baseKey', 33); + proto.localIdentityPublic = new Uint8Array(ourData.identityKeyPublic); + proto.localRegistrationId = ourData.registrationId; + + proto.previousCounter = getInteger(session, 'currentRatchet.previousCounter'); + proto.remoteIdentityPublic = binaryToUint8Array( + session, + 'indexInfo.remoteIdentityKey', + 33 + ); + proto.remoteRegistrationId = getInteger(session, 'registrationId'); + proto.rootKey = binaryToUint8Array(session, 'currentRatchet.rootKey', 32); + proto.sessionVersion = 1; + + // Note: currently unused + // proto.needsRefresh = null; + + // Pending PreKey + + if (session.pendingPreKey) { + proto.pendingPreKey = new signal.proto.storage.SessionStructure.PendingPreKey(); + proto.pendingPreKey.baseKey = binaryToUint8Array( + session, + 'pendingPreKey.baseKey', + 33 + ); + proto.pendingPreKey.signedPreKeyId = getInteger( + session, + 'pendingPreKey.signedKeyId' + ); + + if (session.pendingPreKey.preKeyId !== undefined) { + proto.pendingPreKey.preKeyId = getInteger( + session, + 'pendingPreKey.preKeyId' + ); + } + } + + // Sender Chain + + const senderBaseKey = session.currentRatchet?.ephemeralKeyPair?.pubKey; + if (!senderBaseKey) { + throw new Error('toProtobufSession: No sender base key!'); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const senderChain = (session as any)[senderBaseKey] as ChainType | undefined; + if (!senderChain) { + throw new Error( + 'toProtobufSession: No matching chain found with senderBaseKey!' + ); + } + + if (senderChain.chainType !== 1) { + throw new Error( + `toProtobufSession: Expected sender chain type for senderChain, got ${senderChain.chainType}` + ); + } + + const protoSenderChain = toProtobufChain(senderChain); + + protoSenderChain.senderRatchetKey = binaryToUint8Array( + session, + 'currentRatchet.ephemeralKeyPair.pubKey', + 33 + ); + protoSenderChain.senderRatchetKeyPrivate = binaryToUint8Array( + session, + 'currentRatchet.ephemeralKeyPair.privKey', + 32 + ); + + proto.senderChain = protoSenderChain; + + // First Receiver Chain + + proto.receiverChains = []; + + const firstReceiverChainBaseKey = + session.currentRatchet?.lastRemoteEphemeralKey; + if (!firstReceiverChainBaseKey) { + throw new Error('toProtobufSession: No receiver base key!'); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const firstReceiverChain = (session as any)[firstReceiverChainBaseKey] as + | ChainType + | undefined; + + // If the session was just initialized, then there will be no receiver chain + if (firstReceiverChain) { + const protoFirstReceiverChain = toProtobufChain(firstReceiverChain); + + if (firstReceiverChain.chainType !== 2) { + throw new Error( + `toProtobufSession: Expected receiver chain type for firstReceiverChain, got ${firstReceiverChain.chainType}` + ); + } + + protoFirstReceiverChain.senderRatchetKey = binaryToUint8Array( + session, + 'currentRatchet.lastRemoteEphemeralKey', + 33 + ); + + proto.receiverChains.push(protoFirstReceiverChain); + } + + // Old Receiver Chains + + const oldChains = (session.oldRatchetList || []) + .slice(0) + .sort((left, right) => (right.added || 0) - (left.added || 0)); + oldChains.forEach(oldRatchet => { + const baseKey = oldRatchet.ephemeralKey; + if (!baseKey) { + throw new Error('toProtobufSession: No base key for old receiver chain!'); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const chain = (session as any)[baseKey] as ChainType | undefined; + if (!chain) { + throw new Error( + 'toProtobufSession: No chain for old receiver chain base key!' + ); + } + + if (chain.chainType !== 2) { + throw new Error( + `toProtobufSession: Expected receiver chain type, got ${chain.chainType}` + ); + } + + const protoChain = toProtobufChain(chain); + + protoChain.senderRatchetKey = binaryToUint8Array( + oldRatchet, + 'ephemeralKey', + 33 + ); + + proto.receiverChains.push(protoChain); + }); + + return proto; +} + +function toProtobufChain( + chain: ChainType +): signal.proto.storage.SessionStructure.Chain { + const proto = new Chain(); + + const protoChainKey = new Chain.ChainKey(); + protoChainKey.index = getInteger(chain, 'chainKey.counter'); + if (chain.chainKey?.key !== undefined) { + protoChainKey.key = binaryToUint8Array(chain, 'chainKey.key', 32); + } + proto.chainKey = protoChainKey; + + const messageKeys = Object.entries(chain.messageKeys || {}); + proto.messageKeys = messageKeys.map(entry => { + const protoMessageKey = new SessionStructure.Chain.MessageKey(); + protoMessageKey.index = getInteger(entry, '0'); + const key = binaryToUint8Array(entry, '1', 32); + + const { cipherKey, macKey, iv } = translateMessageKey(key); + + protoMessageKey.cipherKey = new Uint8Array(cipherKey); + protoMessageKey.macKey = new Uint8Array(macKey); + protoMessageKey.iv = new Uint8Array(iv); + + return protoMessageKey; + }); + + return proto; +} + +// Utility functions + +const WHISPER_MESSAGE_KEYS = 'WhisperMessageKeys'; + +function deriveSecrets( + input: ArrayBuffer, + salt: ArrayBuffer, + info: ArrayBuffer +): Array { + const hkdf = HKDF.new(3); + const output = hkdf.deriveSecrets( + 3 * 32, + Buffer.from(input), + Buffer.from(info), + Buffer.from(salt) + ); + return [ + typedArrayToArrayBuffer(output.slice(0, 32)), + typedArrayToArrayBuffer(output.slice(32, 64)), + typedArrayToArrayBuffer(output.slice(64, 96)), + ]; +} + +function translateMessageKey(key: Uint8Array) { + const input = key.buffer; + const salt = new ArrayBuffer(32); + const info = bytesFromString(WHISPER_MESSAGE_KEYS); + + const [cipherKey, macKey, ivContainer] = deriveSecrets(input, salt, info); + + return { + cipherKey, + macKey, + iv: ivContainer.slice(0, 16), + }; +} + +function binaryToUint8Array( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + object: any, + path: string, + length: number +): Uint8Array { + const target = get(object, path); + if (target === null || target === undefined) { + throw new Error(`binaryToUint8Array: Falsey path ${path}`); + } + + if (!isString(target)) { + throw new Error(`binaryToUint8Array: String not found at path ${path}`); + } + + const buffer = fromEncodedBinaryToArrayBuffer(target); + if (length && buffer.byteLength !== length) { + throw new Error( + `binaryToUint8Array: Got unexpected length ${buffer.byteLength} instead of ${length} at path ${path}` + ); + } + + return new Uint8Array(buffer); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function getInteger(object: any, path: string): number { + const target = get(object, path); + if (target === null || target === undefined) { + throw new Error(`getInteger: Falsey path ${path}`); + } + + if (isString(target)) { + const result = parseInt(target, 10); + if (!isFinite(result)) { + throw new Error( + `getInteger: Value could not be parsed as number at ${path}: {target}` + ); + } + + if (!isInteger(result)) { + throw new Error( + `getInteger: Parsed value not an integer at ${path}: {target}` + ); + } + + return result; + } + + if (!isInteger(target)) { + throw new Error(`getInteger: Value not an integer at ${path}: {target}`); + } + + return target; +} From d82ce079421c3fa08a0920a90b7abc19b1bb0e59 Mon Sep 17 00:00:00 2001 From: Josh Perez <60019601+josh-signal@users.noreply.github.com> Date: Thu, 4 Mar 2021 16:44:57 -0500 Subject: [PATCH 037/181] Improve cold start performance --- about_preload.js | 2 +- debug_log_preload.js | 2 +- js/expiring_messages.js | 50 +- js/expiring_tap_to_view_messages.js | 4 +- js/message_controller.js | 30 +- libtextsecure/test/_test.js | 13 + main.js | 53 +- permissions_popup_preload.js | 2 +- preload.js | 4 +- settings_preload.js | 2 +- test/_test.js | 10 + ts/background.ts | 48 +- ts/components/LightboxGallery.stories.tsx | 9 +- .../AttachmentSection.stories.tsx | 3 +- .../media-gallery/AttachmentSection.tsx | 3 +- .../media-gallery/MediaGallery.tsx | 3 +- .../media-gallery/groupMediaItemsByDate.ts | 3 +- .../media-gallery/types/Message.ts | 2 + ts/groups.ts | 3 +- ts/logging/main_process_logging.ts | 29 +- ts/logging/set_up_renderer_logging.ts | 53 +- ts/model-types.d.ts | 1 + ts/models/conversations.ts | 39 +- ts/models/messages.ts | 138 +++-- ts/sql/Client.ts | 477 +++++------------- ts/sql/Interface.ts | 8 +- ts/sql/Queueing.ts | 141 ++++++ ts/sql/Server.ts | 106 ++-- ts/sql/initialize.ts | 16 + ts/test-electron/models/messages_test.ts | 5 +- .../media-gallery/groupMessagesByDate_test.ts | 11 + ts/textsecure.d.ts | 3 + ts/textsecure/MessageReceiver.ts | 191 +++---- ts/util/getMessageTimestamp.ts | 8 + ts/util/incrementMessageCounter.ts | 23 + ts/util/index.ts | 9 +- ts/util/lint/exceptions.json | 4 +- ts/util/messageBatcher.ts | 24 + ts/window.d.ts | 7 + 39 files changed, 911 insertions(+), 628 deletions(-) create mode 100644 ts/sql/Queueing.ts create mode 100644 ts/sql/initialize.ts create mode 100644 ts/util/getMessageTimestamp.ts create mode 100644 ts/util/incrementMessageCounter.ts create mode 100644 ts/util/messageBatcher.ts diff --git a/about_preload.js b/about_preload.js index 1d7d969b3a..2f9fa80f7d 100644 --- a/about_preload.js +++ b/about_preload.js @@ -25,4 +25,4 @@ window.closeAbout = () => ipcRenderer.send('close-about'); window.i18n = i18n.setup(locale, localeMessages); -require('./ts/logging/set_up_renderer_logging'); +require('./ts/logging/set_up_renderer_logging').initialize(); diff --git a/debug_log_preload.js b/debug_log_preload.js index 6f8c18fa91..c63f7a552d 100644 --- a/debug_log_preload.js +++ b/debug_log_preload.js @@ -32,7 +32,7 @@ window.getEnvironment = getEnvironment; window.Backbone = require('backbone'); require('./ts/backbone/views/whisper_view'); require('./ts/backbone/views/toast_view'); -require('./ts/logging/set_up_renderer_logging'); +require('./ts/logging/set_up_renderer_logging').initialize(); window.closeDebugLog = () => ipcRenderer.send('close-debug-log'); window.Backbone = require('backbone'); diff --git a/js/expiring_messages.js b/js/expiring_messages.js index 5618ca5f20..fb181937ab 100644 --- a/js/expiring_messages.js +++ b/js/expiring_messages.js @@ -18,32 +18,38 @@ MessageCollection: Whisper.MessageCollection, }); - await Promise.all( - messages.map(async fromDB => { - const message = MessageController.register(fromDB.id, fromDB); + const messageIds = []; + const inMemoryMessages = []; + const messageCleanup = []; - window.log.info('Message expired', { - sentAt: message.get('sent_at'), - }); + messages.forEach(dbMessage => { + const message = MessageController.register(dbMessage.id, dbMessage); + messageIds.push(message.id); + inMemoryMessages.push(message); + messageCleanup.push(message.cleanup()); + }); - // We delete after the trigger to allow the conversation time to process - // the expiration before the message is removed from the database. - await window.Signal.Data.removeMessage(message.id, { - Message: Whisper.Message, - }); + // We delete after the trigger to allow the conversation time to process + // the expiration before the message is removed from the database. + await window.Signal.Data.removeMessages(messageIds); + await Promise.all(messageCleanup); - Whisper.events.trigger( - 'messageExpired', - message.id, - message.conversationId - ); + inMemoryMessages.forEach(message => { + window.log.info('Message expired', { + sentAt: message.get('sent_at'), + }); - const conversation = message.getConversation(); - if (conversation) { - conversation.trigger('expired', message); - } - }) - ); + Whisper.events.trigger( + 'messageExpired', + message.id, + message.conversationId + ); + + const conversation = message.getConversation(); + if (conversation) { + conversation.trigger('expired', message); + } + }); } catch (error) { window.log.error( 'destroyExpiredMessages: Error deleting expired messages', diff --git a/js/expiring_tap_to_view_messages.js b/js/expiring_tap_to_view_messages.js index 52ff70ea84..32cc027db5 100644 --- a/js/expiring_tap_to_view_messages.js +++ b/js/expiring_tap_to_view_messages.js @@ -58,7 +58,9 @@ return; } - const nextCheck = toAgeOut.get('received_at') + THIRTY_DAYS; + const receivedAt = + toAgeOut.get('received_at_ms') || toAgeOut.get('received_at'); + const nextCheck = receivedAt + THIRTY_DAYS; Whisper.TapToViewMessagesListener.nextCheck = nextCheck; window.log.info( diff --git a/js/message_controller.js b/js/message_controller.js index c42933b504..43f2bd85f3 100644 --- a/js/message_controller.js +++ b/js/message_controller.js @@ -6,6 +6,8 @@ window.Whisper = window.Whisper || {}; const messageLookup = Object.create(null); + const msgIDsBySender = new Map(); + const msgIDsBySentAt = new Map(); const SECOND = 1000; const MINUTE = SECOND * 60; @@ -31,10 +33,18 @@ timestamp: Date.now(), }; + msgIDsBySentAt.set(message.get('sent_at'), id); + msgIDsBySender.set(message.getSenderIdentifier(), id); + return message; } function unregister(id) { + const { message } = messageLookup[id] || {}; + if (message) { + msgIDsBySender.delete(message.getSenderIdentifier()); + msgIDsBySentAt.delete(message.get('sent_at')); + } delete messageLookup[id]; } @@ -50,7 +60,7 @@ now - timestamp > FIVE_MINUTES && (!conversation || !conversation.messageCollection.length) ) { - delete messageLookup[message.id]; + unregister(message.id); } } } @@ -60,6 +70,22 @@ return existing && existing.message ? existing.message : null; } + function findBySentAt(sentAt) { + const id = msgIDsBySentAt.get(sentAt); + if (!id) { + return null; + } + return getById(id); + } + + function findBySender(sender) { + const id = msgIDsBySender.get(sender); + if (!id) { + return null; + } + return getById(id); + } + function _get() { return messageLookup; } @@ -70,6 +96,8 @@ register, unregister, cleanup, + findBySender, + findBySentAt, getById, _get, }; diff --git a/libtextsecure/test/_test.js b/libtextsecure/test/_test.js index 6d5a73d42f..54b1b7faf2 100644 --- a/libtextsecure/test/_test.js +++ b/libtextsecure/test/_test.js @@ -67,3 +67,16 @@ window.Whisper.events = { on() {}, trigger() {}, }; + +before(async () => { + try { + window.log.info('Initializing SQL in renderer'); + await window.sqlInitializer.initialize(); + window.log.info('SQL initialized in renderer'); + } catch (err) { + window.log.error( + 'SQL failed to initialize', + err && err.stack ? err.stack : err + ); + } +}); diff --git a/main.js b/main.js index 7126fec468..d2d1e9d1a4 100644 --- a/main.js +++ b/main.js @@ -19,6 +19,8 @@ const electron = require('electron'); const packageJson = require('./package.json'); const GlobalErrors = require('./app/global_errors'); const { setup: setupSpellChecker } = require('./app/spell_check'); +const { redactAll } = require('./js/modules/privacy'); +const removeUserConfig = require('./app/user_config').remove; GlobalErrors.addHandler(); @@ -30,6 +32,7 @@ const getRealPath = pify(fs.realpath); const { app, BrowserWindow, + clipboard, dialog, ipcMain: ipc, Menu, @@ -1058,12 +1061,37 @@ app.on('ready', async () => { loadingWindow.loadURL(prepareURL([__dirname, 'loading.html'])); }); - const success = await sqlInitPromise; - - if (!success) { + try { + await sqlInitPromise; + } catch (error) { console.log('sql.initialize was unsuccessful; returning early'); + const buttonIndex = dialog.showMessageBoxSync({ + buttons: [ + locale.messages.copyErrorAndQuit.message, + locale.messages.deleteAndRestart.message, + ], + defaultId: 0, + detail: redactAll(error.stack), + message: locale.messages.databaseError.message, + noLink: true, + type: 'error', + }); + + if (buttonIndex === 0) { + clipboard.writeText( + `Database startup error:\n\n${redactAll(error.stack)}` + ); + } else { + await sql.removeDB(); + removeUserConfig(); + app.relaunch(); + } + + app.exit(1); + return; } + // eslint-disable-next-line more/no-then appStartInitialSpellcheckSetting = await getSpellCheckSetting(); await sqlChannels.initialize(); @@ -1075,10 +1103,10 @@ app.on('ready', async () => { await sql.removeIndexedDBFiles(); await sql.removeItemById(IDB_KEY); } - } catch (error) { + } catch (err) { console.log( '(ready event handler) error deleting IndexedDB:', - error && error.stack ? error.stack : error + err && err.stack ? err.stack : err ); } @@ -1113,10 +1141,10 @@ app.on('ready', async () => { try { await attachments.clearTempPath(userDataPath); - } catch (error) { + } catch (err) { logger.error( 'main/ready: Error deleting temp dir:', - error && error.stack ? error.stack : error + err && err.stack ? err.stack : err ); } await attachmentChannel.initialize({ @@ -1458,6 +1486,17 @@ ipc.on('locale-data', event => { event.returnValue = locale.messages; }); +// Used once to initialize SQL in the renderer process +ipc.once('user-config-key', event => { + // eslint-disable-next-line no-param-reassign + event.returnValue = userConfig.get('key'); +}); + +ipc.on('get-user-data-path', event => { + // eslint-disable-next-line no-param-reassign + event.returnValue = app.getPath('userData'); +}); + function getDataFromMainWindow(name, callback) { ipc.once(`get-success-${name}`, (_event, error, value) => callback(error, value) diff --git a/permissions_popup_preload.js b/permissions_popup_preload.js index 63730f95db..ee3ccd02b1 100644 --- a/permissions_popup_preload.js +++ b/permissions_popup_preload.js @@ -49,7 +49,7 @@ window.subscribeToSystemThemeChange = fn => { }); }; -require('./ts/logging/set_up_renderer_logging'); +require('./ts/logging/set_up_renderer_logging').initialize(); window.closePermissionsPopup = () => ipcRenderer.send('close-permissions-popup'); diff --git a/preload.js b/preload.js index 66e8dc143c..fc3888b03d 100644 --- a/preload.js +++ b/preload.js @@ -22,6 +22,8 @@ try { const { app } = remote; const { nativeTheme } = remote.require('electron'); + window.sqlInitializer = require('./ts/sql/initialize'); + window.PROTO_ROOT = 'protos'; const config = require('url').parse(window.location.toString(), true).query; @@ -400,7 +402,7 @@ try { // We pull these dependencies in now, from here, because they have Node.js dependencies - require('./ts/logging/set_up_renderer_logging'); + require('./ts/logging/set_up_renderer_logging').initialize(); if (config.proxyUrl) { window.log.info('Using provided proxy url'); diff --git a/settings_preload.js b/settings_preload.js index 9a4d21aa62..5a138750e2 100644 --- a/settings_preload.js +++ b/settings_preload.js @@ -129,4 +129,4 @@ function makeSetter(name) { window.Backbone = require('backbone'); require('./ts/backbone/views/whisper_view'); require('./ts/backbone/views/toast_view'); -require('./ts/logging/set_up_renderer_logging'); +require('./ts/logging/set_up_renderer_logging').initialize(); diff --git a/test/_test.js b/test/_test.js index 12a61bf8f1..ffe8e55783 100644 --- a/test/_test.js +++ b/test/_test.js @@ -76,6 +76,16 @@ function deleteIndexedDB() { /* Delete the database before running any tests */ before(async () => { await deleteIndexedDB(); + try { + window.log.info('Initializing SQL in renderer'); + await window.sqlInitializer.initialize(); + window.log.info('SQL initialized in renderer'); + } catch (err) { + window.log.error( + 'SQL failed to initialize', + err && err.stack ? err.stack : err + ); + } await window.Signal.Data.removeAll(); await window.storage.fetch(); }); diff --git a/ts/background.ts b/ts/background.ts index a3c2f8a3eb..30e5580b0f 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -3,8 +3,19 @@ import { DataMessageClass } from './textsecure.d'; import { WhatIsThis } from './window.d'; +import { assert } from './util/assert'; export async function startApp(): Promise { + try { + window.log.info('Initializing SQL in renderer'); + await window.sqlInitializer.initialize(); + window.log.info('SQL initialized in renderer'); + } catch (err) { + window.log.error( + 'SQL failed to initialize', + err && err.stack ? err.stack : err + ); + } const eventHandlerQueue = new window.PQueue({ concurrency: 1, timeout: 1000 * 60 * 2, @@ -1615,6 +1626,9 @@ export async function startApp(): Promise { let connectCount = 0; let connecting = false; async function connect(firstRun?: boolean) { + window.receivedAtCounter = + window.storage.get('lastReceivedAtCounter') || Date.now(); + if (connecting) { window.log.warn('connect already running', { connectCount }); return; @@ -2038,7 +2052,7 @@ export async function startApp(): Promise { logger: window.log, }); - let interval: NodeJS.Timer | null = setInterval(() => { + let interval: NodeJS.Timer | null = setInterval(async () => { const view = window.owsDesktopApp.appView; if (view) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -2046,6 +2060,24 @@ export async function startApp(): Promise { interval = null; view.onEmpty(); window.logAppLoadedEvent(); + const attachmentDownloadQueue = window.attachmentDownloadQueue || []; + const THREE_DAYS_AGO = Date.now() - 3600 * 72 * 1000; + const MAX_ATTACHMENT_MSGS_TO_DOWNLOAD = 250; + const attachmentsToDownload = attachmentDownloadQueue.filter( + (message, index) => + index <= MAX_ATTACHMENT_MSGS_TO_DOWNLOAD || + message.getReceivedAt() < THREE_DAYS_AGO + ); + window.log.info( + 'Downloading recent attachments of total attachments', + attachmentsToDownload.length, + attachmentDownloadQueue.length + ); + await Promise.all( + attachmentsToDownload.map(message => + message.queueAttachmentDownloads() + ) + ); } }, 500); @@ -2646,14 +2678,15 @@ export async function startApp(): Promise { sent_at: data.timestamp, serverTimestamp: data.serverTimestamp, sent_to: sentTo, - received_at: now, + received_at: data.receivedAtCounter, + received_at_ms: data.receivedAtDate, conversationId: descriptor.id, type: 'outgoing', sent: true, unidentifiedDeliveries: data.unidentifiedDeliveries || [], expirationStartTimestamp: Math.min( - data.expirationStartTimestamp || data.timestamp || Date.now(), - Date.now() + data.expirationStartTimestamp || data.timestamp || now, + now ), } as WhatIsThis); } @@ -2856,13 +2889,18 @@ export async function startApp(): Promise { data: WhatIsThis, descriptor: MessageDescriptor ) { + assert( + Boolean(data.receivedAtCounter), + `Did not receive receivedAtCounter for message: ${data.timestamp}` + ); return new window.Whisper.Message({ source: data.source, sourceUuid: data.sourceUuid, sourceDevice: data.sourceDevice, sent_at: data.timestamp, serverTimestamp: data.serverTimestamp, - received_at: Date.now(), + received_at: data.receivedAtCounter, + received_at_ms: data.receivedAtDate, conversationId: descriptor.id, unidentifiedDeliveryReceived: data.unidentifiedDeliveryReceived, type: 'incoming', diff --git a/ts/components/LightboxGallery.stories.tsx b/ts/components/LightboxGallery.stories.tsx index 6fad0185a0..568a844618 100644 --- a/ts/components/LightboxGallery.stories.tsx +++ b/ts/components/LightboxGallery.stories.tsx @@ -40,7 +40,8 @@ story.add('Image and Video', () => { message: { attachments: [], id: 'image-msg', - received_at: Date.now(), + received_at: 1, + received_at_ms: Date.now(), }, objectURL: '/fixtures/tina-rolf-269345-unsplash.jpg', }, @@ -55,7 +56,8 @@ story.add('Image and Video', () => { message: { attachments: [], id: 'video-msg', - received_at: Date.now(), + received_at: 2, + received_at_ms: Date.now(), }, objectURL: '/fixtures/pixabay-Soap-Bubble-7141.mp4', }, @@ -79,7 +81,8 @@ story.add('Missing Media', () => { message: { attachments: [], id: 'image-msg', - received_at: Date.now(), + received_at: 3, + received_at_ms: Date.now(), }, objectURL: undefined, }, diff --git a/ts/components/conversation/media-gallery/AttachmentSection.stories.tsx b/ts/components/conversation/media-gallery/AttachmentSection.stories.tsx index c924d1e9c7..4bde261e71 100644 --- a/ts/components/conversation/media-gallery/AttachmentSection.stories.tsx +++ b/ts/components/conversation/media-gallery/AttachmentSection.stories.tsx @@ -52,7 +52,8 @@ const createRandomFile = ( contentType, message: { id: random(now).toString(), - received_at: random(startTime, startTime + timeWindow), + received_at: Math.floor(Math.random() * 10), + received_at_ms: random(startTime, startTime + timeWindow), attachments: [], }, attachment: { diff --git a/ts/components/conversation/media-gallery/AttachmentSection.tsx b/ts/components/conversation/media-gallery/AttachmentSection.tsx index 662cc01e33..978d26dc78 100644 --- a/ts/components/conversation/media-gallery/AttachmentSection.tsx +++ b/ts/components/conversation/media-gallery/AttachmentSection.tsx @@ -9,6 +9,7 @@ import { MediaGridItem } from './MediaGridItem'; import { MediaItemType } from '../../LightboxGallery'; import { missingCaseError } from '../../../util/missingCaseError'; import { LocalizerType } from '../../../types/Util'; +import { getMessageTimestamp } from '../../../util/getMessageTimestamp'; export type Props = { i18n: LocalizerType; @@ -58,7 +59,7 @@ export class AttachmentSection extends React.Component { fileSize={attachment.size} shouldShowSeparator={shouldShowSeparator} onClick={onClick} - timestamp={message.received_at} + timestamp={getMessageTimestamp(message)} /> ); default: diff --git a/ts/components/conversation/media-gallery/MediaGallery.tsx b/ts/components/conversation/media-gallery/MediaGallery.tsx index 38a6b805d0..3be5abf2f2 100644 --- a/ts/components/conversation/media-gallery/MediaGallery.tsx +++ b/ts/components/conversation/media-gallery/MediaGallery.tsx @@ -12,6 +12,7 @@ import { groupMediaItemsByDate } from './groupMediaItemsByDate'; import { ItemClickEvent } from './types/ItemClickEvent'; import { missingCaseError } from '../../../util/missingCaseError'; import { LocalizerType } from '../../../types/Util'; +import { getMessageTimestamp } from '../../../util/getMessageTimestamp'; import { MediaItemType } from '../../LightboxGallery'; @@ -145,7 +146,7 @@ export class MediaGallery extends React.Component { const sections = groupMediaItemsByDate(now, mediaItems).map(section => { const first = section.mediaItems[0]; const { message } = first; - const date = moment(message.received_at); + const date = moment(getMessageTimestamp(message)); const header = section.type === 'yearMonth' ? date.format(MONTH_FORMAT) diff --git a/ts/components/conversation/media-gallery/groupMediaItemsByDate.ts b/ts/components/conversation/media-gallery/groupMediaItemsByDate.ts index 89dd441116..e217b8bd87 100644 --- a/ts/components/conversation/media-gallery/groupMediaItemsByDate.ts +++ b/ts/components/conversation/media-gallery/groupMediaItemsByDate.ts @@ -5,6 +5,7 @@ import moment from 'moment'; import { compact, groupBy, sortBy } from 'lodash'; import { MediaItemType } from '../../LightboxGallery'; +import { getMessageTimestamp } from '../../../util/getMessageTimestamp'; // import { missingCaseError } from '../../../util/missingCaseError'; @@ -120,7 +121,7 @@ const withSection = (referenceDateTime: moment.Moment) => ( const thisMonth = moment(referenceDateTime).startOf('month'); const { message } = mediaItem; - const mediaItemReceivedDate = moment.utc(message.received_at); + const mediaItemReceivedDate = moment.utc(getMessageTimestamp(message)); if (mediaItemReceivedDate.isAfter(today)) { return { order: 0, diff --git a/ts/components/conversation/media-gallery/types/Message.ts b/ts/components/conversation/media-gallery/types/Message.ts index 76d029e38b..4d295670d0 100644 --- a/ts/components/conversation/media-gallery/types/Message.ts +++ b/ts/components/conversation/media-gallery/types/Message.ts @@ -9,4 +9,6 @@ export type Message = { // Assuming this is for the API // eslint-disable-next-line camelcase received_at: number; + // eslint-disable-next-line camelcase + received_at_ms: number; }; diff --git a/ts/groups.ts b/ts/groups.ts index 568cc56b52..bebee4ec32 100644 --- a/ts/groups.ts +++ b/ts/groups.ts @@ -2452,7 +2452,8 @@ async function updateGroup({ return { ...changeMessage, conversationId: conversation.id, - received_at: finalReceivedAt, + received_at: window.Signal.Util.incrementMessageCounter(), + received_at_ms: finalReceivedAt, sent_at: syntheticSentAt, }; }); diff --git a/ts/logging/main_process_logging.ts b/ts/logging/main_process_logging.ts index 0448820b24..4981ed44c0 100644 --- a/ts/logging/main_process_logging.ts +++ b/ts/logging/main_process_logging.ts @@ -61,9 +61,9 @@ export async function initialize(): Promise { }, 500); } - const logFile = path.join(logPath, 'log.log'); + const logFile = path.join(logPath, 'main.log'); const loggerOptions: bunyan.LoggerOptions = { - name: 'log', + name: 'main', streams: [ { type: 'rotating-file', @@ -83,31 +83,6 @@ export async function initialize(): Promise { const logger = bunyan.createLogger(loggerOptions); - ipc.on('batch-log', (_first, batch: unknown) => { - if (!Array.isArray(batch)) { - logger.error( - 'batch-log IPC event was called with a non-array; dropping logs' - ); - return; - } - - batch.forEach(item => { - if (isLogEntry(item)) { - const levelString = getLogLevelString(item.level); - logger[levelString]( - { - time: item.time, - }, - item.msg - ); - } else { - logger.error( - 'batch-log IPC event was called with an invalid log entry; dropping entry' - ); - } - }); - }); - ipc.on('fetch-log', event => { fetch(logPath).then( data => { diff --git a/ts/logging/set_up_renderer_logging.ts b/ts/logging/set_up_renderer_logging.ts index d4c6d09514..6a090481d3 100644 --- a/ts/logging/set_up_renderer_logging.ts +++ b/ts/logging/set_up_renderer_logging.ts @@ -7,11 +7,11 @@ import { ipcRenderer as ipc } from 'electron'; import _ from 'lodash'; -import { levelFromName } from 'bunyan'; +import * as path from 'path'; +import * as bunyan from 'bunyan'; import { uploadDebugLogs } from './debuglogs'; import { redactAll } from '../../js/modules/privacy'; -import { createBatcher } from '../util/batcher'; import { LogEntryType, LogLevel, @@ -23,7 +23,7 @@ import * as log from './log'; import { reallyJsonStringify } from '../util/reallyJsonStringify'; // To make it easier to visually scan logs, we make all levels the same length -const levelMaxLength: number = Object.keys(levelFromName).reduce( +const levelMaxLength: number = Object.keys(bunyan.levelFromName).reduce( (maxLength, level) => Math.max(maxLength, level.length), 0 ); @@ -96,6 +96,30 @@ function fetch(): Promise { }); } +let globalLogger: undefined | bunyan; + +export function initialize(): void { + if (globalLogger) { + throw new Error('Already called initialize!'); + } + + const basePath = ipc.sendSync('get-user-data-path'); + const logFile = path.join(basePath, 'logs', 'app.log'); + const loggerOptions: bunyan.LoggerOptions = { + name: 'app', + streams: [ + { + type: 'rotating-file', + path: logFile, + period: '1d', + count: 3, + }, + ], + }; + + globalLogger = bunyan.createLogger(loggerOptions); +} + const publish = uploadDebugLogs; // A modern logging interface for the browser @@ -103,14 +127,6 @@ const publish = uploadDebugLogs; const env = window.getEnvironment(); const IS_PRODUCTION = env === 'production'; -const ipcBatcher = createBatcher({ - wait: 500, - maxSize: 500, - processBatch: (items: Array) => { - ipc.send('batch-log', items); - }, -}); - // The Bunyan API: https://github.com/trentm/node-bunyan#log-method-api function logAtLevel(level: LogLevel, ...args: ReadonlyArray): void { if (!IS_PRODUCTION) { @@ -120,11 +136,16 @@ function logAtLevel(level: LogLevel, ...args: ReadonlyArray): void { console._log(prefix, now(), ...args); } - ipcBatcher.add({ - level, - msg: cleanArgs(args), - time: new Date().toISOString(), - }); + const levelString = getLogLevelString(level); + const msg = cleanArgs(args); + const time = new Date().toISOString(); + + if (!globalLogger) { + throw new Error('Logger has not been initialized yet'); + return; + } + + globalLogger[levelString]({ time }, msg); } log.setLogAtLevel(logAtLevel); diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 7daba82ad9..541b9b1640 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -134,6 +134,7 @@ export type MessageAttributesType = { groupV2Change?: GroupV2ChangeType; // Required. Used to sort messages in the database for the conversation timeline. received_at?: number; + received_at_ms?: number; // More of a legacy feature, needed as we were updating the schema of messages in the // background, when we were still in IndexedDB, before attachments had gone to disk // We set this so that the idle message upgrade process doesn't pick this message up diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 549a74980c..53764e9ebd 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -2282,7 +2282,8 @@ export class ConversationModel extends window.Backbone.Model< conversationId: this.id, type: 'chat-session-refreshed', sent_at: receivedAt, - received_at: receivedAt, + received_at: window.Signal.Util.incrementMessageCounter(), + received_at_ms: receivedAt, unread: 1, // TODO: DESKTOP-722 // this type does not fully implement the interface it is expected to @@ -2315,7 +2316,8 @@ export class ConversationModel extends window.Backbone.Model< conversationId: this.id, type: 'keychange', sent_at: this.get('timestamp'), - received_at: timestamp, + received_at: window.Signal.Util.incrementMessageCounter(), + received_at_ms: timestamp, key_changed: keyChangedId, unread: 1, // TODO: DESKTOP-722 @@ -2373,7 +2375,8 @@ export class ConversationModel extends window.Backbone.Model< conversationId: this.id, type: 'verified-change', sent_at: lastMessage, - received_at: timestamp, + received_at: window.Signal.Util.incrementMessageCounter(), + received_at_ms: timestamp, verifiedChanged: verifiedChangeId, verified, local: options.local, @@ -2435,7 +2438,8 @@ export class ConversationModel extends window.Backbone.Model< conversationId: this.id, type: 'call-history', sent_at: timestamp, - received_at: timestamp, + received_at: window.Signal.Util.incrementMessageCounter(), + received_at_ms: timestamp, unread, callHistoryDetails: detailsToSave, // TODO: DESKTOP-722 @@ -2481,11 +2485,13 @@ export class ConversationModel extends window.Backbone.Model< profileChange: unknown, conversationId?: string ): Promise { + const now = Date.now(); const message = ({ conversationId: this.id, type: 'profile-change', - sent_at: Date.now(), - received_at: Date.now(), + sent_at: now, + received_at: window.Signal.Util.incrementMessageCounter(), + received_at_ms: now, unread: true, changedId: conversationId || this.id, profileChange, @@ -2984,7 +2990,8 @@ export class ConversationModel extends window.Backbone.Model< type: 'outgoing', conversationId: this.get('id'), sent_at: timestamp, - received_at: timestamp, + received_at: window.Signal.Util.incrementMessageCounter(), + received_at_ms: timestamp, recipients, deletedForEveryoneTimestamp: targetTimestamp, // TODO: DESKTOP-722 @@ -3093,7 +3100,8 @@ export class ConversationModel extends window.Backbone.Model< type: 'outgoing', conversationId: this.get('id'), sent_at: timestamp, - received_at: timestamp, + received_at: window.Signal.Util.incrementMessageCounter(), + received_at_ms: timestamp, recipients, reaction: outgoingReaction, // TODO: DESKTOP-722 @@ -3244,7 +3252,8 @@ export class ConversationModel extends window.Backbone.Model< preview, attachments, sent_at: now, - received_at: now, + received_at: window.Signal.Util.incrementMessageCounter(), + received_at_ms: now, expireTimer, recipients, sticker, @@ -3866,7 +3875,8 @@ export class ConversationModel extends window.Backbone.Model< conversationId: this.id, // No type; 'incoming' messages are specially treated by conversation.markRead() sent_at: timestamp, - received_at: timestamp, + received_at: window.Signal.Util.incrementMessageCounter(), + received_at_ms: timestamp, flags: window.textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, expirationTimerUpdate: { @@ -3970,7 +3980,8 @@ export class ConversationModel extends window.Backbone.Model< conversationId: this.id, // No type; 'incoming' messages are specially treated by conversation.markRead() sent_at: timestamp, - received_at: timestamp, + received_at: window.Signal.Util.incrementMessageCounter(), + received_at_ms: timestamp, // TODO: DESKTOP-722 } as unknown) as MessageAttributesType); @@ -4003,7 +4014,8 @@ export class ConversationModel extends window.Backbone.Model< conversationId: this.id, type: 'outgoing', sent_at: now, - received_at: now, + received_at: window.Signal.Util.incrementMessageCounter(), + received_at_ms: now, destination: this.get('e164'), destinationUuid: this.get('uuid'), recipients: this.getRecipients(), @@ -4059,7 +4071,8 @@ export class ConversationModel extends window.Backbone.Model< conversationId: this.id, type: 'outgoing', sent_at: now, - received_at: now, + received_at: window.Signal.Util.incrementMessageCounter(), + received_at_ms: now, // TODO: DESKTOP-722 } as unknown) as MessageAttributesType); diff --git a/ts/models/messages.ts b/ts/models/messages.ts index f31bdd31b2..4f90a329bd 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -198,6 +198,29 @@ export class MessageModel extends window.Backbone.Model { }; } + getSenderIdentifier(): string { + const sentAt = this.get('sent_at'); + const source = this.get('source'); + const sourceUuid = this.get('sourceUuid'); + const sourceDevice = this.get('sourceDevice'); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const sourceId = window.ConversationController.ensureContactIds({ + e164: source, + uuid: sourceUuid, + })!; + + return `${sourceId}.${sourceDevice}-${sentAt}`; + } + + getReceivedAt(): number { + // We would like to get the received_at_ms ideally since received_at is + // now an incrementing counter for messages and not the actual time that + // the message was received. If this field doesn't exist on the message + // then we can trust received_at. + return Number(this.get('received_at_ms') || this.get('received_at')); + } + isNormalBubble(): boolean { return ( !this.isCallHistory() && @@ -381,7 +404,7 @@ export class MessageModel extends window.Backbone.Model { return { sentAt: this.get('sent_at'), - receivedAt: this.get('received_at'), + receivedAt: this.getReceivedAt(), message: { ...this.getPropsForMessage(), disableMenu: true, @@ -1904,9 +1927,7 @@ export class MessageModel extends window.Backbone.Model { window.Whisper.Notifications.removeBy({ messageId: this.id }); if (!skipSave) { - await window.Signal.Data.saveMessage(this.attributes, { - Message: window.Whisper.Message, - }); + window.Signal.Util.updateMessageBatcher.add(this.attributes); } } @@ -1955,9 +1976,7 @@ export class MessageModel extends window.Backbone.Model { const id = this.get('id'); if (id && !skipSave) { - await window.Signal.Data.saveMessage(this.attributes, { - Message: window.Whisper.Message, - }); + window.Signal.Util.updateMessageBatcher.add(this.attributes); } } } @@ -2822,20 +2841,32 @@ export class MessageModel extends window.Backbone.Model { } ); - const collection = await window.Signal.Data.getMessagesBySentAt(id, { - MessageCollection: window.Whisper.MessageCollection, - }); - const found = collection.find(item => { - const messageAuthorId = item.getContactId(); + const inMemoryMessage = window.MessageController.findBySentAt(id); - return authorConversationId === messageAuthorId; - }); + let queryMessage; - if (!found) { - quote.referencedMessageNotFound = true; - return message; + if (inMemoryMessage) { + queryMessage = inMemoryMessage; + } else { + window.log.info('copyFromQuotedMessage: db lookup needed', id); + const collection = await window.Signal.Data.getMessagesBySentAt(id, { + MessageCollection: window.Whisper.MessageCollection, + }); + const found = collection.find(item => { + const messageAuthorId = item.getContactId(); + + return authorConversationId === messageAuthorId; + }); + + if (!found) { + quote.referencedMessageNotFound = true; + return message; + } + + queryMessage = window.MessageController.register(found.id, found); } - if (found.isTapToView()) { + + if (queryMessage.isTapToView()) { quote.text = null; quote.attachments = [ { @@ -2846,7 +2877,6 @@ export class MessageModel extends window.Backbone.Model { return message; } - const queryMessage = window.MessageController.register(found.id, found); quote.text = queryMessage.get('body'); if (firstAttachment) { firstAttachment.thumbnail = null; @@ -2946,9 +2976,20 @@ export class MessageModel extends window.Backbone.Model { ); // First, check for duplicates. If we find one, stop processing here. - const existingMessage = await getMessageBySender(this.attributes, { - Message: window.Whisper.Message, - }); + const inMemoryMessage = window.MessageController.findBySender( + this.getSenderIdentifier() + ); + if (!inMemoryMessage) { + window.log.info( + 'handleDataMessage: duplicate check db lookup needed', + this.getSenderIdentifier() + ); + } + const existingMessage = + inMemoryMessage || + (await getMessageBySender(this.attributes, { + Message: window.Whisper.Message, + })); const isUpdate = Boolean(data && data.isRecipientUpdate); if (existingMessage && type === 'incoming') { @@ -3422,7 +3463,7 @@ export class MessageModel extends window.Backbone.Model { dataMessage.expireTimer, source, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - message.get('received_at')!, + message.getReceivedAt()!, { fromGroupUpdate: message.isGroupUpdate(), } @@ -3437,7 +3478,7 @@ export class MessageModel extends window.Backbone.Model { undefined, source, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - message.get('received_at')! + message.getReceivedAt()! ); } } @@ -3573,7 +3614,8 @@ export class MessageModel extends window.Backbone.Model { (this.getConversation()!.getAccepted() || message.isOutgoing()) && !shouldHoldOffDownload ) { - await message.queueAttachmentDownloads(); + window.attachmentDownloadQueue = window.attachmentDownloadQueue || []; + window.attachmentDownloadQueue.unshift(message); } // Does this message have any pending, previously-received associated reactions? @@ -3591,24 +3633,11 @@ export class MessageModel extends window.Backbone.Model { ) ); - await window.Signal.Data.saveMessage(message.attributes, { - Message: window.Whisper.Message, - forceSave: true, - }); - - conversation.trigger('newmessage', message); - - if (message.get('unread')) { - await conversation.notify(message); - } - - // Increment the sent message count if this is an outgoing message - if (type === 'outgoing') { - conversation.incrementSentMessageCount(); - } - - window.Whisper.events.trigger('incrementProgress'); - confirm(); + window.log.info( + 'handleDataMessage: Batching save for', + message.get('sent_at') + ); + this.saveAndNotify(conversation, confirm); } catch (error) { const errorForLog = error && error.stack ? error.stack : error; window.log.error( @@ -3622,6 +3651,29 @@ export class MessageModel extends window.Backbone.Model { }); } + async saveAndNotify( + conversation: ConversationModel, + confirm: () => void + ): Promise { + await window.Signal.Util.saveNewMessageBatcher.add(this.attributes); + + window.log.info('Message saved', this.get('sent_at')); + + conversation.trigger('newmessage', this); + + if (this.get('unread')) { + await conversation.notify(this); + } + + // Increment the sent message count if this is an outgoing message + if (this.get('type') === 'outgoing') { + conversation.incrementSentMessageCount(); + } + + window.Whisper.events.trigger('incrementProgress'); + confirm(); + } + async handleReaction( reaction: typeof window.WhatIsThis, shouldPersist = true diff --git a/ts/sql/Client.ts b/ts/sql/Client.ts index 728dd33adc..14be25df34 100644 --- a/ts/sql/Client.ts +++ b/ts/sql/Client.ts @@ -9,18 +9,7 @@ /* eslint-disable @typescript-eslint/ban-types */ import { ipcRenderer } from 'electron'; -import { - cloneDeep, - compact, - fromPairs, - get, - groupBy, - isFunction, - last, - map, - omit, - set, -} from 'lodash'; +import { cloneDeep, get, groupBy, last, map, omit, set } from 'lodash'; import { arrayBufferToBase64, base64ToArrayBuffer } from '../Crypto'; import { CURRENT_SCHEMA_VERSION } from '../../js/modules/types/message'; @@ -36,7 +25,6 @@ import { import { AttachmentDownloadJobType, ClientInterface, - ClientJobType, ConversationType, IdentityKeyType, ItemType, @@ -44,7 +32,6 @@ import { MessageTypeUnhydrated, PreKeyType, SearchResultMessageType, - ServerInterface, SessionType, SignedPreKeyType, StickerPackStatusType, @@ -52,8 +39,10 @@ import { StickerType, UnprocessedType, } from './Interface'; +import Server from './Server'; import { MessageModel } from '../models/messages'; import { ConversationModel } from '../models/conversations'; +import { waitForPendingQueries } from './Queueing'; // We listen to a lot of events on ipcRenderer, often on the same channel. This prevents // any warnings that might be sent to the console in that case. @@ -65,7 +54,6 @@ if (ipcRenderer && ipcRenderer.setMaxListeners) { const DATABASE_UPDATE_TIMEOUT = 2 * 60 * 1000; // two minutes -const SQL_CHANNEL_KEY = 'sql-channel'; const ERASE_SQL_KEY = 'erase-sql-key'; const ERASE_ATTACHMENTS_KEY = 'erase-attachments'; const ERASE_STICKERS_KEY = 'erase-stickers'; @@ -74,19 +62,6 @@ const ERASE_DRAFTS_KEY = 'erase-drafts'; const CLEANUP_ORPHANED_ATTACHMENTS_KEY = 'cleanup-orphaned-attachments'; const ENSURE_FILE_PERMISSIONS = 'ensure-file-permissions'; -type ClientJobUpdateType = { - resolve: Function; - reject: Function; - args?: Array; -}; - -const _jobs: { [id: string]: ClientJobType } = Object.create(null); -const _DEBUG = false; -let _jobCounter = 0; -let _shuttingDown = false; -let _shutdownCallback: Function | null = null; -let _shutdownPromise: Promise | null = null; - // Because we can't force this module to conform to an interface, we narrow our exports // to this one default export, which does conform to the interface. // Note: In Javascript, you need to access the .default property when requiring it @@ -233,25 +208,10 @@ const dataInterface: ClientInterface = { // Client-side only, and test-only _removeConversations, - _jobs, }; export default dataInterface; -const channelsAsUnknown = fromPairs( - compact( - map(dataInterface, (value: any) => { - if (isFunction(value)) { - return [value.name, makeChannel(value.name)]; - } - - return null; - }) - ) -) as any; - -const channels: ServerInterface = channelsAsUnknown; - function _cleanData( data: unknown ): ReturnType['cleaned'] { @@ -267,185 +227,14 @@ function _cleanData( } function _cleanMessageData(data: MessageType): MessageType { + // Ensure that all messages have the received_at set properly + if (!data.received_at) { + assert(false, 'received_at was not set on the message'); + data.received_at = window.Signal.Util.incrementMessageCounter(); + } return _cleanData(omit(data, ['dataMessage'])); } -async function _shutdown() { - const jobKeys = Object.keys(_jobs); - window.log.info( - `data.shutdown: shutdown requested. ${jobKeys.length} jobs outstanding` - ); - - if (_shutdownPromise) { - await _shutdownPromise; - - return; - } - - _shuttingDown = true; - - // No outstanding jobs, return immediately - if (jobKeys.length === 0 || _DEBUG) { - return; - } - - // Outstanding jobs; we need to wait until the last one is done - _shutdownPromise = new Promise((resolve, reject) => { - _shutdownCallback = (error: Error) => { - window.log.info('data.shutdown: process complete'); - if (error) { - reject(error); - - return; - } - - resolve(); - }; - }); - - await _shutdownPromise; -} - -function _makeJob(fnName: string) { - if (_shuttingDown && fnName !== 'close') { - throw new Error( - `Rejecting SQL channel job (${fnName}); application is shutting down` - ); - } - - _jobCounter += 1; - const id = _jobCounter; - - if (_DEBUG) { - window.log.info(`SQL channel job ${id} (${fnName}) started`); - } - _jobs[id] = { - fnName, - start: Date.now(), - }; - - return id; -} - -function _updateJob(id: number, data: ClientJobUpdateType) { - const { resolve, reject } = data; - const { fnName, start } = _jobs[id]; - - _jobs[id] = { - ..._jobs[id], - ...data, - resolve: (value: any) => { - _removeJob(id); - const end = Date.now(); - const delta = end - start; - if (delta > 10 || _DEBUG) { - window.log.info( - `SQL channel job ${id} (${fnName}) succeeded in ${end - start}ms` - ); - } - - return resolve(value); - }, - reject: (error: Error) => { - _removeJob(id); - const end = Date.now(); - window.log.info( - `SQL channel job ${id} (${fnName}) failed in ${end - start}ms` - ); - - if (error && error.message && error.message.includes('SQLITE_CORRUPT')) { - window.log.error( - 'Detected SQLITE_CORRUPT error; restarting the application immediately' - ); - window.restart(); - } - - return reject(error); - }, - }; -} - -function _removeJob(id: number) { - if (_DEBUG) { - _jobs[id].complete = true; - - return; - } - - delete _jobs[id]; - - if (_shutdownCallback) { - const keys = Object.keys(_jobs); - if (keys.length === 0) { - _shutdownCallback(); - } - } -} - -function _getJob(id: number) { - return _jobs[id]; -} - -if (ipcRenderer && ipcRenderer.on) { - ipcRenderer.on( - `${SQL_CHANNEL_KEY}-done`, - (_, jobId, errorForDisplay, result) => { - const job = _getJob(jobId); - if (!job) { - throw new Error( - `Received SQL channel reply to job ${jobId}, but did not have it in our registry!` - ); - } - - const { resolve, reject, fnName } = job; - - if (!resolve || !reject) { - throw new Error( - `SQL channel job ${jobId} (${fnName}): didn't have a resolve or reject` - ); - } - - if (errorForDisplay) { - return reject( - new Error( - `Error received from SQL channel job ${jobId} (${fnName}): ${errorForDisplay}` - ) - ); - } - - return resolve(result); - } - ); -} else { - window.log.warn('sql/Client: ipcRenderer.on is not available!'); -} - -function makeChannel(fnName: string) { - return async (...args: Array) => { - const jobId = _makeJob(fnName); - - return new Promise((resolve, reject) => { - try { - ipcRenderer.send(SQL_CHANNEL_KEY, jobId, fnName, ...args); - - _updateJob(jobId, { - resolve, - reject, - args: _DEBUG ? args : undefined, - }); - - setTimeout(() => { - reject(new Error(`SQL channel job ${jobId} (${fnName}) timed out`)); - }, DATABASE_UPDATE_TIMEOUT); - } catch (error) { - _removeJob(jobId); - - reject(error); - } - }); - }; -} - function keysToArrayBuffer(keys: Array, data: any) { const updated = cloneDeep(data); @@ -481,25 +270,23 @@ function keysFromArrayBuffer(keys: Array, data: any) { // Top-level calls async function shutdown() { - // Stop accepting new SQL jobs, flush outstanding queue - await _shutdown(); - + await waitForPendingQueries(); // Close database await close(); } // Note: will need to restart the app after calling this, to set up afresh async function close() { - await channels.close(); + await Server.close(); } // Note: will need to restart the app after calling this, to set up afresh async function removeDB() { - await channels.removeDB(); + await Server.removeDB(); } async function removeIndexedDBFiles() { - await channels.removeIndexedDBFiles(); + await Server.removeIndexedDBFiles(); } // Identity Keys @@ -510,14 +297,14 @@ async function createOrUpdateIdentityKey(data: IdentityKeyType) { ...data, id: window.ConversationController.getConversationId(data.id), }); - await channels.createOrUpdateIdentityKey(updated); + await Server.createOrUpdateIdentityKey(updated); } async function getIdentityKeyById(identifier: string) { const id = window.ConversationController.getConversationId(identifier); if (!id) { throw new Error('getIdentityKeyById: unable to find conversationId'); } - const data = await channels.getIdentityKeyById(id); + const data = await Server.getIdentityKeyById(id); return keysToArrayBuffer(IDENTITY_KEY_KEYS, data); } @@ -525,20 +312,20 @@ async function bulkAddIdentityKeys(array: Array) { const updated = map(array, data => keysFromArrayBuffer(IDENTITY_KEY_KEYS, data) ); - await channels.bulkAddIdentityKeys(updated); + await Server.bulkAddIdentityKeys(updated); } async function removeIdentityKeyById(identifier: string) { const id = window.ConversationController.getConversationId(identifier); if (!id) { throw new Error('removeIdentityKeyById: unable to find conversationId'); } - await channels.removeIdentityKeyById(id); + await Server.removeIdentityKeyById(id); } async function removeAllIdentityKeys() { - await channels.removeAllIdentityKeys(); + await Server.removeAllIdentityKeys(); } async function getAllIdentityKeys() { - const keys = await channels.getAllIdentityKeys(); + const keys = await Server.getAllIdentityKeys(); return keys.map(key => keysToArrayBuffer(IDENTITY_KEY_KEYS, key)); } @@ -547,25 +334,25 @@ async function getAllIdentityKeys() { async function createOrUpdatePreKey(data: PreKeyType) { const updated = keysFromArrayBuffer(PRE_KEY_KEYS, data); - await channels.createOrUpdatePreKey(updated); + await Server.createOrUpdatePreKey(updated); } async function getPreKeyById(id: number) { - const data = await channels.getPreKeyById(id); + const data = await Server.getPreKeyById(id); return keysToArrayBuffer(PRE_KEY_KEYS, data); } async function bulkAddPreKeys(array: Array) { const updated = map(array, data => keysFromArrayBuffer(PRE_KEY_KEYS, data)); - await channels.bulkAddPreKeys(updated); + await Server.bulkAddPreKeys(updated); } async function removePreKeyById(id: number) { - await channels.removePreKeyById(id); + await Server.removePreKeyById(id); } async function removeAllPreKeys() { - await channels.removeAllPreKeys(); + await Server.removeAllPreKeys(); } async function getAllPreKeys() { - const keys = await channels.getAllPreKeys(); + const keys = await Server.getAllPreKeys(); return keys.map(key => keysToArrayBuffer(PRE_KEY_KEYS, key)); } @@ -575,15 +362,15 @@ async function getAllPreKeys() { const PRE_KEY_KEYS = ['privateKey', 'publicKey']; async function createOrUpdateSignedPreKey(data: SignedPreKeyType) { const updated = keysFromArrayBuffer(PRE_KEY_KEYS, data); - await channels.createOrUpdateSignedPreKey(updated); + await Server.createOrUpdateSignedPreKey(updated); } async function getSignedPreKeyById(id: number) { - const data = await channels.getSignedPreKeyById(id); + const data = await Server.getSignedPreKeyById(id); return keysToArrayBuffer(PRE_KEY_KEYS, data); } async function getAllSignedPreKeys() { - const keys = await channels.getAllSignedPreKeys(); + const keys = await Server.getAllSignedPreKeys(); return keys.map((key: SignedPreKeyType) => keysToArrayBuffer(PRE_KEY_KEYS, key) @@ -591,13 +378,13 @@ async function getAllSignedPreKeys() { } async function bulkAddSignedPreKeys(array: Array) { const updated = map(array, data => keysFromArrayBuffer(PRE_KEY_KEYS, data)); - await channels.bulkAddSignedPreKeys(updated); + await Server.bulkAddSignedPreKeys(updated); } async function removeSignedPreKeyById(id: number) { - await channels.removeSignedPreKeyById(id); + await Server.removeSignedPreKeyById(id); } async function removeAllSignedPreKeys() { - await channels.removeAllSignedPreKeys(); + await Server.removeAllSignedPreKeys(); } // Items @@ -620,16 +407,16 @@ async function createOrUpdateItem(data: ItemType) { const keys = ITEM_KEYS[id]; const updated = Array.isArray(keys) ? keysFromArrayBuffer(keys, data) : data; - await channels.createOrUpdateItem(updated); + await Server.createOrUpdateItem(updated); } async function getItemById(id: string) { const keys = ITEM_KEYS[id]; - const data = await channels.getItemById(id); + const data = await Server.getItemById(id); return Array.isArray(keys) ? keysToArrayBuffer(keys, data) : data; } async function getAllItems() { - const items = await channels.getAllItems(); + const items = await Server.getAllItems(); return map(items, item => { const { id } = item; @@ -645,48 +432,48 @@ async function bulkAddItems(array: Array) { return keys && Array.isArray(keys) ? keysFromArrayBuffer(keys, data) : data; }); - await channels.bulkAddItems(updated); + await Server.bulkAddItems(updated); } async function removeItemById(id: string) { - await channels.removeItemById(id); + await Server.removeItemById(id); } async function removeAllItems() { - await channels.removeAllItems(); + await Server.removeAllItems(); } // Sessions async function createOrUpdateSession(data: SessionType) { - await channels.createOrUpdateSession(data); + await Server.createOrUpdateSession(data); } async function createOrUpdateSessions(array: Array) { - await channels.createOrUpdateSessions(array); + await Server.createOrUpdateSessions(array); } async function getSessionById(id: string) { - const session = await channels.getSessionById(id); + const session = await Server.getSessionById(id); return session; } async function getSessionsById(id: string) { - const sessions = await channels.getSessionsById(id); + const sessions = await Server.getSessionsById(id); return sessions; } async function bulkAddSessions(array: Array) { - await channels.bulkAddSessions(array); + await Server.bulkAddSessions(array); } async function removeSessionById(id: string) { - await channels.removeSessionById(id); + await Server.removeSessionById(id); } async function removeSessionsByConversation(conversationId: string) { - await channels.removeSessionsByConversation(conversationId); + await Server.removeSessionsByConversation(conversationId); } async function removeAllSessions() { - await channels.removeAllSessions(); + await Server.removeAllSessions(); } async function getAllSessions() { - const sessions = await channels.getAllSessions(); + const sessions = await Server.getAllSessions(); return sessions; } @@ -694,22 +481,22 @@ async function getAllSessions() { // Conversation async function getConversationCount() { - return channels.getConversationCount(); + return Server.getConversationCount(); } async function saveConversation(data: ConversationType) { - await channels.saveConversation(data); + await Server.saveConversation(data); } async function saveConversations(array: Array) { - await channels.saveConversations(array); + await Server.saveConversations(array); } async function getConversationById( id: string, { Conversation }: { Conversation: typeof ConversationModel } ) { - const data = await channels.getConversationById(id); + const data = await Server.getConversationById(id); return new Conversation(data); } @@ -737,7 +524,7 @@ async function updateConversations(array: Array) { !pathsChanged.length, `Paths were cleaned: ${JSON.stringify(pathsChanged)}` ); - await channels.updateConversations(cleaned); + await Server.updateConversations(cleaned); } async function removeConversation( @@ -749,18 +536,18 @@ async function removeConversation( // Note: It's important to have a fully database-hydrated model to delete here because // it needs to delete all associated on-disk files along with the database delete. if (existing) { - await channels.removeConversation(id); + await Server.removeConversation(id); await existing.cleanup(); } } // Note: this method will not clean up external files, just delete from SQL async function _removeConversations(ids: Array) { - await channels.removeConversation(ids); + await Server.removeConversation(ids); } async function eraseStorageServiceStateFromConversations() { - await channels.eraseStorageServiceStateFromConversations(); + await Server.eraseStorageServiceStateFromConversations(); } async function getAllConversations({ @@ -768,7 +555,7 @@ async function getAllConversations({ }: { ConversationCollection: typeof ConversationModelCollectionType; }): Promise { - const conversations = await channels.getAllConversations(); + const conversations = await Server.getAllConversations(); const collection = new ConversationCollection(); collection.add(conversations); @@ -777,7 +564,7 @@ async function getAllConversations({ } async function getAllConversationIds() { - const ids = await channels.getAllConversationIds(); + const ids = await Server.getAllConversationIds(); return ids; } @@ -787,7 +574,7 @@ async function getAllPrivateConversations({ }: { ConversationCollection: typeof ConversationModelCollectionType; }) { - const conversations = await channels.getAllPrivateConversations(); + const conversations = await Server.getAllPrivateConversations(); const collection = new ConversationCollection(); collection.add(conversations); @@ -803,7 +590,7 @@ async function getAllGroupsInvolvingId( ConversationCollection: typeof ConversationModelCollectionType; } ) { - const conversations = await channels.getAllGroupsInvolvingId(id); + const conversations = await Server.getAllGroupsInvolvingId(id); const collection = new ConversationCollection(); collection.add(conversations); @@ -812,7 +599,7 @@ async function getAllGroupsInvolvingId( } async function searchConversations(query: string) { - const conversations = await channels.searchConversations(query); + const conversations = await Server.searchConversations(query); return conversations; } @@ -828,7 +615,7 @@ async function searchMessages( query: string, { limit }: { limit?: number } = {} ) { - const messages = await channels.searchMessages(query, { limit }); + const messages = await Server.searchMessages(query, { limit }); return handleSearchMessageJSON(messages); } @@ -838,7 +625,7 @@ async function searchMessagesInConversation( conversationId: string, { limit }: { limit?: number } = {} ) { - const messages = await channels.searchMessagesInConversation( + const messages = await Server.searchMessagesInConversation( query, conversationId, { limit } @@ -850,14 +637,14 @@ async function searchMessagesInConversation( // Message async function getMessageCount(conversationId?: string) { - return channels.getMessageCount(conversationId); + return Server.getMessageCount(conversationId); } async function saveMessage( data: MessageType, { forceSave, Message }: { forceSave?: boolean; Message: typeof MessageModel } ) { - const id = await channels.saveMessage(_cleanMessageData(data), { + const id = await Server.saveMessage(_cleanMessageData(data), { forceSave, }); Message.updateTimers(); @@ -869,7 +656,7 @@ async function saveMessages( arrayOfMessages: Array, { forceSave }: { forceSave?: boolean } = {} ) { - await channels.saveMessages( + await Server.saveMessages( arrayOfMessages.map(message => _cleanMessageData(message)), { forceSave } ); @@ -884,21 +671,21 @@ async function removeMessage( // Note: It's important to have a fully database-hydrated model to delete here because // it needs to delete all associated on-disk files along with the database delete. if (message) { - await channels.removeMessage(id); + await Server.removeMessage(id); await message.cleanup(); } } // Note: this method will not clean up external files, just delete from SQL async function removeMessages(ids: Array) { - await channels.removeMessages(ids); + await Server.removeMessages(ids); } async function getMessageById( id: string, { Message }: { Message: typeof MessageModel } ) { - const message = await channels.getMessageById(id); + const message = await Server.getMessageById(id); if (!message) { return null; } @@ -912,13 +699,13 @@ async function _getAllMessages({ }: { MessageCollection: typeof MessageModelCollectionType; }) { - const messages = await channels._getAllMessages(); + const messages = await Server._getAllMessages(); return new MessageCollection(messages); } async function getAllMessageIds() { - const ids = await channels.getAllMessageIds(); + const ids = await Server.getAllMessageIds(); return ids; } @@ -937,7 +724,7 @@ async function getMessageBySender( }, { Message }: { Message: typeof MessageModel } ) { - const messages = await channels.getMessageBySender({ + const messages = await Server.getMessageBySender({ source, sourceUuid, sourceDevice, @@ -956,7 +743,7 @@ async function getUnreadByConversation( MessageCollection, }: { MessageCollection: typeof MessageModelCollectionType } ) { - const messages = await channels.getUnreadByConversation(conversationId); + const messages = await Server.getUnreadByConversation(conversationId); return new MessageCollection(messages); } @@ -981,15 +768,12 @@ async function getOlderMessagesByConversation( MessageCollection: typeof MessageModelCollectionType; } ) { - const messages = await channels.getOlderMessagesByConversation( - conversationId, - { - limit, - receivedAt, - sentAt, - messageId, - } - ); + const messages = await Server.getOlderMessagesByConversation(conversationId, { + limit, + receivedAt, + sentAt, + messageId, + }); return new MessageCollection(handleMessageJSON(messages)); } @@ -1007,14 +791,11 @@ async function getNewerMessagesByConversation( MessageCollection: typeof MessageModelCollectionType; } ) { - const messages = await channels.getNewerMessagesByConversation( - conversationId, - { - limit, - receivedAt, - sentAt, - } - ); + const messages = await Server.getNewerMessagesByConversation(conversationId, { + limit, + receivedAt, + sentAt, + }); return new MessageCollection(handleMessageJSON(messages)); } @@ -1027,7 +808,7 @@ async function getLastConversationActivity({ ourConversationId: string; Message: typeof MessageModel; }): Promise { - const result = await channels.getLastConversationActivity({ + const result = await Server.getLastConversationActivity({ conversationId, ourConversationId, }); @@ -1045,7 +826,7 @@ async function getLastConversationPreview({ ourConversationId: string; Message: typeof MessageModel; }): Promise { - const result = await channels.getLastConversationPreview({ + const result = await Server.getLastConversationPreview({ conversationId, ourConversationId, }); @@ -1055,9 +836,7 @@ async function getLastConversationPreview({ return undefined; } async function getMessageMetricsForConversation(conversationId: string) { - const result = await channels.getMessageMetricsForConversation( - conversationId - ); + const result = await Server.getMessageMetricsForConversation(conversationId); return result; } @@ -1065,13 +844,13 @@ function hasGroupCallHistoryMessage( conversationId: string, eraId: string ): Promise { - return channels.hasGroupCallHistoryMessage(conversationId, eraId); + return Server.hasGroupCallHistoryMessage(conversationId, eraId); } async function migrateConversationMessages( obsoleteId: string, currentId: string ) { - await channels.migrateConversationMessages(obsoleteId, currentId); + await Server.migrateConversationMessages(obsoleteId, currentId); } async function removeAllMessagesInConversation( @@ -1113,7 +892,7 @@ async function removeAllMessagesInConversation( await queue.onIdle(); window.log.info(`removeAllMessagesInConversation/${logId}: Deleting...`); - await channels.removeMessages(ids); + await Server.removeMessages(ids); } while (messages.length > 0); } @@ -1123,7 +902,7 @@ async function getMessagesBySentAt( MessageCollection, }: { MessageCollection: typeof MessageModelCollectionType } ) { - const messages = await channels.getMessagesBySentAt(sentAt); + const messages = await Server.getMessagesBySentAt(sentAt); return new MessageCollection(messages); } @@ -1133,7 +912,7 @@ async function getExpiredMessages({ }: { MessageCollection: typeof MessageModelCollectionType; }) { - const messages = await channels.getExpiredMessages(); + const messages = await Server.getExpiredMessages(); return new MessageCollection(messages); } @@ -1143,7 +922,7 @@ async function getOutgoingWithoutExpiresAt({ }: { MessageCollection: typeof MessageModelCollectionType; }) { - const messages = await channels.getOutgoingWithoutExpiresAt(); + const messages = await Server.getOutgoingWithoutExpiresAt(); return new MessageCollection(messages); } @@ -1153,7 +932,7 @@ async function getNextExpiringMessage({ }: { Message: typeof MessageModel; }) { - const message = await channels.getNextExpiringMessage(); + const message = await Server.getNextExpiringMessage(); if (message) { return new Message(message); @@ -1167,7 +946,7 @@ async function getNextTapToViewMessageToAgeOut({ }: { Message: typeof MessageModel; }) { - const message = await channels.getNextTapToViewMessageToAgeOut(); + const message = await Server.getNextTapToViewMessageToAgeOut(); if (!message) { return null; } @@ -1179,7 +958,7 @@ async function getTapToViewMessagesNeedingErase({ }: { MessageCollection: typeof MessageModelCollectionType; }) { - const messages = await channels.getTapToViewMessagesNeedingErase(); + const messages = await Server.getTapToViewMessagesNeedingErase(); return new MessageCollection(messages); } @@ -1187,22 +966,22 @@ async function getTapToViewMessagesNeedingErase({ // Unprocessed async function getUnprocessedCount() { - return channels.getUnprocessedCount(); + return Server.getUnprocessedCount(); } async function getAllUnprocessed() { - return channels.getAllUnprocessed(); + return Server.getAllUnprocessed(); } async function getUnprocessedById(id: string) { - return channels.getUnprocessedById(id); + return Server.getUnprocessedById(id); } async function saveUnprocessed( data: UnprocessedType, { forceSave }: { forceSave?: boolean } = {} ) { - const id = await channels.saveUnprocessed(_cleanData(data), { forceSave }); + const id = await Server.saveUnprocessed(_cleanData(data), { forceSave }); return id; } @@ -1211,27 +990,27 @@ async function saveUnprocesseds( arrayOfUnprocessed: Array, { forceSave }: { forceSave?: boolean } = {} ) { - await channels.saveUnprocesseds(_cleanData(arrayOfUnprocessed), { + await Server.saveUnprocesseds(_cleanData(arrayOfUnprocessed), { forceSave, }); } async function updateUnprocessedAttempts(id: string, attempts: number) { - await channels.updateUnprocessedAttempts(id, attempts); + await Server.updateUnprocessedAttempts(id, attempts); } async function updateUnprocessedWithData(id: string, data: UnprocessedType) { - await channels.updateUnprocessedWithData(id, data); + await Server.updateUnprocessedWithData(id, data); } async function updateUnprocessedsWithData(array: Array) { - await channels.updateUnprocessedsWithData(array); + await Server.updateUnprocessedsWithData(array); } async function removeUnprocessed(id: string | Array) { - await channels.removeUnprocessed(id); + await Server.removeUnprocessed(id); } async function removeAllUnprocessed() { - await channels.removeAllUnprocessed(); + await Server.removeAllUnprocessed(); } // Attachment downloads @@ -1240,98 +1019,98 @@ async function getNextAttachmentDownloadJobs( limit?: number, options?: { timestamp?: number } ) { - return channels.getNextAttachmentDownloadJobs(limit, options); + return Server.getNextAttachmentDownloadJobs(limit, options); } async function saveAttachmentDownloadJob(job: AttachmentDownloadJobType) { - await channels.saveAttachmentDownloadJob(_cleanData(job)); + await Server.saveAttachmentDownloadJob(_cleanData(job)); } async function setAttachmentDownloadJobPending(id: string, pending: boolean) { - await channels.setAttachmentDownloadJobPending(id, pending); + await Server.setAttachmentDownloadJobPending(id, pending); } async function resetAttachmentDownloadPending() { - await channels.resetAttachmentDownloadPending(); + await Server.resetAttachmentDownloadPending(); } async function removeAttachmentDownloadJob(id: string) { - await channels.removeAttachmentDownloadJob(id); + await Server.removeAttachmentDownloadJob(id); } async function removeAllAttachmentDownloadJobs() { - await channels.removeAllAttachmentDownloadJobs(); + await Server.removeAllAttachmentDownloadJobs(); } // Stickers async function getStickerCount() { - return channels.getStickerCount(); + return Server.getStickerCount(); } async function createOrUpdateStickerPack(pack: StickerPackType) { - await channels.createOrUpdateStickerPack(pack); + await Server.createOrUpdateStickerPack(pack); } async function updateStickerPackStatus( packId: string, status: StickerPackStatusType, options?: { timestamp: number } ) { - await channels.updateStickerPackStatus(packId, status, options); + await Server.updateStickerPackStatus(packId, status, options); } async function createOrUpdateSticker(sticker: StickerType) { - await channels.createOrUpdateSticker(sticker); + await Server.createOrUpdateSticker(sticker); } async function updateStickerLastUsed( packId: string, stickerId: number, timestamp: number ) { - await channels.updateStickerLastUsed(packId, stickerId, timestamp); + await Server.updateStickerLastUsed(packId, stickerId, timestamp); } async function addStickerPackReference(messageId: string, packId: string) { - await channels.addStickerPackReference(messageId, packId); + await Server.addStickerPackReference(messageId, packId); } async function deleteStickerPackReference(messageId: string, packId: string) { - const paths = await channels.deleteStickerPackReference(messageId, packId); + const paths = await Server.deleteStickerPackReference(messageId, packId); return paths; } async function deleteStickerPack(packId: string) { - const paths = await channels.deleteStickerPack(packId); + const paths = await Server.deleteStickerPack(packId); return paths; } async function getAllStickerPacks() { - const packs = await channels.getAllStickerPacks(); + const packs = await Server.getAllStickerPacks(); return packs; } async function getAllStickers() { - const stickers = await channels.getAllStickers(); + const stickers = await Server.getAllStickers(); return stickers; } async function getRecentStickers() { - const recentStickers = await channels.getRecentStickers(); + const recentStickers = await Server.getRecentStickers(); return recentStickers; } async function clearAllErrorStickerPackAttempts() { - await channels.clearAllErrorStickerPackAttempts(); + await Server.clearAllErrorStickerPackAttempts(); } // Emojis async function updateEmojiUsage(shortName: string) { - await channels.updateEmojiUsage(shortName); + await Server.updateEmojiUsage(shortName); } async function getRecentEmojis(limit = 32) { - return channels.getRecentEmojis(limit); + return Server.getRecentEmojis(limit); } // Other async function removeAll() { - await channels.removeAll(); + await Server.removeAll(); } async function removeAllConfiguration() { - await channels.removeAllConfiguration(); + await Server.removeAllConfiguration(); } async function cleanupOrphanedAttachments() { @@ -1376,7 +1155,7 @@ async function getMessagesNeedingUpgrade( limit: number, { maxVersion = CURRENT_SCHEMA_VERSION }: { maxVersion: number } ) { - const messages = await channels.getMessagesNeedingUpgrade(limit, { + const messages = await Server.getMessagesNeedingUpgrade(limit, { maxVersion, }); @@ -1387,7 +1166,7 @@ async function getMessagesWithVisualMediaAttachments( conversationId: string, { limit }: { limit: number } ) { - return channels.getMessagesWithVisualMediaAttachments(conversationId, { + return Server.getMessagesWithVisualMediaAttachments(conversationId, { limit, }); } @@ -1396,7 +1175,7 @@ async function getMessagesWithFileAttachments( conversationId: string, { limit }: { limit: number } ) { - return channels.getMessagesWithFileAttachments(conversationId, { + return Server.getMessagesWithFileAttachments(conversationId, { limit, }); } diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 848f37ea35..4454deddbf 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -259,7 +259,12 @@ export type ServerInterface = DataInterface & { configDir: string; key: string; messages: LocaleMessagesType; - }) => Promise; + }) => Promise; + + initializeRenderer: (options: { + configDir: string; + key: string; + }) => Promise; removeKnownAttachments: ( allAttachments: Array @@ -393,7 +398,6 @@ export type ClientInterface = DataInterface & { // Client-side only, and test-only _removeConversations: (ids: Array) => Promise; - _jobs: { [id: string]: ClientJobType }; }; export type ClientJobType = { diff --git a/ts/sql/Queueing.ts b/ts/sql/Queueing.ts new file mode 100644 index 0000000000..445a1a7d93 --- /dev/null +++ b/ts/sql/Queueing.ts @@ -0,0 +1,141 @@ +// Copyright 2018-2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import Queue from 'p-queue'; +import { ServerInterface } from './Interface'; + +let allQueriesDone: () => void | undefined; +let sqlQueries = 0; +let singleQueue: Queue | null = null; +let multipleQueue: Queue | null = null; + +// Note: we don't want queue timeouts, because delays here are due to in-progress sql +// operations. For example we might try to start a transaction when the prevous isn't +// done, causing that database operation to fail. +function makeNewSingleQueue(): Queue { + singleQueue = new Queue({ concurrency: 1 }); + return singleQueue; +} +function makeNewMultipleQueue(): Queue { + multipleQueue = new Queue({ concurrency: 10 }); + return multipleQueue; +} + +const DEBUG = false; + +function makeSQLJob( + fn: ServerInterface[keyof ServerInterface], + args: Array, + callName: keyof ServerInterface +) { + if (DEBUG) { + // eslint-disable-next-line no-console + console.log(`SQL(${callName}) queued`); + } + return async () => { + sqlQueries += 1; + const start = Date.now(); + if (DEBUG) { + // eslint-disable-next-line no-console + console.log(`SQL(${callName}) started`); + } + let result; + try { + // Ignoring this error TS2556: Expected 3 arguments, but got 0 or more. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + result = await fn(...args); + } finally { + sqlQueries -= 1; + if (allQueriesDone && sqlQueries <= 0) { + allQueriesDone(); + } + } + const end = Date.now(); + const delta = end - start; + if (DEBUG || delta > 10) { + // eslint-disable-next-line no-console + console.log(`SQL(${callName}) succeeded in ${end - start}ms`); + } + return result; + }; +} + +async function handleCall( + fn: ServerInterface[keyof ServerInterface], + args: Array, + callName: keyof ServerInterface +) { + if (!fn) { + throw new Error(`sql channel: ${callName} is not an available function`); + } + + let result; + + // We queue here to keep multi-query operations atomic. Without it, any multistage + // data operation (even within a BEGIN/COMMIT) can become interleaved, since all + // requests share one database connection. + + // A needsSerial method must be run in our single concurrency queue. + if (fn.needsSerial) { + if (singleQueue) { + result = await singleQueue.add(makeSQLJob(fn, args, callName)); + } else if (multipleQueue) { + const queue = makeNewSingleQueue(); + + const multipleQueueLocal = multipleQueue; + queue.add(() => multipleQueueLocal.onIdle()); + multipleQueue = null; + + result = await queue.add(makeSQLJob(fn, args, callName)); + } else { + const queue = makeNewSingleQueue(); + result = await queue.add(makeSQLJob(fn, args, callName)); + } + } else { + // The request can be parallelized. To keep the same structure as the above block + // we force this section into the 'lonely if' pattern. + // eslint-disable-next-line no-lonely-if + if (multipleQueue) { + result = await multipleQueue.add(makeSQLJob(fn, args, callName)); + } else if (singleQueue) { + const queue = makeNewMultipleQueue(); + queue.pause(); + + const singleQueueRef = singleQueue; + + singleQueue = null; + const promise = queue.add(makeSQLJob(fn, args, callName)); + if (singleQueueRef) { + await singleQueueRef.onIdle(); + } + + queue.start(); + result = await promise; + } else { + const queue = makeNewMultipleQueue(); + result = await queue.add(makeSQLJob(fn, args, callName)); + } + } + + return result; +} + +export async function waitForPendingQueries(): Promise { + return new Promise(resolve => { + if (sqlQueries === 0) { + resolve(); + } else { + allQueriesDone = () => resolve(); + } + }); +} + +export function applyQueueing(dataInterface: ServerInterface): ServerInterface { + return Object.keys(dataInterface).reduce((acc, callName) => { + const serverInterfaceKey = callName as keyof ServerInterface; + acc[serverInterfaceKey] = async (...args: Array) => + handleCall(dataInterface[serverInterfaceKey], args, serverInterfaceKey); + return acc; + }, {} as ServerInterface); +} diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 97b8bacd69..6b5442a6ae 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -14,7 +14,6 @@ import mkdirp from 'mkdirp'; import rimraf from 'rimraf'; import PQueue from 'p-queue'; import sql from '@journeyapps/sqlcipher'; -import { app, clipboard, dialog } from 'electron'; import pify from 'pify'; import { v4 as generateUUID } from 'uuid'; @@ -31,8 +30,6 @@ import { pick, } from 'lodash'; -import { redactAll } from '../../js/modules/privacy'; -import { remove as removeUserConfig } from '../../app/user_config'; import { combineNames } from '../util/combineNames'; import { GroupV2MemberType } from '../model-types.d'; @@ -54,6 +51,7 @@ import { StickerType, UnprocessedType, } from './Interface'; +import { applyQueueing } from './Queueing'; declare global { // We want to extend `Function`'s properties, so we need to use an interface. @@ -195,13 +193,14 @@ const dataInterface: ServerInterface = { // Server-only initialize, + initializeRenderer, removeKnownAttachments, removeKnownStickers, removeKnownDraftAttachments, }; -export default dataInterface; +export default applyQueueing(dataInterface); function objectToJSON(data: any) { return JSON.stringify(data); @@ -210,6 +209,14 @@ function jsonToObject(json: string): any { return JSON.parse(json); } +function isRenderer() { + if (typeof process === 'undefined' || !process) { + return true; + } + + return process.type === 'renderer'; +} + async function openDatabase(filePath: string): Promise { return new Promise((resolve, reject) => { let instance: sql.Database | undefined; @@ -1702,6 +1709,7 @@ async function updateSchema(instance: PromisifiedSQLDatabase) { } let globalInstance: PromisifiedSQLDatabase | undefined; +let globalInstanceRenderer: PromisifiedSQLDatabase | undefined; let databaseFilePath: string | undefined; let indexedDBPath: string | undefined; @@ -1788,37 +1796,57 @@ async function initialize({ await getMessageCount(); } catch (error) { console.log('Database startup error:', error.stack); - const buttonIndex = dialog.showMessageBoxSync({ - buttons: [ - messages.copyErrorAndQuit.message, - messages.deleteAndRestart.message, - ], - defaultId: 0, - detail: redactAll(error.stack), - message: messages.databaseError.message, - noLink: true, - type: 'error', - }); - - if (buttonIndex === 0) { - clipboard.writeText( - `Database startup error:\n\n${redactAll(error.stack)}` - ); - } else { - if (promisified) { - await promisified.close(); - } - await removeDB(); - removeUserConfig(); - app.relaunch(); + if (promisified) { + await promisified.close(); } + throw error; + } +} - app.exit(1); - - return false; +async function initializeRenderer({ + configDir, + key, +}: { + configDir: string; + key: string; +}) { + if (!isRenderer()) { + throw new Error('Cannot call from main process.'); + } + if (globalInstanceRenderer) { + throw new Error('Cannot initialize more than once!'); + } + if (!isString(configDir)) { + throw new Error('initialize: configDir is required!'); + } + if (!isString(key)) { + throw new Error('initialize: key is required!'); } - return true; + if (!indexedDBPath) { + indexedDBPath = join(configDir, 'IndexedDB'); + } + + const dbDir = join(configDir, 'sql'); + + if (!databaseFilePath) { + databaseFilePath = join(dbDir, 'db.sqlite'); + } + + let promisified: PromisifiedSQLDatabase | undefined; + + try { + promisified = await openAndSetUpSQLCipher(databaseFilePath, { key }); + + // At this point we can allow general access to the database + globalInstanceRenderer = promisified; + + // test database + await getMessageCount(); + } catch (error) { + window.log.error('Database startup error:', error.stack); + throw error; + } } async function close() { @@ -1857,6 +1885,13 @@ async function removeIndexedDBFiles() { } function getInstance(): PromisifiedSQLDatabase { + if (isRenderer()) { + if (!globalInstanceRenderer) { + throw new Error('getInstance: globalInstanceRenderer not set!'); + } + return globalInstanceRenderer; + } + if (!globalInstance) { throw new Error('getInstance: globalInstance not set!'); } @@ -2285,6 +2320,7 @@ async function updateConversation(data: ConversationType) { } ); } + async function updateConversations(array: Array) { const db = getInstance(); await db.run('BEGIN TRANSACTION;'); @@ -2544,7 +2580,7 @@ async function saveMessage( `UPDATE messages SET id = $id, json = $json, - + body = $body, conversationId = $conversationId, expirationStartTimestamp = $expirationStartTimestamp, @@ -2616,7 +2652,7 @@ async function saveMessage( `INSERT INTO messages ( id, json, - + body, conversationId, expirationStartTimestamp, @@ -2638,7 +2674,7 @@ async function saveMessage( ) values ( $id, $json, - + $body, $conversationId, $expirationStartTimestamp, @@ -2967,7 +3003,7 @@ async function getLastConversationActivity({ const row = await db.get( `SELECT * FROM messages WHERE conversationId = $conversationId AND - (type IS NULL + (type IS NULL OR type NOT IN ( 'profile-change', diff --git a/ts/sql/initialize.ts b/ts/sql/initialize.ts new file mode 100644 index 0000000000..e7b7493704 --- /dev/null +++ b/ts/sql/initialize.ts @@ -0,0 +1,16 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { ipcRenderer as ipc } from 'electron'; +import fs from 'fs-extra'; +import pify from 'pify'; +import sql from './Server'; + +const getRealPath = pify(fs.realpath); + +export async function initialize(): Promise { + const configDir = await getRealPath(ipc.sendSync('get-user-data-path')); + const key = ipc.sendSync('user-config-key'); + + await sql.initializeRenderer({ configDir, key }); +} diff --git a/ts/test-electron/models/messages_test.ts b/ts/test-electron/models/messages_test.ts index 9dafe98961..b2d232cba0 100644 --- a/ts/test-electron/models/messages_test.ts +++ b/ts/test-electron/models/messages_test.ts @@ -23,7 +23,10 @@ describe('Message', () => { function createMessage(attrs: { [key: string]: unknown }) { const messages = new window.Whisper.MessageCollection(); - return messages.add(attrs); + return messages.add({ + received_at: Date.now(), + ...attrs, + }); } before(async () => { diff --git a/ts/test-node/components/media-gallery/groupMessagesByDate_test.ts b/ts/test-node/components/media-gallery/groupMessagesByDate_test.ts index 8974923d40..9b6af88f5c 100644 --- a/ts/test-node/components/media-gallery/groupMessagesByDate_test.ts +++ b/ts/test-node/components/media-gallery/groupMessagesByDate_test.ts @@ -17,6 +17,7 @@ const toMediaItem = (date: Date): MediaItemType => ({ message: { id: 'id', received_at: date.getTime(), + received_at_ms: date.getTime(), attachments: [], }, attachment: { @@ -57,6 +58,7 @@ describe('groupMediaItemsByDate', () => { message: { id: 'id', received_at: 1523534400000, + received_at_ms: 1523534400000, attachments: [], }, attachment: { @@ -71,6 +73,7 @@ describe('groupMediaItemsByDate', () => { message: { id: 'id', received_at: 1523491260000, + received_at_ms: 1523491260000, attachments: [], }, attachment: { @@ -90,6 +93,7 @@ describe('groupMediaItemsByDate', () => { message: { id: 'id', received_at: 1523491140000, + received_at_ms: 1523491140000, attachments: [], }, attachment: { @@ -109,6 +113,7 @@ describe('groupMediaItemsByDate', () => { message: { id: 'id', received_at: 1523232060000, + received_at_ms: 1523232060000, attachments: [], }, attachment: { @@ -128,6 +133,7 @@ describe('groupMediaItemsByDate', () => { message: { id: 'id', received_at: 1523231940000, + received_at_ms: 1523231940000, attachments: [], }, attachment: { @@ -142,6 +148,7 @@ describe('groupMediaItemsByDate', () => { message: { id: 'id', received_at: 1522540860000, + received_at_ms: 1522540860000, attachments: [], }, attachment: { @@ -163,6 +170,7 @@ describe('groupMediaItemsByDate', () => { message: { id: 'id', received_at: 1522540740000, + received_at_ms: 1522540740000, attachments: [], }, attachment: { @@ -177,6 +185,7 @@ describe('groupMediaItemsByDate', () => { message: { id: 'id', received_at: 1519912800000, + received_at_ms: 1519912800000, attachments: [], }, attachment: { @@ -198,6 +207,7 @@ describe('groupMediaItemsByDate', () => { message: { id: 'id', received_at: 1298937540000, + received_at_ms: 1298937540000, attachments: [], }, attachment: { @@ -212,6 +222,7 @@ describe('groupMediaItemsByDate', () => { message: { id: 'id', received_at: 1296554400000, + received_at_ms: 1296554400000, attachments: [], }, attachment: { diff --git a/ts/textsecure.d.ts b/ts/textsecure.d.ts index 33a6c607d5..ec9c3556bb 100644 --- a/ts/textsecure.d.ts +++ b/ts/textsecure.d.ts @@ -23,6 +23,7 @@ export type UnprocessedType = { decrypted?: string; envelope?: string; id: string; + timestamp: number; serverTimestamp?: number; source?: string; sourceDevice?: number; @@ -795,6 +796,8 @@ export declare class EnvelopeClass { // Note: these additional properties are added in the course of processing id: string; + receivedAtCounter: number; + receivedAtDate: number; unidentifiedDeliveryReceived?: boolean; messageAgeSec?: number; } diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index bf38c49424..899ea1be69 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -203,7 +203,7 @@ class MessageReceiverInner extends EventTarget { this.cacheAddBatcher = createBatcher({ wait: 200, maxSize: 30, - processBatch: this.cacheAndQueueBatch.bind(this), + processBatch: this.cacheAndHandleBatch.bind(this), }); this.cacheUpdateBatcher = createBatcher({ wait: 500, @@ -237,7 +237,7 @@ class MessageReceiverInner extends EventTarget { } // We always process our cache before processing a new websocket message - this.pendingQueue.add(async () => this.queueAllCached()); + this.pendingQueue.add(async () => this.handleAllCached()); this.count = 0; if (this.hasConnected) { @@ -428,13 +428,16 @@ class MessageReceiverInner extends EventTarget { ? envelope.serverTimestamp.toNumber() : null; + envelope.receivedAtCounter = window.Signal.Util.incrementMessageCounter(); + envelope.receivedAtDate = Date.now(); + // Calculate the message age (time on server). envelope.messageAgeSec = this.calculateMessageAge( headers, envelope.serverTimestamp ); - this.cacheAndQueue(envelope, plaintext, request); + this.cacheAndHandle(envelope, plaintext, request); } catch (e) { request.respond(500, 'Bad encrypted websocket message'); window.log.error( @@ -553,16 +556,17 @@ class MessageReceiverInner extends EventTarget { this.dispatchEvent(ev); } - async queueAllCached() { + async handleAllCached() { const items = await this.getAllFromCache(); const max = items.length; for (let i = 0; i < max; i += 1) { // eslint-disable-next-line no-await-in-loop - await this.queueCached(items[i]); + await this.handleCachedEnvelope(items[i]); } } - async queueCached(item: UnprocessedType) { + async handleCachedEnvelope(item: UnprocessedType) { + window.log.info('MessageReceiver.handleCachedEnvelope', item.id); try { let envelopePlaintext: ArrayBuffer; @@ -576,7 +580,7 @@ class MessageReceiverInner extends EventTarget { ); } else { throw new Error( - 'MessageReceiver.queueCached: item.envelope was malformed' + 'MessageReceiver.handleCachedEnvelope: item.envelope was malformed' ); } @@ -584,6 +588,8 @@ class MessageReceiverInner extends EventTarget { envelopePlaintext ); envelope.id = item.id; + envelope.receivedAtCounter = item.timestamp; + envelope.receivedAtDate = Date.now(); envelope.source = envelope.source || item.source; envelope.sourceUuid = envelope.sourceUuid || item.sourceUuid; envelope.sourceDevice = envelope.sourceDevice || item.sourceDevice; @@ -605,13 +611,13 @@ class MessageReceiverInner extends EventTarget { } else { throw new Error('Cached decrypted value was not a string!'); } - this.queueDecryptedEnvelope(envelope, payloadPlaintext); + this.handleDecryptedEnvelope(envelope, payloadPlaintext); } else { - this.queueEnvelope(envelope); + this.handleEnvelope(envelope); } } catch (error) { window.log.error( - 'queueCached error handling item', + 'handleCachedEnvelope error handling item', item.id, 'removing it. Error:', error && error.stack ? error.stack : error @@ -622,7 +628,7 @@ class MessageReceiverInner extends EventTarget { await window.textsecure.storage.unprocessed.remove(id); } catch (deleteError) { window.log.error( - 'queueCached error deleting item', + 'handleCachedEnvelope error deleting item', item.id, 'Error:', deleteError && deleteError.stack ? deleteError.stack : deleteError @@ -656,7 +662,7 @@ class MessageReceiverInner extends EventTarget { if (this.isEmptied) { this.clearRetryTimeout(); this.retryCachedTimeout = setTimeout(() => { - this.pendingQueue.add(async () => this.queueAllCached()); + this.pendingQueue.add(async () => this.handleAllCached()); }, RETRY_TIMEOUT); } } @@ -705,7 +711,8 @@ class MessageReceiverInner extends EventTarget { ); } - async cacheAndQueueBatch(items: Array) { + async cacheAndHandleBatch(items: Array) { + window.log.info('MessageReceiver.cacheAndHandleBatch', items.length); const dataArray = items.map(item => item.data); try { await window.textsecure.storage.unprocessed.batchAdd(dataArray); @@ -714,16 +721,16 @@ class MessageReceiverInner extends EventTarget { item.request.respond(200, 'OK'); } catch (error) { window.log.error( - 'cacheAndQueueBatch: Failed to send 200 to server; still queuing envelope' + 'cacheAndHandleBatch: Failed to send 200 to server; still queuing envelope' ); } - this.queueEnvelope(item.envelope); + this.handleEnvelope(item.envelope); }); this.maybeScheduleRetryTimeout(); } catch (error) { window.log.error( - 'cacheAndQueue error trying to add messages to cache:', + 'cacheAndHandleBatch error trying to add messages to cache:', error && error.stack ? error.stack : error ); @@ -733,7 +740,7 @@ class MessageReceiverInner extends EventTarget { } } - cacheAndQueue( + cacheAndHandle( envelope: EnvelopeClass, plaintext: ArrayBuffer, request: IncomingWebSocketRequest @@ -743,7 +750,7 @@ class MessageReceiverInner extends EventTarget { id, version: 2, envelope: MessageReceiverInner.arrayBufferToStringBase64(plaintext), - timestamp: Date.now(), + timestamp: envelope.receivedAtCounter, attempts: 1, }; this.cacheAddBatcher.add({ @@ -754,6 +761,7 @@ class MessageReceiverInner extends EventTarget { } async cacheUpdateBatch(items: Array>) { + window.log.info('MessageReceiver.cacheUpdateBatch', items.length); await window.textsecure.storage.unprocessed.addDecryptedDataToList(items); } @@ -778,43 +786,71 @@ class MessageReceiverInner extends EventTarget { this.cacheRemoveBatcher.add(id); } - async queueDecryptedEnvelope( + // Same as handleEnvelope, just without the decryption step. Necessary for handling + // messages which were successfully decrypted, but application logic didn't finish + // processing. + async handleDecryptedEnvelope( envelope: EnvelopeClass, plaintext: ArrayBuffer - ) { + ): Promise { const id = this.getEnvelopeId(envelope); - window.log.info('queueing decrypted envelope', id); + window.log.info('MessageReceiver.handleDecryptedEnvelope', id); - const task = this.handleDecryptedEnvelope.bind(this, envelope, plaintext); - const taskWithTimeout = window.textsecure.createTaskWithTimeout( - task, - `queueEncryptedEnvelope ${id}` - ); - const promise = this.addToQueue(taskWithTimeout); + try { + if (this.stoppingProcessing) { + return; + } + // No decryption is required for delivery receipts, so the decrypted field of + // the Unprocessed model will never be set - return promise.catch(error => { + if (envelope.content) { + await this.innerHandleContentMessage(envelope, plaintext); + + return; + } + if (envelope.legacyMessage) { + await this.innerHandleLegacyMessage(envelope, plaintext); + + return; + } + + this.removeFromCache(envelope); + throw new Error('Received message with no content and no legacyMessage'); + } catch (error) { window.log.error( - `queueDecryptedEnvelope error handling envelope ${id}:`, + `handleDecryptedEnvelope error handling envelope ${id}:`, error && error.extra ? JSON.stringify(error.extra) : '', error && error.stack ? error.stack : error ); - }); + } } - async queueEnvelope(envelope: EnvelopeClass) { + async handleEnvelope(envelope: EnvelopeClass) { const id = this.getEnvelopeId(envelope); - window.log.info('queueing envelope', id); + window.log.info('MessageReceiver.handleEnvelope', id); - const task = this.handleEnvelope.bind(this, envelope); - const taskWithTimeout = window.textsecure.createTaskWithTimeout( - task, - `queueEnvelope ${id}` - ); - const promise = this.addToQueue(taskWithTimeout); + try { + if (this.stoppingProcessing) { + return Promise.resolve(); + } - return promise.catch(error => { + if (envelope.type === window.textsecure.protobuf.Envelope.Type.RECEIPT) { + return this.onDeliveryReceipt(envelope); + } + + if (envelope.content) { + return this.handleContentMessage(envelope); + } + if (envelope.legacyMessage) { + return this.handleLegacyMessage(envelope); + } + + this.removeFromCache(envelope); + + throw new Error('Received message with no content and no legacyMessage'); + } catch (error) { const args = [ - 'queueEnvelope error handling envelope', + 'handleEnvelope error handling envelope', this.getEnvelopeId(envelope), ':', error && error.extra ? JSON.stringify(error.extra) : '', @@ -825,54 +861,9 @@ class MessageReceiverInner extends EventTarget { } else { window.log.error(...args); } - }); - } - - // Same as handleEnvelope, just without the decryption step. Necessary for handling - // messages which were successfully decrypted, but application logic didn't finish - // processing. - async handleDecryptedEnvelope( - envelope: EnvelopeClass, - plaintext: ArrayBuffer - ): Promise { - if (this.stoppingProcessing) { - return; - } - // No decryption is required for delivery receipts, so the decrypted field of - // the Unprocessed model will never be set - - if (envelope.content) { - await this.innerHandleContentMessage(envelope, plaintext); - - return; - } - if (envelope.legacyMessage) { - await this.innerHandleLegacyMessage(envelope, plaintext); - - return; } - this.removeFromCache(envelope); - throw new Error('Received message with no content and no legacyMessage'); - } - - async handleEnvelope(envelope: EnvelopeClass) { - if (this.stoppingProcessing) { - return Promise.resolve(); - } - - if (envelope.type === window.textsecure.protobuf.Envelope.Type.RECEIPT) { - return this.onDeliveryReceipt(envelope); - } - - if (envelope.content) { - return this.handleContentMessage(envelope); - } - if (envelope.legacyMessage) { - return this.handleLegacyMessage(envelope); - } - this.removeFromCache(envelope); - throw new Error('Received message with no content and no legacyMessage'); + return undefined; } getStatus() { @@ -1257,6 +1248,10 @@ class MessageReceiverInner extends EventTarget { envelope: EnvelopeClass, sentContainer: SyncMessageClass.Sent ) { + window.log.info( + 'MessageReceiver.handleSentMessage', + this.getEnvelopeId(envelope) + ); const { destination, destinationUuid, @@ -1324,6 +1319,8 @@ class MessageReceiverInner extends EventTarget { unidentifiedStatus, message, isRecipientUpdate, + receivedAtCounter: envelope.receivedAtCounter, + receivedAtDate: envelope.receivedAtDate, }; if (expirationStartTimestamp) { ev.data.expirationStartTimestamp = expirationStartTimestamp.toNumber(); @@ -1334,7 +1331,10 @@ class MessageReceiverInner extends EventTarget { } async handleDataMessage(envelope: EnvelopeClass, msg: DataMessageClass) { - window.log.info('data message from', this.getEnvelopeId(envelope)); + window.log.info( + 'MessageReceiver.handleDataMessage', + this.getEnvelopeId(envelope) + ); let p: Promise = Promise.resolve(); // eslint-disable-next-line no-bitwise const destination = envelope.sourceUuid || envelope.source; @@ -1412,6 +1412,8 @@ class MessageReceiverInner extends EventTarget { serverTimestamp: envelope.serverTimestamp, unidentifiedDeliveryReceived: envelope.unidentifiedDeliveryReceived, message, + receivedAtCounter: envelope.receivedAtCounter, + receivedAtDate: envelope.receivedAtDate, }; return this.dispatchAndWait(ev); }) @@ -1419,6 +1421,10 @@ class MessageReceiverInner extends EventTarget { } async handleLegacyMessage(envelope: EnvelopeClass) { + window.log.info( + 'MessageReceiver.handleLegacyMessage', + this.getEnvelopeId(envelope) + ); return this.decrypt(envelope, envelope.legacyMessage).then(plaintext => { if (!plaintext) { window.log.warn('handleLegacyMessage: plaintext was falsey'); @@ -1437,6 +1443,10 @@ class MessageReceiverInner extends EventTarget { } async handleContentMessage(envelope: EnvelopeClass) { + window.log.info( + 'MessageReceiver.handleContentMessage', + this.getEnvelopeId(envelope) + ); return this.decrypt(envelope, envelope.content).then(plaintext => { if (!plaintext) { window.log.warn('handleContentMessage: plaintext was falsey'); @@ -1579,7 +1589,10 @@ class MessageReceiverInner extends EventTarget { } handleNullMessage(envelope: EnvelopeClass) { - window.log.info('null message from', this.getEnvelopeId(envelope)); + window.log.info( + 'MessageReceiver.handleNullMessage', + this.getEnvelopeId(envelope) + ); this.removeFromCache(envelope); } @@ -1778,7 +1791,6 @@ class MessageReceiverInner extends EventTarget { return undefined; } if (syncMessage.read && syncMessage.read.length) { - window.log.info('read messages from', this.getEnvelopeId(envelope)); return this.handleRead(envelope, syncMessage.read); } if (syncMessage.verified) { @@ -1952,6 +1964,7 @@ class MessageReceiverInner extends EventTarget { envelope: EnvelopeClass, read: Array ) { + window.log.info('MessageReceiver.handleRead', this.getEnvelopeId(envelope)); const results = []; for (let i = 0; i < read.length; i += 1) { const ev = new Event('readSync'); diff --git a/ts/util/getMessageTimestamp.ts b/ts/util/getMessageTimestamp.ts new file mode 100644 index 0000000000..ed57415faf --- /dev/null +++ b/ts/util/getMessageTimestamp.ts @@ -0,0 +1,8 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { Message } from '../components/conversation/media-gallery/types/Message'; + +export function getMessageTimestamp(message: Message): number { + return message.received_at_ms || message.received_at; +} diff --git a/ts/util/incrementMessageCounter.ts b/ts/util/incrementMessageCounter.ts new file mode 100644 index 0000000000..ab910d45fb --- /dev/null +++ b/ts/util/incrementMessageCounter.ts @@ -0,0 +1,23 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { debounce } from 'lodash'; + +export function incrementMessageCounter(): number { + if (!window.receivedAtCounter) { + window.receivedAtCounter = + Number(localStorage.getItem('lastReceivedAtCounter')) || Date.now(); + } + + window.receivedAtCounter += 1; + debouncedUpdateLastReceivedAt(); + + return window.receivedAtCounter; +} + +const debouncedUpdateLastReceivedAt = debounce(() => { + localStorage.setItem( + 'lastReceivedAtCounter', + String(window.receivedAtCounter) + ); +}, 500); diff --git a/ts/util/index.ts b/ts/util/index.ts index c550e7e21b..fb75f19923 100644 --- a/ts/util/index.ts +++ b/ts/util/index.ts @@ -14,8 +14,10 @@ import { getStringForProfileChange } from './getStringForProfileChange'; import { getTextWithMentions } from './getTextWithMentions'; import { getUserAgent } from './getUserAgent'; import { hasExpired } from './hasExpired'; +import { incrementMessageCounter } from './incrementMessageCounter'; import { isFileDangerous } from './isFileDangerous'; import { makeLookup } from './makeLookup'; +import { saveNewMessageBatcher, updateMessageBatcher } from './messageBatcher'; import { missingCaseError } from './missingCaseError'; import { parseRemoteClientExpiration } from './parseRemoteClientExpiration'; import { sleep } from './sleep'; @@ -29,6 +31,8 @@ import { import * as zkgroup from './zkgroup'; export { + GoogleChrome, + Registration, arrayBufferToObjectURL, combineNames, createBatcher, @@ -40,18 +44,19 @@ export { getStringForProfileChange, getTextWithMentions, getUserAgent, - GoogleChrome, hasExpired, + incrementMessageCounter, isFileDangerous, longRunningTaskWrapper, makeLookup, mapToSupportLocale, missingCaseError, parseRemoteClientExpiration, - Registration, + saveNewMessageBatcher, sessionRecordToProtobuf, sessionStructureToArrayBuffer, sleep, toWebSafeBase64, + updateMessageBatcher, zkgroup, }; diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 64ae6ce97a..9ca326dd0b 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -15022,7 +15022,7 @@ "rule": "React-createRef", "path": "ts/components/conversation/media-gallery/MediaGallery.js", "line": " this.focusRef = react_1.default.createRef();", - "lineNumber": 31, + "lineNumber": 32, "reasonCategory": "usageTrusted", "updated": "2019-11-01T22:46:33.013Z", "reasonDetail": "Used for setting focus only" @@ -15031,7 +15031,7 @@ "rule": "React-createRef", "path": "ts/components/conversation/media-gallery/MediaGallery.tsx", "line": " public readonly focusRef: React.RefObject = React.createRef();", - "lineNumber": 71, + "lineNumber": 72, "reasonCategory": "usageTrusted", "updated": "2019-11-01T22:46:33.013Z", "reasonDetail": "Used for setting focus only" diff --git a/ts/util/messageBatcher.ts b/ts/util/messageBatcher.ts new file mode 100644 index 0000000000..e59d918204 --- /dev/null +++ b/ts/util/messageBatcher.ts @@ -0,0 +1,24 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { MessageAttributesType } from '../model-types.d'; +import { createBatcher } from './batcher'; +import { createWaitBatcher } from './waitBatcher'; + +export const updateMessageBatcher = createBatcher({ + wait: 500, + maxSize: 50, + processBatch: async (messages: Array) => { + window.log.info('updateMessageBatcher', messages.length); + await window.Signal.Data.saveMessages(messages, {}); + }, +}); + +export const saveNewMessageBatcher = createWaitBatcher({ + wait: 500, + maxSize: 30, + processBatch: async (messages: Array) => { + window.log.info('saveNewMessageBatcher', messages.length); + await window.Signal.Data.saveMessages(messages, { forceSave: true }); + }, +}); diff --git a/ts/window.d.ts b/ts/window.d.ts index d2b953b39b..759a42db48 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -137,10 +137,12 @@ declare global { WhatIsThis: WhatIsThis; + attachmentDownloadQueue: Array; baseAttachmentsPath: string; baseStickersPath: string; baseTempPath: string; dcodeIO: DCodeIOType; + receivedAtCounter: number; enterKeyboardMode: () => void; enterMouseMode: () => void; getAccountManager: () => AccountManager | undefined; @@ -246,6 +248,9 @@ declare global { titleBarDoubleClick: () => void; unregisterForActive: (handler: () => void) => void; updateTrayIcon: (count: number) => void; + sqlInitializer: { + initialize: () => Promise; + }; Backbone: typeof Backbone; Signal: { @@ -561,6 +566,8 @@ export type DCodeIOType = { }; type MessageControllerType = { + findBySender: (sender: string) => MessageModel | null; + findBySentAt: (sentAt: number) => MessageModel | null; register: (id: string, model: MessageModel) => MessageModel; unregister: (id: string) => void; }; From 230604b451654b2bc30e5ed166a0bc5952e674e1 Mon Sep 17 00:00:00 2001 From: Josh Perez <60019601+josh-signal@users.noreply.github.com> Date: Thu, 4 Mar 2021 18:08:44 -0500 Subject: [PATCH 038/181] Fix for storage service profileSharing setting --- ts/models/conversations.ts | 8 ++++---- ts/services/storageRecordOps.ts | 7 ++++++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 53764e9ebd..7a5f90e502 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -846,7 +846,7 @@ export class ConversationModel extends window.Backbone.Model< const after = this.get('profileSharing'); if (!viaStorageServiceSync && Boolean(before) !== Boolean(after)) { - this.captureChange('profileSharing'); + this.captureChange('enableProfileSharing'); } } @@ -858,7 +858,7 @@ export class ConversationModel extends window.Backbone.Model< const after = this.get('profileSharing'); if (!viaStorageServiceSync && Boolean(before) !== Boolean(after)) { - this.captureChange('profileSharing'); + this.captureChange('disableProfileSharing'); } } @@ -4853,7 +4853,7 @@ export class ConversationModel extends window.Backbone.Model< // [X] whitelisted // [X] archived // [X] markedUnread - captureChange(property: string): void { + captureChange(logMessage: string): void { if (!window.Signal.RemoteConfig.isEnabled('desktop.storageWrite2')) { window.log.info( 'conversation.captureChange: Returning early; desktop.storageWrite2 is falsey' @@ -4864,7 +4864,7 @@ export class ConversationModel extends window.Backbone.Model< window.log.info( 'storageService[captureChange]', - property, + logMessage, this.idForLogging() ); this.set({ needsStorageServiceSync: true }); diff --git a/ts/services/storageRecordOps.ts b/ts/services/storageRecordOps.ts index 9ad1522fb8..44bf3895ca 100644 --- a/ts/services/storageRecordOps.ts +++ b/ts/services/storageRecordOps.ts @@ -282,7 +282,7 @@ function applyMessageRequestState( conversation.unblock({ viaStorageServiceSync: true }); } - if (!record.whitelisted) { + if (record.whitelisted === false) { conversation.disableProfileSharing({ viaStorageServiceSync: true }); } } @@ -392,6 +392,7 @@ export async function mergeGroupV1Record( if (!conversation) { throw new Error(`No conversation for group(${groupId})`); } + window.log.info('storageService.mergeGroupV1Record:', conversation.debugID()); if (!conversation.isGroupV1()) { throw new Error(`Record has group type mismatch ${conversation.debugID()}`); @@ -476,6 +477,8 @@ export async function mergeGroupV2Record( const masterKeyBuffer = groupV2Record.masterKey.toArrayBuffer(); const conversation = await getGroupV2Conversation(masterKeyBuffer); + window.log.info('storageService.mergeGroupV2Record:', conversation.debugID()); + conversation.set({ isArchived: Boolean(groupV2Record.archived), markedUnread: Boolean(groupV2Record.markedUnread), @@ -551,6 +554,8 @@ export async function mergeContactRecord( 'private' ); + window.log.info('storageService.mergeContactRecord:', conversation.debugID()); + if (contactRecord.profileKey) { await conversation.setProfileKey( arrayBufferToBase64(contactRecord.profileKey.toArrayBuffer()), From dab5386207a8b2ba36b29f8539eef58f52974d31 Mon Sep 17 00:00:00 2001 From: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com> Date: Thu, 4 Mar 2021 17:19:20 -0600 Subject: [PATCH 039/181] Update group limits error message --- ts/groups/limits.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ts/groups/limits.ts b/ts/groups/limits.ts index 9cce18fce0..18e7d6e3f6 100644 --- a/ts/groups/limits.ts +++ b/ts/groups/limits.ts @@ -10,7 +10,7 @@ function makeGetter(configKey: ConfigKeyType): (fallback?: number) => number { try { return parseIntOrThrow( getValue(configKey), - 'Failed to parse group size limit' + `Failed to parse ${configKey} as an integer` ); } catch (err) { if (isNumber(fallback)) { From 3cc6c5f5ad9c9237bd1d88aee53e3e3f0374192c Mon Sep 17 00:00:00 2001 From: Josh Perez <60019601+josh-signal@users.noreply.github.com> Date: Fri, 5 Mar 2021 12:57:09 -0500 Subject: [PATCH 040/181] Fix for missing replacement text in bodyRanges --- ts/background.ts | 13 +++- ts/models/messages.ts | 11 +++- ts/state/selectors/search.ts | 69 +++++++++++++-------- ts/test-both/state/selectors/search_test.ts | 3 - ts/util/messageBatcher.ts | 12 ++-- ts/window.d.ts | 2 +- 6 files changed, 72 insertions(+), 38 deletions(-) diff --git a/ts/background.ts b/ts/background.ts index 30e5580b0f..f470923719 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -2,10 +2,12 @@ // SPDX-License-Identifier: AGPL-3.0-only import { DataMessageClass } from './textsecure.d'; +import { MessageAttributesType } from './model-types.d'; import { WhatIsThis } from './window.d'; import { assert } from './util/assert'; export async function startApp(): Promise { + window.attachmentDownloadQueue = []; try { window.log.info('Initializing SQL in renderer'); await window.sqlInitializer.initialize(); @@ -2073,11 +2075,20 @@ export async function startApp(): Promise { attachmentsToDownload.length, attachmentDownloadQueue.length ); - await Promise.all( + window.attachmentDownloadQueue = undefined; + const messagesWithDownloads = await Promise.all( attachmentsToDownload.map(message => message.queueAttachmentDownloads() ) ); + const messagesToSave: Array = []; + messagesWithDownloads.forEach((shouldSave, messageKey) => { + if (shouldSave) { + const message = attachmentsToDownload[messageKey]; + messagesToSave.push(message.attributes); + } + }); + await window.Signal.Data.saveMessages(messagesToSave, {}); } }, 500); diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 4f90a329bd..0e54194a1a 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -3614,8 +3614,15 @@ export class MessageModel extends window.Backbone.Model { (this.getConversation()!.getAccepted() || message.isOutgoing()) && !shouldHoldOffDownload ) { - window.attachmentDownloadQueue = window.attachmentDownloadQueue || []; - window.attachmentDownloadQueue.unshift(message); + if (window.attachmentDownloadQueue) { + window.attachmentDownloadQueue.unshift(message); + window.log.info( + 'Adding to attachmentDownloadQueue', + message.get('sent_at') + ); + } else { + await message.queueAttachmentDownloads(); + } } // Does this message have any pending, previously-received associated reactions? diff --git a/ts/state/selectors/search.ts b/ts/state/selectors/search.ts index ba6cf03374..ee9f0ba357 100644 --- a/ts/state/selectors/search.ts +++ b/ts/state/selectors/search.ts @@ -28,6 +28,8 @@ import { getConversationSelector, } from './conversations'; +import { BodyRangeType } from '../../types/Util'; + export const getSearch = (state: StateType): SearchStateType => state.search; export const getQuery = createSelector( @@ -110,29 +112,6 @@ export const getSearchResults = createSelector( } ); -export function _messageSearchResultSelector( - message: MessageSearchResultType, - from: ConversationType, - to: ConversationType, - searchConversationId?: string, - selectedMessageId?: string -): MessageSearchResultPropsDataType { - return { - from, - to, - - id: message.id, - conversationId: message.conversationId, - sentAt: message.sent_at, - snippet: message.snippet, - bodyRanges: message.bodyRanges, - body: message.body, - - isSelected: Boolean(selectedMessageId && message.id === selectedMessageId), - isSearchingInConversation: Boolean(searchConversationId), - }; -} - // A little optimization to reset our selector cache whenever high-level application data // changes: regionCode and userNumber. type CachedMessageSearchResultSelectorType = ( @@ -142,18 +121,58 @@ type CachedMessageSearchResultSelectorType = ( searchConversationId?: string, selectedMessageId?: string ) => MessageSearchResultPropsDataType; + export const getCachedSelectorForMessageSearchResult = createSelector( getUserConversationId, - (): CachedMessageSearchResultSelectorType => { + getConversationSelector, + ( + _, + conversationSelector: GetConversationByIdType + ): CachedMessageSearchResultSelectorType => { // Note: memoizee will check all parameters provided, and only run our selector // if any of them have changed. - return memoizee(_messageSearchResultSelector, { max: 500 }); + return memoizee( + ( + message: MessageSearchResultType, + from: ConversationType, + to: ConversationType, + searchConversationId?: string, + selectedMessageId?: string + ) => { + const bodyRanges = message.bodyRanges || []; + return { + from, + to, + + id: message.id, + conversationId: message.conversationId, + sentAt: message.sent_at, + snippet: message.snippet, + bodyRanges: bodyRanges.map((bodyRange: BodyRangeType) => { + const conversation = conversationSelector(bodyRange.mentionUuid); + + return { + ...bodyRange, + replacementText: conversation.title, + }; + }), + body: message.body, + + isSelected: Boolean( + selectedMessageId && message.id === selectedMessageId + ), + isSearchingInConversation: Boolean(searchConversationId), + }; + }, + { max: 500 } + ); } ); type GetMessageSearchResultByIdType = ( id: string ) => MessageSearchResultPropsDataType | undefined; + export const getMessageSearchResultSelector = createSelector( getCachedSelectorForMessageSearchResult, getMessageSearchResultLookup, diff --git a/ts/test-both/state/selectors/search_test.ts b/ts/test-both/state/selectors/search_test.ts index ac3a7fe11b..2fcb62a0a0 100644 --- a/ts/test-both/state/selectors/search_test.ts +++ b/ts/test-both/state/selectors/search_test.ts @@ -204,9 +204,6 @@ describe('both/state/selectors/search', () => { ...state, conversations: { ...state.conversations, - conversationLookup: { - ...state.conversations.conversationLookup, - }, }, }; const secondSelector = getMessageSearchResultSelector(secondState); diff --git a/ts/util/messageBatcher.ts b/ts/util/messageBatcher.ts index e59d918204..ec8813dd22 100644 --- a/ts/util/messageBatcher.ts +++ b/ts/util/messageBatcher.ts @@ -8,17 +8,17 @@ import { createWaitBatcher } from './waitBatcher'; export const updateMessageBatcher = createBatcher({ wait: 500, maxSize: 50, - processBatch: async (messages: Array) => { - window.log.info('updateMessageBatcher', messages.length); - await window.Signal.Data.saveMessages(messages, {}); + processBatch: async (messageAttrs: Array) => { + window.log.info('updateMessageBatcher', messageAttrs.length); + await window.Signal.Data.saveMessages(messageAttrs, {}); }, }); export const saveNewMessageBatcher = createWaitBatcher({ wait: 500, maxSize: 30, - processBatch: async (messages: Array) => { - window.log.info('saveNewMessageBatcher', messages.length); - await window.Signal.Data.saveMessages(messages, { forceSave: true }); + processBatch: async (messageAttrs: Array) => { + window.log.info('saveNewMessageBatcher', messageAttrs.length); + await window.Signal.Data.saveMessages(messageAttrs, { forceSave: true }); }, }); diff --git a/ts/window.d.ts b/ts/window.d.ts index 759a42db48..05a5967d06 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -137,7 +137,7 @@ declare global { WhatIsThis: WhatIsThis; - attachmentDownloadQueue: Array; + attachmentDownloadQueue: Array | undefined; baseAttachmentsPath: string; baseStickersPath: string; baseTempPath: string; From c313514f340f1be58527ca645634c346559857bc Mon Sep 17 00:00:00 2001 From: Josh Perez <60019601+josh-signal@users.noreply.github.com> Date: Fri, 5 Mar 2021 14:26:10 -0500 Subject: [PATCH 041/181] Auto healing unreferenced quotes --- ts/models/messages.ts | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 0e54194a1a..ee3c30df04 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -1135,6 +1135,35 @@ export class MessageModel extends window.Backbone.Model { ); const authorColor = contact ? contact.getColor() : 'grey'; + let reallyNotFound = referencedMessageNotFound; + // Is the quote really without a reference? Check with our in memory store + // first to make sure it's not there. + if (referencedMessageNotFound) { + const messageId = this.get('sent_at'); + window.log.info( + `getPropsForQuote: Verifying that ${messageId} referencing ${sentAt} is really not found` + ); + const inMemoryMessage = window.MessageController.findBySentAt( + Number(sentAt) + ); + reallyNotFound = !inMemoryMessage; + + // We found the quote in memory so update the message in the database + // so we don't have to do this check again + if (!reallyNotFound) { + window.log.info( + `getPropsForQuote: Found ${sentAt}, scheduling an update to ${messageId}` + ); + this.set({ + quote: { + ...quote, + referencedMessageNotFound: false, + }, + }); + window.Signal.Util.updateMessageBatcher.add(this.attributes); + } + } + const authorPhoneNumber = format(author, { ourRegionCode: regionCode, }); @@ -1158,7 +1187,7 @@ export class MessageModel extends window.Backbone.Model { authorTitle, authorName, authorColor, - referencedMessageNotFound, + referencedMessageNotFound: reallyNotFound, onClick: () => this.trigger('scroll-to-message'), }; } From e3d13f24801642ed0a004a6cc5552ceea7adc2ab Mon Sep 17 00:00:00 2001 From: Josh Perez Date: Fri, 5 Mar 2021 17:27:07 -0500 Subject: [PATCH 042/181] Fix window refresh and reloads --- main.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/main.js b/main.js index d2d1e9d1a4..d461fc1c53 100644 --- a/main.js +++ b/main.js @@ -1486,8 +1486,7 @@ ipc.on('locale-data', event => { event.returnValue = locale.messages; }); -// Used once to initialize SQL in the renderer process -ipc.once('user-config-key', event => { +ipc.on('user-config-key', event => { // eslint-disable-next-line no-param-reassign event.returnValue = userConfig.get('key'); }); From 1ad01c7d367e838b654c40c69a356278466bbd47 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Mon, 8 Mar 2021 07:23:57 -0800 Subject: [PATCH 043/181] Fix 'you created group' message pinned to bottom --- ts/groups.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ts/groups.ts b/ts/groups.ts index bebee4ec32..f628262fa8 100644 --- a/ts/groups.ts +++ b/ts/groups.ts @@ -1384,7 +1384,8 @@ export async function createGroupV2({ type: 'group-v2-change', sourceUuid: conversation.ourUuid, conversationId: conversation.id, - received_at: timestamp, + received_at: window.Signal.Util.incrementMessageCounter(), + received_at_ms: timestamp, sent_at: timestamp, groupV2Change: { from: ourConversationId, From a2071d9fa61760c922c397baf36848fb0ebdf7b8 Mon Sep 17 00:00:00 2001 From: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com> Date: Mon, 8 Mar 2021 11:58:59 -0600 Subject: [PATCH 044/181] Update font values per Design's recommendations --- stylesheets/_mixins.scss | 40 +++++++++++++++++++-------------------- stylesheets/_modules.scss | 4 ++-- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/stylesheets/_mixins.scss b/stylesheets/_mixins.scss index 9aa5e1a8a7..bcca14f2a3 100644 --- a/stylesheets/_mixins.scss +++ b/stylesheets/_mixins.scss @@ -5,7 +5,7 @@ @mixin font-title-1 { font-family: $inter; - font-weight: bolder; + font-weight: 700; font-size: 28px; line-height: 34px; letter-spacing: -0.56px; @@ -13,7 +13,7 @@ @mixin font-title-2 { font-family: $inter; - font-weight: bolder; + font-weight: 700; font-size: 20px; line-height: 26px; letter-spacing: -0.34px; @@ -21,13 +21,13 @@ @mixin font-body-1 { font-family: $inter; - font-size: 15px; - line-height: 21px; - letter-spacing: -0.14px; + font-size: 14px; + line-height: 20px; + letter-spacing: -0.08px; } @mixin font-body-1-bold { @include font-body-1; - font-weight: bold; + font-weight: 600; } @mixin font-body-1-italic { @include font-body-1; @@ -35,19 +35,19 @@ } @mixin font-body-1-bold-italic { @include font-body-1; - font-weight: bold; + font-weight: 600; font-style: italic; } @mixin font-body-2 { font-family: $inter; - font-size: 13px; - line-height: 18px; - letter-spacing: -0.03px; + font-size: 12px; + line-height: 16px; + letter-spacing: 0; } @mixin font-body-2-bold { @include font-body-2; - font-weight: bold; + font-weight: 600; } @mixin font-body-2-italic { @include font-body-2; @@ -55,30 +55,30 @@ } @mixin font-body-2-bold-italic { @include font-body-2; - font-weight: bold; + font-weight: 600; font-style: italic; } @mixin font-subtitle { font-family: $inter; - font-size: 12px; - line-height: 16px; - letter-spacing: 0px; + font-size: 11px; + line-height: 14px; + letter-spacing: 0.06px; } @mixin font-caption { font-family: $inter; - font-size: 11px; - line-height: 16px; - letter-spacing: 0.06px; + font-size: 10px; + line-height: 12px; + letter-spacing: 0.1px; } @mixin font-caption-bold { @include font-caption; - font-weight: bold; + font-weight: 600; } @mixin font-caption-bold-italic { @include font-caption; - font-weight: bold; + font-weight: 600; font-style: italic; } diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index b3d14b8bd8..d355ec9ba4 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -9859,10 +9859,10 @@ button.module-image__border-overlay:focus { } .module-avatar-popup__profile__name { - @include font-body-2-bold; + @include font-body-1-bold; } .module-avatar-popup__profile__number { - @include font-caption; + @include font-subtitle; @include light-theme { color: $color-gray-60; From 934e0fa415b06611cbaa742e86921c560a58b7cd Mon Sep 17 00:00:00 2001 From: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com> Date: Mon, 8 Mar 2021 14:07:37 -0600 Subject: [PATCH 045/181] In , use new "scrollToBottom" helper --- ts/components/ContactPills.tsx | 7 ++-- ts/test-electron/scrollToBottom_test.ts | 47 +++++++++++++++++++++++++ ts/util/lint/exceptions.json | 6 ++-- ts/util/scrollToBottom.ts | 8 +++++ 4 files changed, 62 insertions(+), 6 deletions(-) create mode 100644 ts/test-electron/scrollToBottom_test.ts create mode 100644 ts/util/scrollToBottom.ts diff --git a/ts/components/ContactPills.tsx b/ts/components/ContactPills.tsx index eda64626eb..ed3789c78e 100644 --- a/ts/components/ContactPills.tsx +++ b/ts/components/ContactPills.tsx @@ -9,6 +9,8 @@ import React, { ReactNode, } from 'react'; +import { scrollToBottom } from '../util/scrollToBottom'; + type PropsType = { children?: ReactNode; }; @@ -24,10 +26,9 @@ export const ContactPills: FunctionComponent = ({ children }) => { useEffect(() => { const hasAddedNewChild = childCount > previousChildCount; const el = elRef.current; - if (!hasAddedNewChild || !el) { - return; + if (hasAddedNewChild && el) { + scrollToBottom(el); } - el.scrollTop = el.scrollHeight; }, [childCount, previousChildCount]); return ( diff --git a/ts/test-electron/scrollToBottom_test.ts b/ts/test-electron/scrollToBottom_test.ts new file mode 100644 index 0000000000..4b81d854da --- /dev/null +++ b/ts/test-electron/scrollToBottom_test.ts @@ -0,0 +1,47 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import { scrollToBottom } from '../util/scrollToBottom'; + +describe('scrollToBottom', () => { + let sandbox: HTMLDivElement; + + beforeEach(() => { + sandbox = document.createElement('div'); + document.body.appendChild(sandbox); + }); + + afterEach(() => { + sandbox.remove(); + }); + + it("sets the element's scrollTop to the element's scrollHeight", () => { + const el = document.createElement('div'); + el.innerText = 'a'.repeat(50000); + Object.assign(el.style, { + height: '50px', + overflow: 'scroll', + whiteSpace: 'wrap', + width: '100px', + wordBreak: 'break-word', + }); + sandbox.appendChild(el); + + assert.strictEqual( + el.scrollTop, + 0, + 'Test is not set up correctly. Element is already scrolled' + ); + assert.isAtLeast( + el.scrollHeight, + 50, + 'Test is not set up correctly. scrollHeight is too low' + ); + + scrollToBottom(el); + + assert.isAtLeast(el.scrollTop, el.scrollHeight - 50); + }); +}); diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 9ca326dd0b..dcabd4d5cb 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -14671,7 +14671,7 @@ "rule": "React-useRef", "path": "ts/components/ContactPills.js", "line": " const elRef = react_1.useRef(null);", - "lineNumber": 27, + "lineNumber": 28, "reasonCategory": "usageTrusted", "updated": "2021-03-01T18:34:36.638Z", "reasonDetail": "Used for scrolling. Doesn't otherwise manipulate the DOM" @@ -14680,7 +14680,7 @@ "rule": "React-useRef", "path": "ts/components/ContactPills.js", "line": " const previousChildCountRef = react_1.useRef(childCount);", - "lineNumber": 29, + "lineNumber": 30, "reasonCategory": "usageTrusted", "updated": "2021-03-01T18:34:36.638Z", "reasonDetail": "Doesn't reference the DOM. Refers to a number" @@ -15394,4 +15394,4 @@ "updated": "2021-01-08T15:46:32.143Z", "reasonDetail": "Doesn't manipulate the DOM. This is just a function." } -] +] \ No newline at end of file diff --git a/ts/util/scrollToBottom.ts b/ts/util/scrollToBottom.ts new file mode 100644 index 0000000000..0ac3b55f2c --- /dev/null +++ b/ts/util/scrollToBottom.ts @@ -0,0 +1,8 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export function scrollToBottom(el: HTMLElement): void { + // We want to mutate the parameter here. + // eslint-disable-next-line no-param-reassign + el.scrollTop = el.scrollHeight; +} From ecc04d36de824117402bedcbe016d9e38336d957 Mon Sep 17 00:00:00 2001 From: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com> Date: Mon, 8 Mar 2021 16:31:19 -0600 Subject: [PATCH 046/181] Disallow group names longer than 32 extended graphemes --- components/GroupTitleInput.scss | 23 ++++ stylesheets/_modules.scss | 21 ---- stylesheets/manifest.scss | 3 +- ts/Intl.d.ts | 27 +++++ ts/components/GroupTitleInput.stories.tsx | 32 ++++++ ts/components/GroupTitleInput.tsx | 100 ++++++++++++++++++ ts/components/Tooltip.tsx | 18 +--- .../LeftPaneSetGroupMetadataHelper.tsx | 11 +- ts/test-both/util/grapheme_test.ts | 49 +++++++++ ts/util/grapheme.ts | 13 +++ ts/util/lint/exceptions.json | 29 ++++- ts/util/multiRef.ts | 22 ++++ 12 files changed, 302 insertions(+), 46 deletions(-) create mode 100644 components/GroupTitleInput.scss create mode 100644 ts/Intl.d.ts create mode 100644 ts/components/GroupTitleInput.stories.tsx create mode 100644 ts/components/GroupTitleInput.tsx create mode 100644 ts/test-both/util/grapheme_test.ts create mode 100644 ts/util/grapheme.ts create mode 100644 ts/util/multiRef.ts diff --git a/components/GroupTitleInput.scss b/components/GroupTitleInput.scss new file mode 100644 index 0000000000..0f93e43123 --- /dev/null +++ b/components/GroupTitleInput.scss @@ -0,0 +1,23 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.module-GroupTitleInput { + margin: 16px; + @include font-body-1; + padding: 8px 12px; + border-radius: 6px; + border: 2px solid $color-gray-15; + background: $color-white; + color: $color-black; + + &:focus { + outline: none; + + @include light-theme { + border-color: $ultramarine-ui-light; + } + @include dark-theme { + border-color: $ultramarine-ui-dark; + } + } +} diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index d355ec9ba4..1c552f7098 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -7433,27 +7433,6 @@ button.module-image__border-overlay:focus { } } -.module-left-pane__compose-input { - margin: 16px; - @include font-body-1; - padding: 8px 12px; - border-radius: 6px; - border: 2px solid $color-gray-15; - background: $color-white; - color: $color-black; - - &:focus { - outline: none; - - @include light-theme { - border-color: $ultramarine-ui-light; - } - @include dark-theme { - border-color: $ultramarine-ui-dark; - } - } -} - .module-left-pane__list--measure { flex-grow: 1; flex-shrink: 1; diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 5d54d61ac2..6316ea70af 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -32,5 +32,6 @@ @import './components/Button.scss'; @import './components/ContactPill.scss'; @import './components/ContactPills.scss'; -@import './components/GroupDialog.scss'; @import './components/ConversationHeader.scss'; +@import './components/GroupDialog.scss'; +@import './components/GroupTitleInput.scss'; diff --git a/ts/Intl.d.ts b/ts/Intl.d.ts new file mode 100644 index 0000000000..0461dfc023 --- /dev/null +++ b/ts/Intl.d.ts @@ -0,0 +1,27 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +declare namespace Intl { + type SegmenterOptions = { + granularity?: 'grapheme' | 'word' | 'sentence'; + }; + + type SegmentData = { + index: number; + input: string; + segment: string; + }; + + interface Segments { + containing(index: number): SegmentData; + + [Symbol.iterator](): Iterator; + } + + // `Intl.Segmenter` is not yet in TypeScript's type definitions, so we add it. + class Segmenter { + constructor(locale?: string, options?: SegmenterOptions); + + segment(str: string): Segments; + } +} diff --git a/ts/components/GroupTitleInput.stories.tsx b/ts/components/GroupTitleInput.stories.tsx new file mode 100644 index 0000000000..e6e0a43e23 --- /dev/null +++ b/ts/components/GroupTitleInput.stories.tsx @@ -0,0 +1,32 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { useState } from 'react'; + +import { storiesOf } from '@storybook/react'; + +import { setup as setupI18n } from '../../js/modules/i18n'; +import enMessages from '../../_locales/en/messages.json'; + +import { GroupTitleInput } from './GroupTitleInput'; + +const i18n = setupI18n('en', enMessages); + +const story = storiesOf('Components/GroupTitleInput', module); + +const Wrapper = ({ disabled }: { disabled?: boolean }) => { + const [value, setValue] = useState(''); + + return ( + + ); +}; + +story.add('Default', () => ); + +story.add('Disabled', () => ); diff --git a/ts/components/GroupTitleInput.tsx b/ts/components/GroupTitleInput.tsx new file mode 100644 index 0000000000..d4df24fcc6 --- /dev/null +++ b/ts/components/GroupTitleInput.tsx @@ -0,0 +1,100 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { forwardRef, useRef, ClipboardEvent } from 'react'; + +import { LocalizerType } from '../types/Util'; +import { multiRef } from '../util/multiRef'; +import * as grapheme from '../util/grapheme'; + +const MAX_GRAPHEME_COUNT = 32; + +type PropsType = { + disabled?: boolean; + i18n: LocalizerType; + onChangeValue: (value: string) => void; + value: string; +}; + +/** + * Group titles must have fewer than MAX_GRAPHEME_COUNT glyphs. Ideally, we'd use the + * `maxLength` property on inputs, but that doesn't account for glyphs that are more than + * one UTF-16 code units. For example: `'💩💩'.length === 4`. + * + * This component effectively implements a "max grapheme length" on an input. + * + * At a high level, this component handles two methods of input: + * + * - `onChange`. *Before* the value is changed (in `onKeyDown`), we save the value and the + * cursor position. Then, in `onChange`, we see if the new value is too long. If it is, + * we revert the value and selection. Otherwise, we fire `onChangeValue`. + * + * - `onPaste`. If you're pasting something that will fit, we fall back to normal browser + * behavior, which calls `onChange`. If you're pasting something that won't fit, it's a + * noop. + */ +export const GroupTitleInput = forwardRef( + ({ i18n, disabled = false, onChangeValue, value }, ref) => { + const innerRef = useRef(null); + const valueOnKeydownRef = useRef(value); + const selectionStartOnKeydownRef = useRef(value.length); + + return ( + { + const inputEl = innerRef.current; + if (!inputEl) { + return; + } + + valueOnKeydownRef.current = inputEl.value; + selectionStartOnKeydownRef.current = inputEl.selectionStart || 0; + }} + onChange={() => { + const inputEl = innerRef.current; + if (!inputEl) { + return; + } + + const newValue = inputEl.value; + if (grapheme.count(newValue) <= MAX_GRAPHEME_COUNT) { + onChangeValue(newValue); + } else { + inputEl.value = valueOnKeydownRef.current; + inputEl.selectionStart = selectionStartOnKeydownRef.current; + inputEl.selectionEnd = selectionStartOnKeydownRef.current; + } + }} + onPaste={(event: ClipboardEvent) => { + const inputEl = innerRef.current; + if (!inputEl) { + return; + } + + const selectionStart = inputEl.selectionStart || 0; + const selectionEnd = + inputEl.selectionEnd || inputEl.selectionStart || 0; + const textBeforeSelection = value.slice(0, selectionStart); + const textAfterSelection = value.slice(selectionEnd); + + const pastedText = event.clipboardData.getData('Text'); + + const newGraphemeCount = + grapheme.count(textBeforeSelection) + + grapheme.count(pastedText) + + grapheme.count(textAfterSelection); + + if (newGraphemeCount > MAX_GRAPHEME_COUNT) { + event.preventDefault(); + } + }} + placeholder={i18n('setGroupMetadata__group-name-placeholder')} + ref={multiRef(ref, innerRef)} + type="text" + value={value} + /> + ); + } +); diff --git a/ts/components/Tooltip.tsx b/ts/components/Tooltip.tsx index f87e6566b2..d3e67b112c 100644 --- a/ts/components/Tooltip.tsx +++ b/ts/components/Tooltip.tsx @@ -6,6 +6,7 @@ import classNames from 'classnames'; import { noop } from 'lodash'; import { Manager, Reference, Popper } from 'react-popper'; import { Theme, themeClassName } from '../util/theme'; +import { multiRef } from '../util/multiRef'; type EventWrapperPropsType = { children: React.ReactNode; @@ -50,22 +51,7 @@ const TooltipEventWrapper = React.forwardRef< { - wrapperRef.current = el; - - // This is a simplified version of [what React does][0] to set a ref. - // [0]: https://github.com/facebook/react/blob/29b7b775f2ecf878eaf605be959d959030598b07/packages/react-reconciler/src/ReactFiberCommitWork.js#L661-L677 - if (typeof ref === 'function') { - ref(el); - } else if (ref) { - // I believe the types for `ref` are wrong in this case, as `ref.current` should - // not be `readonly`. That's why we do this cast. See [the React source][1]. - // [1]: https://github.com/facebook/react/blob/29b7b775f2ecf878eaf605be959d959030598b07/packages/shared/ReactTypes.js#L78-L80 - // eslint-disable-next-line no-param-reassign - (ref as React.MutableRefObject).current = el; - } - }} + ref={multiRef(ref, wrapperRef)} > {children} diff --git a/ts/components/leftPane/LeftPaneSetGroupMetadataHelper.tsx b/ts/components/leftPane/LeftPaneSetGroupMetadataHelper.tsx index 9b3740e508..5ec513ebdf 100644 --- a/ts/components/leftPane/LeftPaneSetGroupMetadataHelper.tsx +++ b/ts/components/leftPane/LeftPaneSetGroupMetadataHelper.tsx @@ -11,6 +11,7 @@ import { AvatarInput } from '../AvatarInput'; import { Alert } from '../Alert'; import { Spinner } from '../Spinner'; import { Button } from '../Button'; +import { GroupTitleInput } from '../GroupTitleInput'; export type LeftPaneSetGroupMetadataPropsType = { groupAvatar: undefined | ArrayBuffer; @@ -113,15 +114,11 @@ export class LeftPaneSetGroupMetadataHelper extends LeftPaneHelper< onChange={setComposeGroupAvatar} value={this.groupAvatar} /> - { - setComposeGroupName(event.target.value); - }} - placeholder={i18n('setGroupMetadata__group-name-placeholder')} + i18n={i18n} + onChangeValue={setComposeGroupName} ref={focusRef} - type="text" value={this.groupName} /> diff --git a/ts/test-both/util/grapheme_test.ts b/ts/test-both/util/grapheme_test.ts new file mode 100644 index 0000000000..3f84f760cc --- /dev/null +++ b/ts/test-both/util/grapheme_test.ts @@ -0,0 +1,49 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import { count } from '../../util/grapheme'; + +describe('grapheme utilities', () => { + describe('count', () => { + it('returns the number of extended graphemes in a string (not necessarily the length)', () => { + // These tests modified [from iOS][0]. + // [0]: https://github.com/signalapp/Signal-iOS/blob/800930110b0386a4c351716c001940a3e8fac942/Signal/test/util/DisplayableTextFilterTest.swift#L40-L71 + + // Plain text + assert.strictEqual(count(''), 0); + assert.strictEqual(count('boring text'), 11); + assert.strictEqual(count('Bokmål'), 6); + + // Emojis + assert.strictEqual(count('💩💩💩'), 3); + assert.strictEqual(count('👩‍❤️‍👩'), 1); + assert.strictEqual(count('🇹🇹🌼🇹🇹🌼🇹🇹'), 5); + assert.strictEqual(count('🇹🇹'), 1); + assert.strictEqual(count('🇹🇹 '), 2); + assert.strictEqual(count('👌🏽👌🏾👌🏿'), 3); + assert.strictEqual(count('😍'), 1); + assert.strictEqual(count('👩🏽'), 1); + assert.strictEqual(count('👾🙇💁🙅🙆🙋🙎🙍'), 8); + assert.strictEqual(count('🐵🙈🙉🙊'), 4); + assert.strictEqual(count('❤️💔💌💕💞💓💗💖💘💝💟💜💛💚💙'), 15); + assert.strictEqual(count('✋🏿💪🏿👐🏿🙌🏿👏🏿🙏🏿'), 6); + assert.strictEqual(count('🚾🆒🆓🆕🆖🆗🆙🏧'), 8); + assert.strictEqual(count('0️⃣1️⃣2️⃣3️⃣4️⃣5️⃣6️⃣7️⃣8️⃣9️⃣🔟'), 11); + assert.strictEqual(count('🇺🇸🇷🇺🇦🇫🇦🇲'), 4); + assert.strictEqual(count('🇺🇸🇷🇺🇸 🇦🇫🇦🇲🇸'), 7); + assert.strictEqual(count('🇺🇸🇷🇺🇸🇦🇫🇦🇲'), 5); + assert.strictEqual(count('🇺🇸🇷🇺🇸🇦'), 3); + assert.strictEqual(count('123'), 3); + + // Normal diacritic usage + assert.strictEqual(count('Příliš žluťoučký kůň úpěl ďábelské ódy.'), 39); + + // Excessive diacritics + assert.strictEqual(count('Z͑ͫ̓ͪ̂ͫ̽͏̴̙̤̞͉͚̯̞̠͍A̴̵̜̰͔ͫ͗͢L̠ͨͧͩ͘G̴̻͈͍͔̹̑͗̎̅͛́Ǫ̵̹̻̝̳͂̌̌͘'), 5); + assert.strictEqual(count('H҉̸̧͘͠A͢͞V̛̛I̴̸N͏̕͏G҉̵͜͏͢ ̧̧́T̶̛͘͡R̸̵̨̢̀O̷̡U͡҉B̶̛͢͞L̸̸͘͢͟É̸ ̸̛͘͏R͟È͠͞A̸͝Ḑ̕͘͜I̵͘҉͜͞N̷̡̢͠G̴͘͠ ͟͞T͏̢́͡È̀X̕҉̢̀T̢͠?̕͏̢͘͢'), 28); + assert.strictEqual(count('L̷̳͔̲͝Ģ̵̮̯̤̩̙͍̬̟͉̹̘̹͍͈̮̦̰̣͟͝O̶̴̮̻̮̗͘͡!̴̷̟͓͓'), 4); + }); + }); +}); diff --git a/ts/util/grapheme.ts b/ts/util/grapheme.ts new file mode 100644 index 0000000000..46aac86bf6 --- /dev/null +++ b/ts/util/grapheme.ts @@ -0,0 +1,13 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export function count(str: string): number { + const segments = new Intl.Segmenter().segment(str); + const iterator = segments[Symbol.iterator](); + + let result = -1; + for (let done = false; !done; result += 1) { + done = Boolean(iterator.next().done); + } + return result; +} diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index dcabd4d5cb..2f6a29f6c2 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -14730,6 +14730,33 @@ "updated": "2020-11-17T23:29:38.698Z", "reasonDetail": "Doesn't touch the DOM." }, + { + "rule": "React-useRef", + "path": "ts/components/GroupTitleInput.js", + "line": " const innerRef = react_1.useRef(null);", + "lineNumber": 47, + "reasonCategory": "usageTrusted", + "updated": "2021-03-05T16:51:54.214Z", + "reasonDetail": "Used to handle an element. Only updates the value and selection state." + }, + { + "rule": "React-useRef", + "path": "ts/components/GroupTitleInput.js", + "line": " const valueOnKeydownRef = react_1.useRef(value);", + "lineNumber": 48, + "reasonCategory": "usageTrusted", + "updated": "2021-03-05T16:51:54.214Z", + "reasonDetail": "Only stores a string." + }, + { + "rule": "React-useRef", + "path": "ts/components/GroupTitleInput.js", + "line": " const selectionStartOnKeydownRef = react_1.useRef(value.length);", + "lineNumber": 49, + "reasonCategory": "usageTrusted", + "updated": "2021-03-05T16:51:54.214Z", + "reasonDetail": "Only stores a number." + }, { "rule": "jQuery-$(", "path": "ts/components/Intl.js", @@ -14830,7 +14857,7 @@ "rule": "React-useRef", "path": "ts/components/Tooltip.js", "line": " const wrapperRef = react_1.default.useRef(null);", - "lineNumber": 19, + "lineNumber": 20, "reasonCategory": "usageTrusted", "updated": "2020-12-04T00:11:08.128Z", "reasonDetail": "Used to add (and remove) event listeners." diff --git a/ts/util/multiRef.ts b/ts/util/multiRef.ts new file mode 100644 index 0000000000..76768d021c --- /dev/null +++ b/ts/util/multiRef.ts @@ -0,0 +1,22 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { Ref } from 'react'; + +export function multiRef(...refs: Array>): (topLevelRef: T) => void { + return (el: T) => { + refs.forEach(ref => { + // This is a simplified version of [what React does][0] to set a ref. + // [0]: https://github.com/facebook/react/blob/29b7b775f2ecf878eaf605be959d959030598b07/packages/react-reconciler/src/ReactFiberCommitWork.js#L661-L677 + if (typeof ref === 'function') { + ref(el); + } else if (ref) { + // I believe the types for `ref` are wrong in this case, as `ref.current` should + // not be `readonly`. That's why we do this cast. See [the React source][1]. + // [1]: https://github.com/facebook/react/blob/29b7b775f2ecf878eaf605be959d959030598b07/packages/shared/ReactTypes.js#L78-L80 + // eslint-disable-next-line no-param-reassign + (ref as React.MutableRefObject).current = el; + } + }); + }; +} From 729d808f6240c3590ec0d1669598f3a48214f43e Mon Sep 17 00:00:00 2001 From: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com> Date: Mon, 8 Mar 2021 16:42:29 -0600 Subject: [PATCH 047/181] macOS: make conversation and main header draggable --- stylesheets/_global.scss | 2 + stylesheets/_modules.scss | 8 ++ .../components/ConversationHeader.scss | 4 +- ts/background.ts | 20 ++--- ts/components/MainHeader.tsx | 1 + .../util/isWindowDragElement_test.ts | 74 +++++++++++++++++++ ts/util/isWindowDragElement.ts | 21 ++++++ 7 files changed, 119 insertions(+), 11 deletions(-) create mode 100644 ts/test-electron/util/isWindowDragElement_test.ts create mode 100644 ts/util/isWindowDragElement.ts diff --git a/stylesheets/_global.scss b/stylesheets/_global.scss index f373f55a66..1513e15ae7 100644 --- a/stylesheets/_global.scss +++ b/stylesheets/_global.scss @@ -21,8 +21,10 @@ body { // It'd be great if we could use the `:fullscreen` selector here, but that does not seem // to work with Electron, at least on macOS. --title-bar-drag-area-height: 0px; // Needs to have a unit to work with `calc()`. + --draggable-app-region: initial; &.os-macos:not(.full-screen) { --title-bar-drag-area-height: 28px; + --draggable-app-region: drag; } } diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 1c552f7098..0d96dee1b2 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -4217,6 +4217,8 @@ button.module-conversation-details__action-button { // Module: Main Header .module-main-header { + -webkit-app-region: var(--draggable-app-region); + height: calc(#{$header-height} + var(--title-bar-drag-area-height)); width: 100%; @@ -4236,11 +4238,16 @@ button.module-conversation-details__action-button { } } + &__avatar { + -webkit-app-region: no-drag; + } + &__search { flex-grow: 1; position: relative; display: flex; flex-direction: row; + -webkit-app-region: no-drag; &__input { flex-grow: 1; @@ -4388,6 +4395,7 @@ button.module-conversation-details__action-button { width: 24px; height: 24px; + -webkit-app-region: no-drag; @include light-theme { @include color-svg($icon, $color-gray-90); diff --git a/stylesheets/components/ConversationHeader.scss b/stylesheets/components/ConversationHeader.scss index bdb17adfe1..83f3b238c0 100644 --- a/stylesheets/components/ConversationHeader.scss +++ b/stylesheets/components/ConversationHeader.scss @@ -8,11 +8,11 @@ --button-spacing: 16px; } + -webkit-app-region: var(--draggable-app-region); padding-top: var(--title-bar-drag-area-height); display: flex; flex-direction: row; align-items: center; - height: calc(#{$header-height} + var(--title-bar-drag-area-height)); @include light-theme { @@ -78,6 +78,7 @@ &--clickable { @include button-reset; border-radius: 4px; + -webkit-app-region: no-drag; // These are clobbered by button-reset: margin-left: 4px; @@ -171,6 +172,7 @@ &__button { $icon-size: 32px; + -webkit-app-region: no-drag; @include button-reset; align-items: stretch; border-radius: 4px; diff --git a/ts/background.ts b/ts/background.ts index f470923719..78842ca2d1 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -4,6 +4,8 @@ import { DataMessageClass } from './textsecure.d'; import { MessageAttributesType } from './model-types.d'; import { WhatIsThis } from './window.d'; +import { getTitleBarVisibility, TitleBarVisibility } from './types/Settings'; +import { isWindowDragElement } from './util/isWindowDragElement'; import { assert } from './util/assert'; export async function startApp(): Promise { @@ -78,16 +80,14 @@ export async function startApp(): Promise { }, }); - window.addEventListener('dblclick', (event: Event) => { - const target = event.target as HTMLElement; - const isDoubleClickOnTitleBar = Boolean( - target.classList.contains('module-title-bar-drag-area') || - target.closest('module-title-bar-drag-area') - ); - if (isDoubleClickOnTitleBar) { - window.titleBarDoubleClick(); - } - }); + if (getTitleBarVisibility() === TitleBarVisibility.Hidden) { + window.addEventListener('dblclick', (event: Event) => { + const target = event.target as HTMLElement; + if (isWindowDragElement(target)) { + window.titleBarDoubleClick(); + } + }); + } // Globally disable drag and drop document.body.addEventListener( diff --git a/ts/components/MainHeader.tsx b/ts/components/MainHeader.tsx index f88550dd06..b1a5a4f57c 100644 --- a/ts/components/MainHeader.tsx +++ b/ts/components/MainHeader.tsx @@ -367,6 +367,7 @@ export class MainHeader extends React.Component { {({ ref }) => ( { + const crel = (tagName: string, appRegion?: string): Element => { + const result = document.createElement(tagName); + if (appRegion) { + result.style.cssText = `-webkit-app-region: ${appRegion}`; + } + return result; + }; + + let sandboxEl: HTMLElement; + + beforeEach(() => { + sandboxEl = document.createElement('div'); + document.body.appendChild(sandboxEl); + }); + + afterEach(() => { + sandboxEl.remove(); + }); + + it('returns false for elements with no -webkit-app-region property in the heirarchy', () => { + const root = crel('div'); + const outer = crel('span'); + const inner = crel('div'); + root.appendChild(outer); + outer.appendChild(inner); + sandboxEl.appendChild(root); + + assert.isFalse(isWindowDragElement(root)); + assert.isFalse(isWindowDragElement(outer)); + assert.isFalse(isWindowDragElement(inner)); + }); + + it('returns false for elements with -webkit-app-region: drag on a sub-element', () => { + const parent = crel('div'); + const child = crel('div', 'drag'); + parent.appendChild(child); + sandboxEl.appendChild(parent); + + assert.isFalse(isWindowDragElement(parent)); + }); + + it('returns false if any element up the chain is found to be -webkit-app-region: no-drag', () => { + const root = crel('div', 'drag'); + const outer = crel('div', 'no-drag'); + const inner = crel('div'); + root.appendChild(outer); + outer.appendChild(inner); + sandboxEl.appendChild(root); + + assert.isFalse(isWindowDragElement(outer)); + assert.isFalse(isWindowDragElement(inner)); + }); + + it('returns true if any element up the chain is found to be -webkit-app-region: drag', () => { + const root = crel('div', 'drag'); + const outer = crel('div'); + const inner = crel('div'); + root.appendChild(outer); + outer.appendChild(inner); + sandboxEl.appendChild(root); + + assert.isTrue(isWindowDragElement(root)); + assert.isTrue(isWindowDragElement(outer)); + assert.isTrue(isWindowDragElement(inner)); + }); +}); diff --git a/ts/util/isWindowDragElement.ts b/ts/util/isWindowDragElement.ts new file mode 100644 index 0000000000..174a0a5e17 --- /dev/null +++ b/ts/util/isWindowDragElement.ts @@ -0,0 +1,21 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export function isWindowDragElement(el: Readonly): boolean { + let currentEl: Element | null = el; + do { + const appRegion = getComputedStyle(currentEl).getPropertyValue( + '-webkit-app-region' + ); + switch (appRegion) { + case 'no-drag': + return false; + case 'drag': + return true; + default: + currentEl = currentEl.parentElement; + break; + } + } while (currentEl); + return false; +} From cee8207e724bb97d6df6e4799bf8dcf155ad3008 Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Mon, 8 Mar 2021 14:44:50 -0800 Subject: [PATCH 048/181] Fix hidden Dev Tools menu item in development runs --- main.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.js b/main.js index d461fc1c53..f77ee550d8 100644 --- a/main.js +++ b/main.js @@ -1170,13 +1170,13 @@ function setupMenu(options) { ...options, development, isBeta: isBeta(app.getVersion()), + devTools: defaultWebPrefs.devTools, showDebugLog: showDebugLogWindow, showKeyboardShortcuts, showWindow, showAbout, showSettings: showSettingsWindow, showStickerCreator, - showDevTools: defaultWebPrefs.devTools, openContactUs, openJoinTheBeta, openReleaseNotes, From 468d491d34f40a6a45dda562057d73cab69311dd Mon Sep 17 00:00:00 2001 From: Josh Perez <60019601+josh-signal@users.noreply.github.com> Date: Tue, 9 Mar 2021 14:11:52 -0500 Subject: [PATCH 049/181] Optimizations to the performance improvement changes --- js/delivery_receipts.js | 4 +- js/read_receipts.js | 4 +- js/read_syncs.js | 4 +- main.js | 2 +- ts/background.ts | 4 ++ ts/models/messages.ts | 95 +++++++++++++++++++++++++++++++- ts/textsecure/MessageReceiver.ts | 7 +++ 7 files changed, 109 insertions(+), 11 deletions(-) diff --git a/js/delivery_receipts.js b/js/delivery_receipts.js index 44be4b5e56..ea1a2704e6 100644 --- a/js/delivery_receipts.js +++ b/js/delivery_receipts.js @@ -100,9 +100,7 @@ await message.setToExpire(false, { skipSave: true }); } - await window.Signal.Data.saveMessage(message.attributes, { - Message: Whisper.Message, - }); + window.Signal.Util.updateMessageBatcher.add(message.attributes); // notify frontend listeners const conversation = ConversationController.get( diff --git a/js/read_receipts.js b/js/read_receipts.js index 0f1b173283..30a7f28cee 100644 --- a/js/read_receipts.js +++ b/js/read_receipts.js @@ -101,9 +101,7 @@ await message.setToExpire(false, { skipSave: true }); } - await window.Signal.Data.saveMessage(message.attributes, { - Message: Whisper.Message, - }); + window.Signal.Util.updateMessageBatcher.add(message.attributes); // notify frontend listeners const conversation = ConversationController.get( diff --git a/js/read_syncs.js b/js/read_syncs.js index ca76472f5f..23296062d0 100644 --- a/js/read_syncs.js +++ b/js/read_syncs.js @@ -94,9 +94,7 @@ } } - await window.Signal.Data.saveMessage(message.attributes, { - Message: Whisper.Message, - }); + window.Signal.Util.updateMessageBatcher.add(message.attributes); this.remove(receipt); } catch (error) { diff --git a/main.js b/main.js index f77ee550d8..d3554ec267 100644 --- a/main.js +++ b/main.js @@ -956,7 +956,7 @@ app.on('ready', async () => { // We use this event only a single time to log the startup time of the app // from when it's first ready until the loading screen disappears. ipc.once('signal-app-loaded', () => { - console.log('App has finished loading in:', Date.now() - startTime); + console.log('App loaded - time:', Date.now() - startTime); }); const userDataPath = await getRealPath(app.getPath('userData')); diff --git a/ts/background.ts b/ts/background.ts index 78842ca2d1..d01ff44f4b 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -2062,6 +2062,10 @@ export async function startApp(): Promise { interval = null; view.onEmpty(); window.logAppLoadedEvent(); + window.log.info( + 'App loaded - messages:', + messageReceiver.getProcessedCount() + ); const attachmentDownloadQueue = window.attachmentDownloadQueue || []; const THREE_DAYS_AGO = Date.now() - 3600 * 72 * 1000; const MAX_ATTACHMENT_MSGS_TO_DOWNLOAD = 250; diff --git a/ts/models/messages.ts b/ts/models/messages.ts index ee3c30df04..d690a01f77 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -2595,7 +2595,94 @@ export class MessageModel extends window.Backbone.Model { return this.syncPromise; } + // NOTE: If you're modifying this function then you'll likely also need + // to modify queueAttachmentDownloads since it contains the logic below + hasAttachmentDownloads(): boolean { + const attachments = this.get('attachments') || []; + + const [longMessageAttachments, normalAttachments] = _.partition( + attachments, + attachment => + attachment.contentType === + window.Whisper.Message.LONG_MESSAGE_CONTENT_TYPE + ); + + if (longMessageAttachments.length > 0) { + return true; + } + + const hasNormalAttachments = normalAttachments.some(attachment => { + if (!attachment) { + return false; + } + // We've already downloaded this! + if (attachment.path) { + return false; + } + return true; + }); + if (hasNormalAttachments) { + return true; + } + + const previews = this.get('preview') || []; + const hasPreviews = previews.some(item => { + if (!item.image) { + return false; + } + // We've already downloaded this! + if (item.image.path) { + return false; + } + return true; + }); + if (hasPreviews) { + return true; + } + + const contacts = this.get('contact') || []; + const hasContacts = contacts.some(item => { + if (!item.avatar || !item.avatar.avatar) { + return false; + } + if (item.avatar.avatar.path) { + return false; + } + return true; + }); + if (hasContacts) { + return true; + } + + const quote = this.get('quote'); + const quoteAttachments = + quote && quote.attachments ? quote.attachments : []; + const hasQuoteAttachments = quoteAttachments.some(item => { + if (!item.thumbnail) { + return false; + } + // We've already downloaded this! + if (item.thumbnail.path) { + return false; + } + return true; + }); + if (hasQuoteAttachments) { + return true; + } + + const sticker = this.get('sticker'); + if (sticker) { + return !sticker.data || (sticker.data && !sticker.data.path); + } + + return false; + } + // Receive logic + // NOTE: If you're changing any logic in this function that deals with the + // count then you'll also have to modify the above function + // hasAttachmentDownloads async queueAttachmentDownloads(): Promise { const attachmentsToQueue = this.get('attachments') || []; const messageId = this.id; @@ -3008,7 +3095,12 @@ export class MessageModel extends window.Backbone.Model { const inMemoryMessage = window.MessageController.findBySender( this.getSenderIdentifier() ); - if (!inMemoryMessage) { + if (inMemoryMessage) { + window.log.info( + 'handleDataMessage: cache hit', + this.getSenderIdentifier() + ); + } else { window.log.info( 'handleDataMessage: duplicate check db lookup needed', this.getSenderIdentifier() @@ -3639,6 +3731,7 @@ export class MessageModel extends window.Backbone.Model { (isImage(attachments) || isVideo(attachments)) && isInCall(reduxState); if ( + this.hasAttachmentDownloads() && // eslint-disable-next-line @typescript-eslint/no-non-null-assertion (this.getConversation()!.getAccepted() || message.isOutgoing()) && !shouldHoldOffDownload diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index 899ea1be69..344ea1dc8e 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -124,6 +124,8 @@ class MessageReceiverInner extends EventTarget { count: number; + processedCount: number; + deviceId?: number; hasConnected?: boolean; @@ -170,6 +172,7 @@ class MessageReceiverInner extends EventTarget { super(); this.count = 0; + this.processedCount = 0; this.signalingKey = signalingKey; this.username = oldUsername; @@ -784,6 +787,7 @@ class MessageReceiverInner extends EventTarget { removeFromCache(envelope: EnvelopeClass) { const { id } = envelope; this.cacheRemoveBatcher.add(id); + this.processedCount += 1; } // Same as handleEnvelope, just without the decryption step. Necessary for handling @@ -2396,6 +2400,7 @@ export default class MessageReceiver { this.cleanupSessionResets = inner.cleanupSessionResets.bind(inner); inner.connect(); + this.getProcessedCount = () => inner.processedCount; } addEventListener: (name: string, handler: Function) => void; @@ -2420,6 +2425,8 @@ export default class MessageReceiver { cleanupSessionResets: () => void; + getProcessedCount: () => number; + static stringToArrayBuffer = MessageReceiverInner.stringToArrayBuffer; static arrayBufferToString = MessageReceiverInner.arrayBufferToString; From 9f5335b85483356e133d492ed5dacdce04f2c5dc Mon Sep 17 00:00:00 2001 From: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com> Date: Tue, 9 Mar 2021 13:16:56 -0600 Subject: [PATCH 050/181] New Group administration: update title and avatar --- _locales/en/messages.json | 8 + components/GroupTitleInput.scss | 17 +- images/icons/v2/compose-solid-24.svg | 1 + stylesheets/_modules.scss | 47 +++- stylesheets/components/AvatarInput.scss | 15 ++ .../EditConversationAttributesModal.scss | 81 +++++++ stylesheets/manifest.scss | 1 + ts/components/AvatarInput.stories.tsx | 33 +-- ts/components/AvatarInput.tsx | 28 +-- ts/components/Button.tsx | 34 ++- .../ConversationDetails.stories.tsx | 1 + .../ConversationDetails.tsx | 71 +++++- .../ConversationDetailsHeader.stories.tsx | 16 +- .../ConversationDetailsHeader.tsx | 30 ++- ...ditConversationAttributesModal.stories.tsx | 57 +++++ .../EditConversationAttributesModal.tsx | 209 ++++++++++++++++++ ts/groups.ts | 93 +++++++- ts/models/conversations.ts | 25 +++ ts/state/smart/ConversationDetails.tsx | 6 + ts/test-both/util/characters_test.ts | 19 ++ .../util/canvasToArrayBuffer_test.ts | 27 +++ ts/util/canvasToArrayBuffer.ts | 17 ++ ts/util/characters.ts | 6 + ts/util/lint/exceptions.json | 22 +- ts/views/conversation_view.ts | 3 + 25 files changed, 806 insertions(+), 61 deletions(-) create mode 100644 images/icons/v2/compose-solid-24.svg create mode 100644 stylesheets/components/EditConversationAttributesModal.scss create mode 100644 ts/components/conversation/conversation-details/EditConversationAttributesModal.stories.tsx create mode 100644 ts/components/conversation/conversation-details/EditConversationAttributesModal.tsx create mode 100644 ts/test-both/util/characters_test.ts create mode 100644 ts/test-electron/util/canvasToArrayBuffer_test.ts create mode 100644 ts/util/canvasToArrayBuffer.ts create mode 100644 ts/util/characters.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 7783dfb361..b9714032b0 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1987,6 +1987,14 @@ "message": "This group couldn’t be created. Check your connection and try again.", "description": "Shown in the modal when we can't create a group" }, + "updateGroupAttributes__title": { + "message": "Edit group name and photo", + "description": "Shown in the modal when we want to update a group" + }, + "updateGroupAttributes__error-message": { + "message": "Failed to update the group. Check your connection and try again.", + "description": "Shown in the modal when we can't update a group" + }, "notSupportedSMS": { "message": "SMS/MMS messages are not supported.", "description": "Label underneath number informing user that SMS is not supported on desktop" diff --git a/components/GroupTitleInput.scss b/components/GroupTitleInput.scss index 0f93e43123..0e70d7f257 100644 --- a/components/GroupTitleInput.scss +++ b/components/GroupTitleInput.scss @@ -6,9 +6,20 @@ @include font-body-1; padding: 8px 12px; border-radius: 6px; - border: 2px solid $color-gray-15; - background: $color-white; - color: $color-black; + border-width: 2px; + border-style: solid; + + @include light-theme { + background: $color-white; + color: $color-black; + border-color: $color-gray-15; + } + + @include dark-theme { + background: $color-gray-80; + color: $color-gray-05; + border-color: $color-gray-45; + } &:focus { outline: none; diff --git a/images/icons/v2/compose-solid-24.svg b/images/icons/v2/compose-solid-24.svg new file mode 100644 index 0000000000..7efa2cbc74 --- /dev/null +++ b/images/icons/v2/compose-solid-24.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 0d96dee1b2..60aadeeea5 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -2871,18 +2871,31 @@ button.module-conversation-details__action-button { .module-conversation-details { &-header { - &__root { + &__root, + &__root--editable { align-items: center; + background: none; + border: none; + color: inherit; display: flex; flex-direction: column; - padding-bottom: 24px; + margin: 0; + outline: inherit; + padding: 0 0 24px 0; text-align: center; + width: 100%; + } + + &__root--editable { + cursor: pointer; } &__title { @include font-title-1; - padding-top: 12px; + align-items: center; + display: flex; padding-bottom: 8px; + padding-top: 12px; } &__subtitle { @@ -2894,6 +2907,34 @@ button.module-conversation-details__action-button { color: $color-gray-25; } } + + &__root--editable &__title { + $icon: '../images/icons/v2/compose-solid-24.svg'; + + &::after { + $size: 24px; + + content: ''; + height: $size; + left: $size + 13px; + margin-left: -$size; + opacity: 0; + position: relative; + transition: opacity 100ms ease-out; + width: $size; + + @include light-theme { + @include color-svg($icon, $color-gray-60); + } + @include dark-theme { + @include color-svg($icon, $color-gray-25); + } + } + } + + &__root--editable:hover &__title::after { + opacity: 1; + } } &__leave-group { diff --git a/stylesheets/components/AvatarInput.scss b/stylesheets/components/AvatarInput.scss index df781fcf0b..c355ba9d1f 100644 --- a/stylesheets/components/AvatarInput.scss +++ b/stylesheets/components/AvatarInput.scss @@ -3,12 +3,15 @@ .module-AvatarInput { @include button-reset; + display: flex; flex-direction: column; align-items: center; width: 100%; background: none; + $dark-selector: '#{&}--dark'; + &__avatar { @include button-reset; @@ -23,6 +26,10 @@ align-items: stretch; background: $color-white; + @at-root '#{$dark-selector} #{&}' { + background: $ultramarine-ui-light; + } + &::before { flex-grow: 1; content: ''; @@ -33,6 +40,14 @@ false ); -webkit-mask-size: 24px 24px; + + @at-root '#{$dark-selector} #{&}' { + @include color-svg( + '../images/icons/v2/camera-outline-24.svg', + $color-white, + false + ); + } } } diff --git a/stylesheets/components/EditConversationAttributesModal.scss b/stylesheets/components/EditConversationAttributesModal.scss new file mode 100644 index 0000000000..15f6e317bc --- /dev/null +++ b/stylesheets/components/EditConversationAttributesModal.scss @@ -0,0 +1,81 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.module-EditConversationAttributesModal { + @include popper-shadow(); + border-radius: 8px; + margin: 0 auto; + max-height: 100%; + max-width: 360px; + padding: 16px; + position: relative; + width: 95%; + display: flex; + flex-direction: column; + + @include light-theme() { + background: $color-white; + color: $color-gray-90; + } + + @include dark-theme() { + background: $color-gray-95; + color: $color-gray-05; + } + + &__close-button { + @include button-reset; + + position: absolute; + right: 12px; + top: 12px; + + height: 24px; + width: 24px; + + @include light-theme { + @include color-svg('../images/icons/v2/x-24.svg', $color-gray-75); + } + + @include dark-theme { + @include color-svg('../images/icons/v2/x-24.svg', $color-gray-15); + } + + &:focus { + @include keyboard-mode { + background-color: $ultramarine-ui-light; + } + @include dark-keyboard-mode { + background-color: $ultramarine-ui-dark; + } + } + } + + &__header { + @include font-body-1-bold; + margin: 0; + } + + .module-AvatarInput { + margin: 40px 0 24px 0; + } + + &__error-message { + @include font-body-1; + margin: 16px 0; + } + + &__button-container { + display: flex; + justify-content: flex-end; + margin-top: 16px; + flex-grow: 0; + flex-shrink: 0; + + .module-Button { + &:not(:first-child) { + margin-left: 12px; + } + } + } +} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 6316ea70af..a58c3d57cf 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -33,5 +33,6 @@ @import './components/ContactPill.scss'; @import './components/ContactPills.scss'; @import './components/ConversationHeader.scss'; +@import './components/EditConversationAttributesModal.scss'; @import './components/GroupDialog.scss'; @import './components/GroupTitleInput.scss'; diff --git a/ts/components/AvatarInput.stories.tsx b/ts/components/AvatarInput.stories.tsx index 0087dc871e..14949c22ca 100644 --- a/ts/components/AvatarInput.stories.tsx +++ b/ts/components/AvatarInput.stories.tsx @@ -9,7 +9,7 @@ import { storiesOf } from '@storybook/react'; import { setup as setupI18n } from '../../js/modules/i18n'; import enMessages from '../../_locales/en/messages.json'; -import { AvatarInput } from './AvatarInput'; +import { AvatarInput, AvatarInputVariant } from './AvatarInput'; const i18n = setupI18n('en', enMessages); @@ -22,7 +22,13 @@ const TEST_IMAGE = new Uint8Array( ).map(bytePair => parseInt(bytePair.join(''), 16)) ).buffer; -const Wrapper = ({ startValue }: { startValue: undefined | ArrayBuffer }) => { +const Wrapper = ({ + startValue, + variant, +}: { + startValue: undefined | ArrayBuffer; + variant?: AvatarInputVariant; +}) => { const [value, setValue] = useState(startValue); const [objectUrl, setObjectUrl] = useState(); @@ -40,18 +46,13 @@ const Wrapper = ({ startValue }: { startValue: undefined | ArrayBuffer }) => { return ( <> -
- -
+
Processed image (if it exists)
{objectUrl && } @@ -67,3 +68,7 @@ story.add('No start state', () => { story.add('Starting with a value', () => { return ; }); + +story.add('Dark variant', () => { + return ; +}); diff --git a/ts/components/AvatarInput.tsx b/ts/components/AvatarInput.tsx index 3de1399792..9944540ad6 100644 --- a/ts/components/AvatarInput.tsx +++ b/ts/components/AvatarInput.tsx @@ -9,12 +9,14 @@ import React, { MouseEventHandler, FunctionComponent, } from 'react'; +import classNames from 'classnames'; import { ContextMenu, MenuItem, ContextMenuTrigger } from 'react-contextmenu'; import loadImage, { LoadImageOptions } from 'blueimp-load-image'; import { noop } from 'lodash'; import { LocalizerType } from '../types/Util'; import { Spinner } from './Spinner'; +import { canvasToArrayBuffer } from '../util/canvasToArrayBuffer'; type PropsType = { // This ID needs to be globally unique across the app. @@ -23,6 +25,7 @@ type PropsType = { i18n: LocalizerType; onChange: (value: undefined | ArrayBuffer) => unknown; value: undefined | ArrayBuffer; + variant?: AvatarInputVariant; }; enum ImageStatus { @@ -31,12 +34,18 @@ enum ImageStatus { HasImage = 'has-image', } +export enum AvatarInputVariant { + Light = 'light', + Dark = 'dark', +} + export const AvatarInput: FunctionComponent = ({ contextMenuId, disabled, i18n, onChange, value, + variant = AvatarInputVariant.Light, }) => { const fileInputRef = useRef(null); // Comes from a third-party dependency @@ -136,7 +145,10 @@ export const AvatarInput: FunctionComponent = ({ diff --git a/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx b/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx index 99a816eb1f..457ff6bce4 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx @@ -60,6 +60,7 @@ const createProps = (hasGroupLink = false): Props => ({ showGroupV2Permissions: action('showGroupV2Permissions'), showPendingInvites: action('showPendingInvites'), showLightboxForMedia: action('showLightboxForMedia'), + updateGroupAttributes: action('updateGroupAttributes'), onBlockAndDelete: action('onBlockAndDelete'), onDelete: action('onDelete'), }); diff --git a/ts/components/conversation/conversation-details/ConversationDetails.tsx b/ts/components/conversation/conversation-details/ConversationDetails.tsx index 7a5897b23a..9d150412e7 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.tsx @@ -1,7 +1,7 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React from 'react'; +import React, { useState } from 'react'; import { ConversationType } from '../../../state/ducks/conversations'; import { @@ -18,6 +18,10 @@ import { ConversationDetailsHeader } from './ConversationDetailsHeader'; import { ConversationDetailsIcon } from './ConversationDetailsIcon'; import { ConversationDetailsMediaList } from './ConversationDetailsMediaList'; import { ConversationDetailsMembershipList } from './ConversationDetailsMembershipList'; +import { + EditConversationAttributesModal, + RequestState as EditGroupAttributesRequestState, +} from './EditConversationAttributesModal'; export type StateProps = { canEditGroupInfo: boolean; @@ -36,6 +40,12 @@ export type StateProps = { selectedMediaItem: MediaItemType, media: Array ) => void; + updateGroupAttributes: ( + _: Readonly<{ + avatar?: undefined | ArrayBuffer; + title?: string; + }> + ) => void; onBlockAndDelete: () => void; onDelete: () => void; }; @@ -56,9 +66,20 @@ export const ConversationDetails: React.ComponentType = ({ showGroupV2Permissions, showPendingInvites, showLightboxForMedia, + updateGroupAttributes, onBlockAndDelete, onDelete, }) => { + const [isEditingGroupAttributes, setIsEditingGroupAttributes] = useState( + false + ); + const [ + editGroupAttributesRequestState, + setEditGroupAttributesRequestState, + ] = useState( + EditGroupAttributesRequestState.Inactive + ); + const updateExpireTimer = (event: React.ChangeEvent) => { setDisappearingMessages(parseInt(event.target.value, 10)); }; @@ -75,7 +96,14 @@ export const ConversationDetails: React.ComponentType = ({ return (
- + { + setIsEditingGroupAttributes(true); + }} + /> {canEditGroupInfo ? ( @@ -171,6 +199,43 @@ export const ConversationDetails: React.ComponentType = ({ onDelete={onDelete} onBlockAndDelete={onBlockAndDelete} /> + + {isEditingGroupAttributes && ( + + ) => { + setEditGroupAttributesRequestState( + EditGroupAttributesRequestState.Active + ); + + try { + await updateGroupAttributes(options); + setIsEditingGroupAttributes(false); + setEditGroupAttributesRequestState( + EditGroupAttributesRequestState.Inactive + ); + } catch (err) { + setEditGroupAttributesRequestState( + EditGroupAttributesRequestState.InactiveWithError + ); + } + }} + onClose={() => { + setIsEditingGroupAttributes(false); + setEditGroupAttributesRequestState( + EditGroupAttributesRequestState.Inactive + ); + }} + requestState={editGroupAttributesRequestState} + title={conversation.title} + /> + )}
); }; diff --git a/ts/components/conversation/conversation-details/ConversationDetailsHeader.stories.tsx b/ts/components/conversation/conversation-details/ConversationDetailsHeader.stories.tsx index 9bbe6ea285..c869286f8b 100644 --- a/ts/components/conversation/conversation-details/ConversationDetailsHeader.stories.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetailsHeader.stories.tsx @@ -1,9 +1,10 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; import { number, text } from '@storybook/addon-knobs'; import { setup as setupI18n } from '../../../../js/modules/i18n'; @@ -15,7 +16,7 @@ import { ConversationDetailsHeader, Props } from './ConversationDetailsHeader'; const i18n = setupI18n('en', enMessages); const story = storiesOf( - 'Components/Conversation/ConversationDetails/ConversationDetailHeader', + 'Components/Conversation/ConversationDetails/ConversationDetailsHeader', module ); @@ -28,9 +29,12 @@ const createConversation = (): ConversationType => ({ memberships: new Array(number('conversation members length', 0)), }); -const createProps = (): Props => ({ +const createProps = (overrideProps: Partial = {}): Props => ({ conversation: createConversation(), i18n, + canEdit: false, + startEditing: action('startEditing'), + ...overrideProps, }); story.add('Basic', () => { @@ -38,3 +42,9 @@ story.add('Basic', () => { return ; }); + +story.add('Editable', () => { + const props = createProps({ canEdit: true }); + + return ; +}); diff --git a/ts/components/conversation/conversation-details/ConversationDetailsHeader.tsx b/ts/components/conversation/conversation-details/ConversationDetailsHeader.tsx index 25111fcf2e..2fd6dc93c2 100644 --- a/ts/components/conversation/conversation-details/ConversationDetailsHeader.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetailsHeader.tsx @@ -1,4 +1,4 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React from 'react'; @@ -9,20 +9,24 @@ import { ConversationType } from '../../../state/ducks/conversations'; import { bemGenerator } from './util'; export type Props = { - i18n: LocalizerType; + canEdit: boolean; conversation: ConversationType; + i18n: LocalizerType; + startEditing: () => void; }; const bem = bemGenerator('module-conversation-details-header'); export const ConversationDetailsHeader: React.ComponentType = ({ - i18n, + canEdit, conversation, + i18n, + startEditing, }) => { const memberships = conversation.memberships || []; - return ( -
+ const contents = ( + <> = ({ ])}
-
+ ); + + if (canEdit) { + return ( + + ); + } + + return
{contents}
; }; diff --git a/ts/components/conversation/conversation-details/EditConversationAttributesModal.stories.tsx b/ts/components/conversation/conversation-details/EditConversationAttributesModal.stories.tsx new file mode 100644 index 0000000000..2ca43fcfe8 --- /dev/null +++ b/ts/components/conversation/conversation-details/EditConversationAttributesModal.stories.tsx @@ -0,0 +1,57 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { ComponentProps } from 'react'; + +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; + +import { setup as setupI18n } from '../../../../js/modules/i18n'; +import enMessages from '../../../../_locales/en/messages.json'; +import { + EditConversationAttributesModal, + RequestState, +} from './EditConversationAttributesModal'; + +const i18n = setupI18n('en', enMessages); + +const story = storiesOf( + 'Components/Conversation/ConversationDetails/EditConversationAttributesModal', + module +); + +type PropsType = ComponentProps; + +const createProps = (overrideProps: Partial = {}): PropsType => ({ + avatarPath: undefined, + i18n, + onClose: action('onClose'), + makeRequest: action('onMakeRequest'), + requestState: RequestState.Inactive, + title: 'Bing Bong Group', + ...overrideProps, +}); + +story.add('No avatar, empty title', () => ( + +)); + +story.add('Avatar and title', () => ( + +)); + +story.add('Request active', () => ( + +)); + +story.add('Has error', () => ( + +)); diff --git a/ts/components/conversation/conversation-details/EditConversationAttributesModal.tsx b/ts/components/conversation/conversation-details/EditConversationAttributesModal.tsx new file mode 100644 index 0000000000..3a46ccde66 --- /dev/null +++ b/ts/components/conversation/conversation-details/EditConversationAttributesModal.tsx @@ -0,0 +1,209 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { + FormEventHandler, + FunctionComponent, + useEffect, + useRef, + useState, +} from 'react'; +import { noop } from 'lodash'; + +import { LocalizerType } from '../../../types/Util'; +import { ModalHost } from '../../ModalHost'; +import { AvatarInput, AvatarInputVariant } from '../../AvatarInput'; +import { Button, ButtonVariant } from '../../Button'; +import { Spinner } from '../../Spinner'; +import { GroupTitleInput } from '../../GroupTitleInput'; +import * as log from '../../../logging/log'; +import { canvasToArrayBuffer } from '../../../util/canvasToArrayBuffer'; + +const TEMPORARY_AVATAR_VALUE = new ArrayBuffer(0); + +type PropsType = { + avatarPath?: string; + i18n: LocalizerType; + makeRequest: ( + _: Readonly<{ + avatar?: undefined | ArrayBuffer; + title?: undefined | string; + }> + ) => void; + onClose: () => void; + requestState: RequestState; + title: string; +}; + +export enum RequestState { + Inactive, + InactiveWithError, + Active, +} + +export const EditConversationAttributesModal: FunctionComponent = ({ + avatarPath: externalAvatarPath, + i18n, + makeRequest, + onClose, + requestState, + title: externalTitle, +}) => { + const startingTitleRef = useRef(externalTitle); + const startingAvatarPathRef = useRef(externalAvatarPath); + + const [avatar, setAvatar] = useState( + externalAvatarPath ? TEMPORARY_AVATAR_VALUE : undefined + ); + const [title, setTitle] = useState(externalTitle); + const [hasAvatarChanged, setHasAvatarChanged] = useState(false); + + useEffect(() => { + const startingAvatarPath = startingAvatarPathRef.current; + if (!startingAvatarPath) { + return noop; + } + + let shouldCancel = false; + + (async () => { + try { + const buffer = await imagePathToArrayBuffer(startingAvatarPath); + if (shouldCancel) { + return; + } + setAvatar(buffer); + } catch (err) { + log.warn( + `Failed to convert image URL to array buffer. Error message: ${ + err && err.message + }` + ); + } + })(); + + return () => { + shouldCancel = true; + }; + }, []); + + const hasChangedExternally = + startingAvatarPathRef.current !== externalAvatarPath || + startingTitleRef.current !== externalTitle; + const hasTitleChanged = title !== externalTitle; + + const isRequestActive = requestState === RequestState.Active; + + const canSubmit = + !isRequestActive && + (hasChangedExternally || hasTitleChanged || hasAvatarChanged) && + title.length > 0; + + const onSubmit: FormEventHandler = event => { + event.preventDefault(); + + const request: { + avatar?: undefined | ArrayBuffer; + title?: string; + } = {}; + if (hasAvatarChanged) { + request.avatar = avatar; + } + if (hasTitleChanged) { + request.title = title; + } + makeRequest(request); + }; + + return ( + +
+ + + +
+ + + ); +}; + +async function imagePathToArrayBuffer(src: string): Promise { + const image = new Image(); + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + if (!context) { + throw new Error( + 'imagePathToArrayBuffer: could not get canvas rendering context' + ); + } + + image.src = src; + await image.decode(); + + canvas.width = image.width; + canvas.height = image.height; + + context.drawImage(image, 0, 0); + + const result = await canvasToArrayBuffer(canvas); + return result; +} diff --git a/ts/groups.ts b/ts/groups.ts index f628262fa8..909419b7fd 100644 --- a/ts/groups.ts +++ b/ts/groups.ts @@ -380,6 +380,16 @@ async function uploadAvatar( } } +function buildGroupTitleBuffer( + clientZkGroupCipher: ClientZkGroupCipher, + title: string +): ArrayBuffer { + const titleBlob = new window.textsecure.protobuf.GroupAttributeBlob(); + titleBlob.title = title; + const titleBlobPlaintext = titleBlob.toArrayBuffer(); + return encryptGroupBlob(clientZkGroupCipher, titleBlobPlaintext); +} + function buildGroupProto( attributes: Pick< ConversationAttributesType, @@ -423,10 +433,9 @@ function buildGroupProto( proto.publicKey = base64ToArrayBuffer(publicParams); proto.version = attributes.revision || 0; - const titleBlob = new window.textsecure.protobuf.GroupAttributeBlob(); - titleBlob.title = attributes.name; - const titleBlobPlaintext = titleBlob.toArrayBuffer(); - proto.title = encryptGroupBlob(clientZkGroupCipher, titleBlobPlaintext); + if (attributes.name) { + proto.title = buildGroupTitleBuffer(clientZkGroupCipher, attributes.name); + } if (attributes.avatarUrl) { proto.avatar = attributes.avatarUrl; @@ -533,6 +542,82 @@ function buildGroupProto( return proto; } +export async function buildUpdateAttributesChange( + conversation: Pick< + ConversationAttributesType, + 'id' | 'revision' | 'publicParams' | 'secretParams' + >, + attributes: Readonly<{ + avatar?: undefined | ArrayBuffer; + title?: string; + }> +): Promise { + const { publicParams, secretParams, revision, id } = conversation; + + const logId = `groupv2(${id})`; + + if (!publicParams) { + throw new Error( + `buildUpdateAttributesChange/${logId}: attributes were missing publicParams!` + ); + } + if (!secretParams) { + throw new Error( + `buildUpdateAttributesChange/${logId}: attributes were missing secretParams!` + ); + } + + const actions = new window.textsecure.protobuf.GroupChange.Actions(); + + let hasChangedSomething = false; + + const clientZkGroupCipher = getClientZkGroupCipher(secretParams); + + // There are three possible states here: + // + // 1. 'avatar' not in attributes: we don't want to change the avatar. + // 2. attributes.avatar === undefined: we want to clear the avatar. + // 3. attributes.avatar !== undefined: we want to update the avatar. + if ('avatar' in attributes) { + hasChangedSomething = true; + + actions.modifyAvatar = new window.textsecure.protobuf.GroupChange.Actions.ModifyAvatarAction(); + const { avatar } = attributes; + if (avatar) { + const uploadedAvatar = await uploadAvatar({ + data: avatar, + logId, + publicParams, + secretParams, + }); + actions.modifyAvatar.avatar = uploadedAvatar.key; + } + + // If we don't set `actions.modifyAvatar.avatar`, it will be cleared. + } + + const { title } = attributes; + if (title) { + hasChangedSomething = true; + + actions.modifyTitle = new window.textsecure.protobuf.GroupChange.Actions.ModifyTitleAction(); + actions.modifyTitle.title = buildGroupTitleBuffer( + clientZkGroupCipher, + title + ); + } + + if (!hasChangedSomething) { + // This shouldn't happen. When these actions are passed to `modifyGroupV2`, a warning + // will be logged. + return undefined; + } + + actions.version = (revision || 0) + 1; + + return actions; +} + export function buildDisappearingMessagesTimerChange({ expireTimer, group, diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 7a5f90e502..dd090c6da2 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -1716,6 +1716,27 @@ export class ConversationModel extends window.Backbone.Model< }); } + async updateGroupAttributesV2( + attributes: Readonly<{ + avatar?: undefined | ArrayBuffer; + title?: string; + }> + ): Promise { + await this.modifyGroupV2({ + name: 'updateGroupAttributesV2', + createGroupChange: () => + window.Signal.Groups.buildUpdateAttributesChange( + { + id: this.id, + publicParams: this.get('publicParams'), + revision: this.get('revision'), + secretParams: this.get('secretParams'), + }, + attributes + ), + }); + } + async leaveGroupV2(): Promise { const ourConversationId = window.ConversationController.getOurConversationId(); @@ -4818,6 +4839,10 @@ export class ConversationModel extends window.Backbone.Model< return false; } + if (this.get('left')) { + return false; + } + return ( this.areWeAdmin() || this.get('accessControl')?.attributes === diff --git a/ts/state/smart/ConversationDetails.tsx b/ts/state/smart/ConversationDetails.tsx index 12d6012c0a..39cf4fb137 100644 --- a/ts/state/smart/ConversationDetails.tsx +++ b/ts/state/smart/ConversationDetails.tsx @@ -26,6 +26,12 @@ export type SmartConversationDetailsProps = { selectedMediaItem: MediaItemType, media: Array ) => void; + updateGroupAttributes: ( + _: Readonly<{ + avatar?: undefined | ArrayBuffer; + title?: string; + }> + ) => void; onBlockAndDelete: () => void; onDelete: () => void; }; diff --git a/ts/test-both/util/characters_test.ts b/ts/test-both/util/characters_test.ts new file mode 100644 index 0000000000..b6d02bde68 --- /dev/null +++ b/ts/test-both/util/characters_test.ts @@ -0,0 +1,19 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import { count } from '../../util/characters'; + +describe('character utilities', () => { + describe('count', () => { + it('returns the number of characters in a string (not necessarily the length)', () => { + assert.strictEqual(count(''), 0); + assert.strictEqual(count('hello'), 5); + assert.strictEqual(count('Bokmål'), 6); + assert.strictEqual(count('💩💩💩'), 3); + assert.strictEqual(count('👩‍❤️‍👩'), 6); + assert.strictEqual(count('Z͑ͫ̓ͪ̂ͫ̽͏̴̙̤̞͉͚̯̞̠͍A̴̵̜̰͔ͫ͗͢L̠ͨͧͩ͘G̴̻͈͍͔̹̑͗̎̅͛́Ǫ̵̹̻̝̳͂̌̌͘'), 58); + }); + }); +}); diff --git a/ts/test-electron/util/canvasToArrayBuffer_test.ts b/ts/test-electron/util/canvasToArrayBuffer_test.ts new file mode 100644 index 0000000000..5df574c857 --- /dev/null +++ b/ts/test-electron/util/canvasToArrayBuffer_test.ts @@ -0,0 +1,27 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import { canvasToArrayBuffer } from '../../util/canvasToArrayBuffer'; + +describe('canvasToArrayBuffer', () => { + it('converts a canvas to an ArrayBuffer', async () => { + const canvas = document.createElement('canvas'); + canvas.width = 100; + canvas.height = 200; + + const context = canvas.getContext('2d'); + if (!context) { + throw new Error('Test setup error: cannot get canvas rendering context'); + } + context.fillStyle = '#ff9900'; + context.fillRect(10, 10, 20, 20); + + const result = await canvasToArrayBuffer(canvas); + + // These are just smoke tests. + assert.instanceOf(result, ArrayBuffer); + assert.isAtLeast(result.byteLength, 50); + }); +}); diff --git a/ts/util/canvasToArrayBuffer.ts b/ts/util/canvasToArrayBuffer.ts new file mode 100644 index 0000000000..2d9763f2c7 --- /dev/null +++ b/ts/util/canvasToArrayBuffer.ts @@ -0,0 +1,17 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export async function canvasToArrayBuffer( + canvas: HTMLCanvasElement +): Promise { + const blob: Blob = await new Promise((resolve, reject) => { + canvas.toBlob(result => { + if (result) { + resolve(result); + } else { + reject(new Error("Couldn't convert the canvas to a Blob")); + } + }, 'image/webp'); + }); + return blob.arrayBuffer(); +} diff --git a/ts/util/characters.ts b/ts/util/characters.ts new file mode 100644 index 0000000000..a4e44ef524 --- /dev/null +++ b/ts/util/characters.ts @@ -0,0 +1,6 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export function count(str: string): number { + return Array.from(str).length; +} diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 2f6a29f6c2..5e673c1b27 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -14473,7 +14473,7 @@ "rule": "React-useRef", "path": "ts/components/AvatarInput.js", "line": " const fileInputRef = react_1.useRef(null);", - "lineNumber": 40, + "lineNumber": 47, "reasonCategory": "usageTrusted", "updated": "2021-03-01T18:34:36.638Z", "reasonDetail": "Needed to trigger a click on a 'file' input. Doesn't update the DOM." @@ -14482,7 +14482,7 @@ "rule": "React-useRef", "path": "ts/components/AvatarInput.js", "line": " const menuTriggerRef = react_1.useRef(null);", - "lineNumber": 43, + "lineNumber": 50, "reasonCategory": "usageTrusted", "updated": "2021-03-01T18:34:36.638Z", "reasonDetail": "Used to reference popup menu" @@ -15045,6 +15045,24 @@ "updated": "2019-07-31T00:19:18.696Z", "reasonDetail": "Timeline needs to interact with its child List directly" }, + { + "rule": "React-useRef", + "path": "ts/components/conversation/conversation-details/EditConversationAttributesModal.js", + "line": " const startingTitleRef = react_1.useRef(externalTitle);", + "lineNumber": 42, + "reasonCategory": "usageTrusted", + "updated": "2021-03-05T22:52:40.572Z", + "reasonDetail": "Doesn't interact with the DOM." + }, + { + "rule": "React-useRef", + "path": "ts/components/conversation/conversation-details/EditConversationAttributesModal.js", + "line": " const startingAvatarPathRef = react_1.useRef(externalAvatarPath);", + "lineNumber": 43, + "reasonCategory": "usageTrusted", + "updated": "2021-03-05T22:52:40.572Z", + "reasonDetail": "Doesn't interact with the DOM." + }, { "rule": "React-createRef", "path": "ts/components/conversation/media-gallery/MediaGallery.js", diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index 3b36574e5e..8d26f35033 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -2897,6 +2897,9 @@ Whisper.ConversationView = Whisper.View.extend({ showGroupV2Permissions: this.showGroupV2Permissions.bind(this), showPendingInvites: this.showPendingInvites.bind(this), showLightboxForMedia: this.showLightboxForMedia.bind(this), + updateGroupAttributes: conversation.updateGroupAttributesV2.bind( + conversation + ), onDelete, onBlockAndDelete, }; From 1ca4960924012cea8ac8a538199daf8aa71a23f3 Mon Sep 17 00:00:00 2001 From: Josh Perez <60019601+josh-signal@users.noreply.github.com> Date: Tue, 9 Mar 2021 16:30:07 -0500 Subject: [PATCH 051/181] Changes display of your own name to "You" --- .../conversation-details/ConversationDetailsMembershipList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.tsx b/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.tsx index 647e5df3d3..d947af7fb9 100644 --- a/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.tsx @@ -97,7 +97,7 @@ export const ConversationDetailsMembershipList: React.ComponentType = ({ {...member} /> } - label={member.title} + label={member.isMe ? i18n('you') : member.title} right={isAdmin ? i18n('GroupV2--admin') : ''} /> ))} From 12d7f24d0fc82b2ebb7f131fe0ed98781ac8c2a6 Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Wed, 10 Mar 2021 12:36:58 -0800 Subject: [PATCH 052/181] New UI for audio playback and global audio player Introduce new UI and behavior for playing audio attachments in conversations. Previously, playback stopped unexpectedly during window resizes and scrolling through the messages due to the row height recomputation in `react-virtualized`. With this commit we introduce `` instance that wraps whole conversation and provides an `
+ + ); +}; + +function shouldNeverBeCalled(..._args: ReadonlyArray): unknown { + assert(false, 'This should never be called. Doing nothing'); +} diff --git a/ts/components/conversation/conversation-details/AddGroupMembersModal/ConfirmAdditionsModal.tsx b/ts/components/conversation/conversation-details/AddGroupMembersModal/ConfirmAdditionsModal.tsx new file mode 100644 index 0000000000..b4175f67f6 --- /dev/null +++ b/ts/components/conversation/conversation-details/AddGroupMembersModal/ConfirmAdditionsModal.tsx @@ -0,0 +1,109 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { FunctionComponent, ReactNode } from 'react'; + +import { LocalizerType } from '../../../../types/Util'; +import { assert } from '../../../../util/assert'; +import { ModalHost } from '../../../ModalHost'; +import { Button, ButtonVariant } from '../../../Button'; +import { Spinner } from '../../../Spinner'; +import { ConversationType } from '../../../../state/ducks/conversations'; +import { RequestState } from '../util'; +import { Intl } from '../../../Intl'; +import { Emojify } from '../../Emojify'; +import { ContactName } from '../../ContactName'; + +type PropsType = { + groupTitle: string; + i18n: LocalizerType; + makeRequest: () => void; + onClose: () => void; + requestState: RequestState; + selectedContacts: ReadonlyArray; +}; + +export const ConfirmAdditionsModal: FunctionComponent = ({ + groupTitle, + i18n, + makeRequest, + onClose, + requestState, + selectedContacts, +}) => { + const firstContact = selectedContacts[0]; + assert( + firstContact, + 'Expected at least one conversation to be selected but none were picked' + ); + + const groupTitleNode: JSX.Element = ; + + let headerText: ReactNode; + if (selectedContacts.length === 1) { + headerText = ( + + ), + group: groupTitleNode, + }} + /> + ); + } else { + headerText = ( + + ); + } + + let buttonContents: ReactNode; + if (requestState === RequestState.Active) { + buttonContents = ( + + ); + } else if (selectedContacts.length === 1) { + buttonContents = i18n('AddGroupMembersModal--confirm-button--one'); + } else { + buttonContents = i18n('AddGroupMembersModal--confirm-button--many'); + } + + return ( + +
+

{headerText}

+ {requestState === RequestState.InactiveWithError && ( +
+ {i18n('updateGroupAttributes__error-message')} +
+ )} +
+ + + +
+
+
+ ); +}; diff --git a/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx b/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx index 457ff6bce4..806a2a1dcf 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx @@ -5,6 +5,7 @@ import * as React from 'react'; import { storiesOf } from '@storybook/react'; import { action } from '@storybook/addon-actions'; +import { times } from 'lodash'; import { setup as setupI18n } from '../../../../js/modules/i18n'; import enMessages from '../../../../_locales/en/messages.json'; @@ -47,7 +48,11 @@ const conversation: ConversationType = { }; const createProps = (hasGroupLink = false): Props => ({ + addMembers: async () => { + action('addMembers'); + }, canEditGroupInfo: false, + candidateContactsToAdd: times(10, () => getDefaultConversation()), conversation, hasGroupLink, i18n, diff --git a/ts/components/conversation/conversation-details/ConversationDetails.tsx b/ts/components/conversation/conversation-details/ConversationDetails.tsx index 9d150412e7..73afc69428 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.tsx @@ -1,30 +1,39 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { useState } from 'react'; +import React, { useState, ReactNode } from 'react'; import { ConversationType } from '../../../state/ducks/conversations'; +import { assert } from '../../../util/assert'; import { ExpirationTimerOptions, TimerOption, } from '../../../util/ExpirationTimerOptions'; import { LocalizerType } from '../../../types/Util'; import { MediaItemType } from '../../LightboxGallery'; +import { missingCaseError } from '../../../util/missingCaseError'; import { PanelRow } from './PanelRow'; import { PanelSection } from './PanelSection'; +import { AddGroupMembersModal } from './AddGroupMembersModal'; import { ConversationDetailsActions } from './ConversationDetailsActions'; import { ConversationDetailsHeader } from './ConversationDetailsHeader'; import { ConversationDetailsIcon } from './ConversationDetailsIcon'; import { ConversationDetailsMediaList } from './ConversationDetailsMediaList'; import { ConversationDetailsMembershipList } from './ConversationDetailsMembershipList'; -import { - EditConversationAttributesModal, - RequestState as EditGroupAttributesRequestState, -} from './EditConversationAttributesModal'; +import { EditConversationAttributesModal } from './EditConversationAttributesModal'; +import { RequestState } from './util'; + +enum ModalState { + NothingOpen, + EditingGroupAttributes, + AddingGroupMembers, +} export type StateProps = { + addMembers: (conversationIds: ReadonlyArray) => Promise; canEditGroupInfo: boolean; + candidateContactsToAdd: Array; conversation?: ConversationType; hasGroupLink: boolean; i18n: LocalizerType; @@ -53,7 +62,9 @@ export type StateProps = { export type Props = StateProps; export const ConversationDetails: React.ComponentType = ({ + addMembers, canEditGroupInfo, + candidateContactsToAdd, conversation, hasGroupLink, i18n, @@ -70,15 +81,17 @@ export const ConversationDetails: React.ComponentType = ({ onBlockAndDelete, onDelete, }) => { - const [isEditingGroupAttributes, setIsEditingGroupAttributes] = useState( - false + const [modalState, setModalState] = useState( + ModalState.NothingOpen ); const [ editGroupAttributesRequestState, setEditGroupAttributesRequestState, - ] = useState( - EditGroupAttributesRequestState.Inactive - ); + ] = useState(RequestState.Inactive); + const [ + addGroupMembersRequestState, + setAddGroupMembersRequestState, + ] = useState(RequestState.Inactive); const updateExpireTimer = (event: React.ChangeEvent) => { setDisappearingMessages(parseInt(event.target.value, 10)); @@ -94,6 +107,88 @@ export const ConversationDetails: React.ComponentType = ({ const invitesCount = pendingMemberships.length + pendingApprovalMemberships.length; + let modalNode: ReactNode; + switch (modalState) { + case ModalState.NothingOpen: + modalNode = undefined; + break; + case ModalState.EditingGroupAttributes: + modalNode = ( + + ) => { + setEditGroupAttributesRequestState(RequestState.Active); + + try { + await updateGroupAttributes(options); + setModalState(ModalState.NothingOpen); + setEditGroupAttributesRequestState(RequestState.Inactive); + } catch (err) { + setEditGroupAttributesRequestState( + RequestState.InactiveWithError + ); + } + }} + onClose={() => { + setModalState(ModalState.NothingOpen); + setEditGroupAttributesRequestState(RequestState.Inactive); + }} + requestState={editGroupAttributesRequestState} + title={conversation.title} + /> + ); + break; + case ModalState.AddingGroupMembers: + modalNode = ( + { + setAddGroupMembersRequestState(oldRequestState => { + assert( + oldRequestState !== RequestState.Active, + 'Should not be clearing an active request state' + ); + return RequestState.Inactive; + }); + }} + conversationIdsAlreadyInGroup={ + new Set( + (conversation.memberships || []).map( + membership => membership.member.id + ) + ) + } + groupTitle={conversation.title} + i18n={i18n} + makeRequest={async conversationIds => { + setAddGroupMembersRequestState(RequestState.Active); + + try { + await addMembers(conversationIds); + setModalState(ModalState.NothingOpen); + setAddGroupMembersRequestState(RequestState.Inactive); + } catch (err) { + setAddGroupMembersRequestState(RequestState.InactiveWithError); + } + }} + onClose={() => { + setModalState(ModalState.NothingOpen); + setEditGroupAttributesRequestState(RequestState.Inactive); + }} + requestState={addGroupMembersRequestState} + /> + ); + break; + default: + throw missingCaseError(modalState); + } + return (
= ({ conversation={conversation} i18n={i18n} startEditing={() => { - setIsEditingGroupAttributes(true); + setModalState(ModalState.EditingGroupAttributes); }} /> @@ -141,9 +236,13 @@ export const ConversationDetails: React.ComponentType = ({ ) : null} { + setModalState(ModalState.AddingGroupMembers); + }} /> @@ -200,42 +299,7 @@ export const ConversationDetails: React.ComponentType = ({ onBlockAndDelete={onBlockAndDelete} /> - {isEditingGroupAttributes && ( - - ) => { - setEditGroupAttributesRequestState( - EditGroupAttributesRequestState.Active - ); - - try { - await updateGroupAttributes(options); - setIsEditingGroupAttributes(false); - setEditGroupAttributesRequestState( - EditGroupAttributesRequestState.Inactive - ); - } catch (err) { - setEditGroupAttributesRequestState( - EditGroupAttributesRequestState.InactiveWithError - ); - } - }} - onClose={() => { - setIsEditingGroupAttributes(false); - setEditGroupAttributesRequestState( - EditGroupAttributesRequestState.Inactive - ); - }} - requestState={editGroupAttributesRequestState} - title={conversation.title} - /> - )} + {modalNode}
); }; diff --git a/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.stories.tsx b/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.stories.tsx index 13d748761f..8803373003 100644 --- a/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.stories.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.stories.tsx @@ -1,7 +1,8 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; +import { isBoolean } from 'lodash'; import { storiesOf } from '@storybook/react'; import { action } from '@storybook/addon-actions'; @@ -42,9 +43,13 @@ const createMemberships = ( }; const createProps = (overrideProps: Partial): Props => ({ + canAddNewMembers: isBoolean(overrideProps.canAddNewMembers) + ? overrideProps.canAddNewMembers + : false, i18n, memberships: overrideProps.memberships || [], showContactModal: action('showContactModal'), + startAddingNewMembers: action('startAddingNewMembers'), }); story.add('Few', () => { @@ -92,3 +97,11 @@ story.add('None', () => { return ; }); + +story.add('Can add new members', () => { + const memberships = createMemberships(10); + + const props = createProps({ canAddNewMembers: true, memberships }); + + return ; +}); diff --git a/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.tsx b/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.tsx index d947af7fb9..d36ac2fc9f 100644 --- a/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.tsx @@ -1,4 +1,4 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React from 'react'; @@ -19,8 +19,10 @@ export type GroupV2Membership = { }; export type Props = { + canAddNewMembers: boolean; memberships: Array; showContactModal: (conversationId: string) => void; + startAddingNewMembers: () => void; i18n: LocalizerType; }; @@ -66,8 +68,10 @@ function sortMemberships( } export const ConversationDetailsMembershipList: React.ComponentType = ({ + canAddNewMembers, memberships, showContactModal, + startAddingNewMembers, i18n, }) => { const [showAllMembers, setShowAllMembers] = React.useState(false); @@ -85,6 +89,15 @@ export const ConversationDetailsMembershipList: React.ComponentType = ({ sortedMemberships.length.toString(), ])} > + {canAddNewMembers && ( + + } + label={i18n('ConversationDetailsMembershipList--add-members')} + onClick={startAddingNewMembers} + /> + )} {sortedMemberships.slice(0, membersToShow).map(({ isAdmin, member }) => ( = ({ avatarPath: externalAvatarPath, i18n, diff --git a/ts/components/conversation/conversation-details/util.ts b/ts/components/conversation/conversation-details/util.ts index 3672694be3..48fb187f43 100644 --- a/ts/components/conversation/conversation-details/util.ts +++ b/ts/components/conversation/conversation-details/util.ts @@ -1,8 +1,14 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import classNames from 'classnames'; +export enum RequestState { + Inactive, + InactiveWithError, + Active, +} + export const bemGenerator = (block: string) => ( element: string, modifier?: string | Record diff --git a/ts/components/conversationList/BaseConversationListItem.tsx b/ts/components/conversationList/BaseConversationListItem.tsx index 6f65f1e3a0..37580dbaaf 100644 --- a/ts/components/conversationList/BaseConversationListItem.tsx +++ b/ts/components/conversationList/BaseConversationListItem.tsx @@ -98,6 +98,11 @@ export const BaseConversationListItem: FunctionComponent = React.memo className={CHECKBOX_CLASS_NAME} disabled={disabled} onChange={onClick} + onKeyDown={event => { + if (onClick && !disabled && event.key === 'Enter') { + onClick(); + } + }} type="checkbox" /> ); diff --git a/ts/components/conversationList/ContactCheckbox.tsx b/ts/components/conversationList/ContactCheckbox.tsx index e28ea7bfc7..f8ac6b2ae2 100644 --- a/ts/components/conversationList/ContactCheckbox.tsx +++ b/ts/components/conversationList/ContactCheckbox.tsx @@ -1,7 +1,7 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { CSSProperties, FunctionComponent } from 'react'; +import React, { CSSProperties, FunctionComponent, ReactNode } from 'react'; import { BaseConversationListItem } from './BaseConversationListItem'; import { ColorType } from '../../types/Colors'; @@ -11,7 +11,8 @@ import { About } from '../conversation/About'; export enum ContactCheckboxDisabledReason { // We start the enum at 1 because the default starting value of 0 is falsy. - MaximumContactsSelected = 1, + AlreadyAdded = 1, + MaximumContactsSelected, NotCapable, } @@ -67,7 +68,14 @@ export const ContactCheckbox: FunctionComponent = React.memo( /> ); - const messageText = about ? : null; + let messageText: ReactNode; + if (disabledReason === ContactCheckboxDisabledReason.AlreadyAdded) { + messageText = i18n('alreadyAMember'); + } else if (about) { + messageText = ; + } else { + messageText = null; + } const onClickItem = () => { onClick(id, disabledReason); diff --git a/ts/components/leftPane/LeftPaneChooseGroupMembersHelper.tsx b/ts/components/leftPane/LeftPaneChooseGroupMembersHelper.tsx index 43e8dadef2..701daf9721 100644 --- a/ts/components/leftPane/LeftPaneChooseGroupMembersHelper.tsx +++ b/ts/components/leftPane/LeftPaneChooseGroupMembersHelper.tsx @@ -9,7 +9,10 @@ import { ConversationType } from '../../state/ducks/conversations'; import { ContactCheckboxDisabledReason } from '../conversationList/ContactCheckbox'; import { ContactPills } from '../ContactPills'; import { ContactPill } from '../ContactPill'; -import { Alert } from '../Alert'; +import { + AddGroupMemberErrorDialog, + AddGroupMemberErrorDialogMode, +} from '../AddGroupMemberErrorDialog'; import { Button } from '../Button'; import { LocalizerType } from '../../types/Util'; import { @@ -111,35 +114,34 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper< ) => unknown; removeSelectedContact: (conversationId: string) => unknown; }>): ReactChild { - let modalDetails: - | undefined - | { title: string; body: string; onClose: () => void }; + let modalNode: undefined | ReactChild; if (this.isShowingMaximumGroupSizeModal) { - modalDetails = { - title: i18n('chooseGroupMembers__maximum-group-size__title'), - body: i18n('chooseGroupMembers__maximum-group-size__body', [ - this.getMaximumNumberOfContacts().toString(), - ]), - onClose: closeMaximumGroupSizeModal, - }; + modalNode = ( + + ); } else if (this.isShowingRecommendedGroupSizeModal) { - modalDetails = { - title: i18n( - 'chooseGroupMembers__maximum-recommended-group-size__title' - ), - body: i18n('chooseGroupMembers__maximum-recommended-group-size__body', [ - this.getRecommendedMaximumNumberOfContacts().toString(), - ]), - onClose: closeRecommendedGroupSizeModal, - }; + modalNode = ( + + ); } else if (this.cantAddContactForModal) { - modalDetails = { - title: i18n('chooseGroupMembers__cant-add-member__title'), - body: i18n('chooseGroupMembers__cant-add-member__body', [ - this.cantAddContactForModal.title, - ]), - onClose: closeCantAddContactToGroupModal, - }; + modalNode = ( + + ); } return ( @@ -149,7 +151,7 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper< type="text" ref={focusRef} className="module-left-pane__compose-search-form__input" - placeholder={i18n('newConversationContactSearchPlaceholder')} + placeholder={i18n('contactSearchPlaceholder')} dir="auto" value={this.searchTerm} onChange={onChangeComposeSearchTerm} @@ -178,18 +180,11 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper< {this.getRowCount() ? null : (
- {i18n('newConversationNoContacts')} + {i18n('noContactsFound')}
)} - {modalDetails && ( - - )} + {modalNode} ); } diff --git a/ts/components/leftPane/LeftPaneComposeHelper.tsx b/ts/components/leftPane/LeftPaneComposeHelper.tsx index f232d65e08..0351fdb18b 100644 --- a/ts/components/leftPane/LeftPaneComposeHelper.tsx +++ b/ts/components/leftPane/LeftPaneComposeHelper.tsx @@ -90,7 +90,7 @@ export class LeftPaneComposeHelper extends LeftPaneHelper< type="text" ref={focusRef} className="module-left-pane__compose-search-form__input" - placeholder={i18n('newConversationContactSearchPlaceholder')} + placeholder={i18n('contactSearchPlaceholder')} dir="auto" value={this.searchTerm} onChange={onChangeComposeSearchTerm} @@ -99,7 +99,7 @@ export class LeftPaneComposeHelper extends LeftPaneHelper< {this.getRowCount() ? null : (
- {i18n('newConversationNoContacts')} + {i18n('noContactsFound')}
)} diff --git a/ts/groups.ts b/ts/groups.ts index 7b5f8f3ca1..7495f6b315 100644 --- a/ts/groups.ts +++ b/ts/groups.ts @@ -550,6 +550,148 @@ function buildGroupProto( return proto; } +export async function buildAddMembersChange( + conversation: Pick< + ConversationAttributesType, + 'id' | 'publicParams' | 'revision' | 'secretParams' + >, + conversationIds: ReadonlyArray +): Promise { + const MEMBER_ROLE_ENUM = window.textsecure.protobuf.Member.Role; + + const { id, publicParams, revision, secretParams } = conversation; + + const logId = `groupv2(${id})`; + + if (!publicParams) { + throw new Error( + `buildAddMembersChange/${logId}: attributes were missing publicParams!` + ); + } + if (!secretParams) { + throw new Error( + `buildAddMembersChange/${logId}: attributes were missing secretParams!` + ); + } + + const newGroupVersion = (revision || 0) + 1; + const serverPublicParamsBase64 = window.getServerPublicParams(); + const clientZkProfileCipher = getClientZkProfileOperations( + serverPublicParamsBase64 + ); + const clientZkGroupCipher = getClientZkGroupCipher(secretParams); + + const ourConversationId = window.ConversationController.getOurConversationIdOrThrow(); + const ourConversation = window.ConversationController.get(ourConversationId); + const ourUuid = ourConversation?.get('uuid'); + if (!ourUuid) { + throw new Error( + `buildAddMembersChange/${logId}: unable to find our own UUID!` + ); + } + const ourUuidCipherTextBuffer = encryptUuid(clientZkGroupCipher, ourUuid); + + const now = Date.now(); + + const addMembers: Array = []; + const addPendingMembers: Array = []; + + await Promise.all( + conversationIds.map(async conversationId => { + const contact = window.ConversationController.get(conversationId); + if (!contact) { + assert( + false, + `buildAddMembersChange/${logId}: missing local contact, skipping` + ); + return; + } + + const uuid = contact.get('uuid'); + if (!uuid) { + assert(false, `buildAddMembersChange/${logId}: missing UUID; skipping`); + return; + } + + // Refresh our local data to be sure + if ( + !contact.get('capabilities')?.gv2 || + !contact.get('profileKey') || + !contact.get('profileKeyCredential') + ) { + await contact.getProfiles(); + } + + if (!contact.get('capabilities')?.gv2) { + assert( + false, + `buildAddMembersChange/${logId}: member is missing GV2 capability; skipping` + ); + return; + } + + const profileKey = contact.get('profileKey'); + const profileKeyCredential = contact.get('profileKeyCredential'); + + if (!profileKey) { + assert( + false, + `buildAddMembersChange/${logId}: member is missing profile key; skipping` + ); + return; + } + + const member = new window.textsecure.protobuf.Member(); + member.userId = encryptUuid(clientZkGroupCipher, uuid); + member.role = MEMBER_ROLE_ENUM.DEFAULT; + member.joinedAtVersion = newGroupVersion; + + // This is inspired by [Android's equivalent code][0]. + // + // [0]: https://github.com/signalapp/Signal-Android/blob/2be306867539ab1526f0e49d1aa7bd61e783d23f/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations.java#L152-L174 + if (profileKey && profileKeyCredential) { + member.presentation = createProfileKeyCredentialPresentation( + clientZkProfileCipher, + profileKeyCredential, + secretParams + ); + + const addMemberAction = new window.textsecure.protobuf.GroupChange.Actions.AddMemberAction(); + addMemberAction.added = member; + addMemberAction.joinFromInviteLink = false; + + addMembers.push(addMemberAction); + } else { + const memberPendingProfileKey = new window.textsecure.protobuf.MemberPendingProfileKey(); + memberPendingProfileKey.member = member; + memberPendingProfileKey.addedByUserId = ourUuidCipherTextBuffer; + memberPendingProfileKey.timestamp = now; + + const addPendingMemberAction = new window.textsecure.protobuf.GroupChange.Actions.AddMemberPendingProfileKeyAction(); + addPendingMemberAction.added = memberPendingProfileKey; + + addPendingMembers.push(addPendingMemberAction); + } + }) + ); + + const actions = new window.textsecure.protobuf.GroupChange.Actions(); + if (!addMembers.length && !addPendingMembers.length) { + // This shouldn't happen. When these actions are passed to `modifyGroupV2`, a warning + // will be logged. + return undefined; + } + if (addMembers.length) { + actions.addMembers = addMembers; + } + if (addPendingMembers.length) { + actions.addPendingMembers = addPendingMembers; + } + actions.version = newGroupVersion; + + return actions; +} + export async function buildUpdateAttributesChange( conversation: Pick< ConversationAttributesType, diff --git a/ts/groups/toggleSelectedContactForGroupAddition.ts b/ts/groups/toggleSelectedContactForGroupAddition.ts new file mode 100644 index 0000000000..4fd8349d2b --- /dev/null +++ b/ts/groups/toggleSelectedContactForGroupAddition.ts @@ -0,0 +1,68 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { without } from 'lodash'; + +export enum OneTimeModalState { + NeverShown, + Showing, + Shown, +} + +export function toggleSelectedContactForGroupAddition( + conversationId: string, + currentState: Readonly<{ + maxGroupSize: number; + maxRecommendedGroupSize: number; + maximumGroupSizeModalState: OneTimeModalState; + numberOfContactsAlreadyInGroup: number; + recommendedGroupSizeModalState: OneTimeModalState; + selectedConversationIds: Array; + }> +): { + maximumGroupSizeModalState: OneTimeModalState; + recommendedGroupSizeModalState: OneTimeModalState; + selectedConversationIds: Array; +} { + const { + maxGroupSize, + maxRecommendedGroupSize, + numberOfContactsAlreadyInGroup, + selectedConversationIds: oldSelectedConversationIds, + } = currentState; + let { + maximumGroupSizeModalState, + recommendedGroupSizeModalState, + } = currentState; + + const selectedConversationIds = without( + oldSelectedConversationIds, + conversationId + ); + const shouldAdd = + selectedConversationIds.length === oldSelectedConversationIds.length; + if (shouldAdd) { + const newExpectedMemberCount = + selectedConversationIds.length + numberOfContactsAlreadyInGroup + 1; + if (newExpectedMemberCount <= maxGroupSize) { + if ( + newExpectedMemberCount === maxGroupSize && + maximumGroupSizeModalState === OneTimeModalState.NeverShown + ) { + maximumGroupSizeModalState = OneTimeModalState.Showing; + } else if ( + newExpectedMemberCount >= maxRecommendedGroupSize && + recommendedGroupSizeModalState === OneTimeModalState.NeverShown + ) { + recommendedGroupSizeModalState = OneTimeModalState.Showing; + } + selectedConversationIds.push(conversationId); + } + } + + return { + selectedConversationIds, + maximumGroupSizeModalState, + recommendedGroupSizeModalState, + }; +} diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index dd090c6da2..ade463f40d 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -1716,6 +1716,22 @@ export class ConversationModel extends window.Backbone.Model< }); } + async addMembersV2(conversationIds: ReadonlyArray): Promise { + await this.modifyGroupV2({ + name: 'addMembersV2', + createGroupChange: () => + window.Signal.Groups.buildAddMembersChange( + { + id: this.id, + publicParams: this.get('publicParams'), + revision: this.get('revision'), + secretParams: this.get('secretParams'), + }, + conversationIds + ), + }); + } + async updateGroupAttributesV2( attributes: Readonly<{ avatar?: undefined | ArrayBuffer; diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 03e5d5af35..6e356c56cc 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -35,6 +35,7 @@ import { getGroupSizeRecommendedLimit, getGroupSizeHardLimit, } from '../../groups/limits'; +import { toggleSelectedContactForGroupAddition } from '../../groups/toggleSelectedContactForGroupAddition'; // State @@ -2273,50 +2274,23 @@ export function reducer( return state; } - const { selectedConversationIds: oldSelectedConversationIds } = composer; - let { - maximumGroupSizeModalState, - recommendedGroupSizeModalState, - } = composer; - const { - conversationId, - maxGroupSize, - maxRecommendedGroupSize, - } = action.payload; - - const selectedConversationIds = without( - oldSelectedConversationIds, - conversationId - ); - const shouldAdd = - selectedConversationIds.length === oldSelectedConversationIds.length; - if (shouldAdd) { - // 1 for you, 1 for the new contact. - const newExpectedMemberCount = selectedConversationIds.length + 2; - if (newExpectedMemberCount > maxGroupSize) { - return state; - } - if ( - newExpectedMemberCount === maxGroupSize && - maximumGroupSizeModalState === OneTimeModalState.NeverShown - ) { - maximumGroupSizeModalState = OneTimeModalState.Showing; - } else if ( - newExpectedMemberCount >= maxRecommendedGroupSize && - recommendedGroupSizeModalState === OneTimeModalState.NeverShown - ) { - recommendedGroupSizeModalState = OneTimeModalState.Showing; - } - selectedConversationIds.push(conversationId); - } - return { ...state, composer: { ...composer, - maximumGroupSizeModalState, - recommendedGroupSizeModalState, - selectedConversationIds, + ...toggleSelectedContactForGroupAddition( + action.payload.conversationId, + { + maxGroupSize: action.payload.maxGroupSize, + maxRecommendedGroupSize: action.payload.maxRecommendedGroupSize, + maximumGroupSizeModalState: composer.maximumGroupSizeModalState, + // We say you're already in the group, even though it hasn't been created yet. + numberOfContactsAlreadyInGroup: 1, + recommendedGroupSizeModalState: + composer.recommendedGroupSizeModalState, + selectedConversationIds: composer.selectedConversationIds, + } + ), }, }; } diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index 7a2227e883..9966145942 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -4,7 +4,6 @@ import memoizee from 'memoizee'; import { fromPairs, isNumber, isString } from 'lodash'; import { createSelector } from 'reselect'; -import Fuse, { FuseOptions } from 'fuse.js'; import { StateType } from '../reducer'; import { @@ -29,6 +28,7 @@ import { PropsDataType as TimelinePropsType } from '../../components/conversatio import { TimelineItemType } from '../../components/conversation/TimelineItem'; import { assert } from '../../util/assert'; import { isConversationUnregistered } from '../../util/isConversationUnregistered'; +import { filterAndSortContacts } from '../../util/filterAndSortContacts'; import { getInteractionMode, @@ -342,14 +342,14 @@ export const getComposerContactSearchTerm = createSelector( ); /** - * This returns contacts for the composer, which isn't just your primary's system - * contacts. It may include false positives, which is better than missing contacts. + * This returns contacts for the composer and group members, which isn't just your primary + * system contacts. It may include false positives, which is better than missing contacts. * * Because it filters unregistered contacts and that's (partially) determined by the * current time, it's possible for this to return stale contacts that have unregistered * if no other conversations change. This should be a rare false positive. */ -const getContacts = createSelector( +export const getContacts = createSelector( getConversationLookup, (conversationLookup: ConversationLookupType): Array => Object.values(conversationLookup).filter( @@ -371,13 +371,6 @@ const getNoteToSelfTitle = createSelector(getIntl, (i18n: LocalizerType) => i18n('noteToSelf').toLowerCase() ); -const COMPOSE_CONTACTS_FUSE_OPTIONS: FuseOptions = { - // A small-but-nonzero threshold lets us match parts of E164s better, and makes the - // search a little more forgiving. - threshold: 0.05, - keys: ['title', 'name', 'e164'], -}; - export const getComposeContacts = createSelector( getNormalizedComposerContactSearchTerm, getContacts, @@ -389,55 +382,21 @@ export const getComposeContacts = createSelector( noteToSelf: ConversationType, noteToSelfTitle: string ): Array => { - let result: Array; - - if (searchTerm.length) { - const fuse = new Fuse( - contacts, - COMPOSE_CONTACTS_FUSE_OPTIONS - ); - result = fuse.search(searchTerm); - if (noteToSelfTitle.includes(searchTerm)) { - result.push(noteToSelf); - } - } else { - result = contacts.concat(); - result.sort((a, b) => collator.compare(a.title, b.title)); + const result: Array = filterAndSortContacts( + contacts, + searchTerm + ); + if (!searchTerm || noteToSelfTitle.includes(searchTerm)) { result.push(noteToSelf); } - return result; } ); -/* - * This returns contacts for the composer when you're picking new group members. It casts - * a wider net than `getContacts`. - */ -const getGroupContacts = createSelector( - getConversationLookup, - (conversationLookup): Array => - Object.values(conversationLookup).filter( - contact => - contact.type === 'direct' && - !contact.isMe && - !contact.isBlocked && - !isConversationUnregistered(contact) - ) -); - -export const getCandidateGroupContacts = createSelector( +export const getCandidateContactsForNewGroup = createSelector( + getContacts, getNormalizedComposerContactSearchTerm, - getGroupContacts, - (searchTerm, contacts): Array => { - if (searchTerm.length) { - return new Fuse( - contacts, - COMPOSE_CONTACTS_FUSE_OPTIONS - ).search(searchTerm); - } - return contacts.concat().sort((a, b) => collator.compare(a.title, b.title)); - } + filterAndSortContacts ); export const getCantAddContactForModal = createSelector( diff --git a/ts/state/smart/ConversationDetails.tsx b/ts/state/smart/ConversationDetails.tsx index 39cf4fb137..1437689f64 100644 --- a/ts/state/smart/ConversationDetails.tsx +++ b/ts/state/smart/ConversationDetails.tsx @@ -8,11 +8,15 @@ import { ConversationDetails, StateProps, } from '../../components/conversation/conversation-details/ConversationDetails'; -import { getConversationSelector } from '../selectors/conversations'; +import { + getContacts, + getConversationSelector, +} from '../selectors/conversations'; import { getIntl } from '../selectors/user'; import { MediaItemType } from '../../components/LightboxGallery'; export type SmartConversationDetailsProps = { + addMembers: (conversationIds: ReadonlyArray) => Promise; conversationId: string; hasGroupLink: boolean; loadRecentMediaItems: (limit: number) => void; @@ -46,10 +50,12 @@ const mapStateToProps = ( ? conversation.canEditGroupInfo : false; const isAdmin = Boolean(conversation?.areWeAdmin); + const candidateContactsToAdd = getContacts(state); return { ...props, canEditGroupInfo, + candidateContactsToAdd, conversation, i18n: getIntl(state), isAdmin, diff --git a/ts/state/smart/LeftPane.tsx b/ts/state/smart/LeftPane.tsx index 28e513a3f9..d39abba6ba 100644 --- a/ts/state/smart/LeftPane.tsx +++ b/ts/state/smart/LeftPane.tsx @@ -16,7 +16,7 @@ import { ComposerStep, OneTimeModalState } from '../ducks/conversations'; import { getSearchResults, isSearching } from '../selectors/search'; import { getIntl, getRegionCode } from '../selectors/user'; import { - getCandidateGroupContacts, + getCandidateContactsForNewGroup, getCantAddContactForModal, getComposeContacts, getComposeGroupAvatar, @@ -102,7 +102,7 @@ const getModeSpecificProps = ( case ComposerStep.ChooseGroupMembers: return { mode: LeftPaneMode.ChooseGroupMembers, - candidateContacts: getCandidateGroupContacts(state), + candidateContacts: getCandidateContactsForNewGroup(state), cantAddContactForModal: getCantAddContactForModal(state), isShowingRecommendedGroupSizeModal: getRecommendedGroupSizeModalState(state) === diff --git a/ts/test-both/helpers/getDefaultConversation.ts b/ts/test-both/helpers/getDefaultConversation.ts index 47e88b7d51..ee12d15eb2 100644 --- a/ts/test-both/helpers/getDefaultConversation.ts +++ b/ts/test-both/helpers/getDefaultConversation.ts @@ -1,7 +1,8 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { v4 as generateUuid } from 'uuid'; +import { sample } from 'lodash'; import { ConversationType } from '../../state/ducks/conversations'; const FIRST_NAMES = [ @@ -310,21 +311,23 @@ const LAST_NAMES = [ 'Jimenez', ]; -export function getRandomTitle(): string { - const firstName = FIRST_NAMES[Math.floor(Math.random() * FIRST_NAMES.length)]; - const lastName = LAST_NAMES[Math.floor(Math.random() * LAST_NAMES.length)]; - return `${firstName} ${lastName}`; -} +const getFirstName = (): string => sample(FIRST_NAMES) || 'Test'; +const getLastName = (): string => sample(LAST_NAMES) || 'Test'; export function getDefaultConversation( - overrideProps: Partial + overrideProps: Partial = {} ): ConversationType { + const firstName = getFirstName(); + const lastName = getLastName(); + return { id: generateUuid(), + isGroupV2Capable: true, lastUpdated: Date.now(), markedUnread: Boolean(overrideProps.markedUnread), e164: '+1300555000', - title: getRandomTitle(), + firstName, + title: `${firstName} ${lastName}`, type: 'direct' as const, uuid: generateUuid(), ...overrideProps, diff --git a/ts/test-both/state/selectors/conversations_test.ts b/ts/test-both/state/selectors/conversations_test.ts index 06328ca12f..5360b6f99f 100644 --- a/ts/test-both/state/selectors/conversations_test.ts +++ b/ts/test-both/state/selectors/conversations_test.ts @@ -13,7 +13,7 @@ import { import { _getConversationComparator, _getLeftPaneLists, - getCandidateGroupContacts, + getCandidateContactsForNewGroup, getCantAddContactForModal, getComposeContacts, getComposeGroupAvatar, @@ -555,7 +555,7 @@ describe('both/state/selectors/conversations', () => { }); }); - describe('#getCandidateGroupContacts', () => { + describe('#getCandidateContactsForNewGroup', () => { const getRootState = (contactSearchTerm = ''): StateType => { const rootState = getEmptyRootState(); return { @@ -574,7 +574,7 @@ describe('both/state/selectors/conversations', () => { }, 'convo-2': { ...getDefaultConversation('convo-2'), - title: 'B. Sorted Second', + title: 'Should be dropped (has no name)', }, 'convo-3': { ...getDefaultConversation('convo-3'), @@ -584,19 +584,17 @@ describe('both/state/selectors/conversations', () => { 'convo-4': { ...getDefaultConversation('convo-4'), isBlocked: true, + name: 'My Name', title: 'Should Be Dropped (blocked)', }, 'convo-5': { ...getDefaultConversation('convo-5'), discoveredUnregisteredAt: new Date(1999, 3, 20).getTime(), + name: 'My Name', title: 'Should Be Dropped (unregistered)', }, 'convo-6': { ...getDefaultConversation('convo-6'), - title: 'D. Sorted Last', - }, - 'convo-7': { - ...getDefaultConversation('convo-7'), discoveredUnregisteredAt: Date.now(), name: 'In System Contacts (and only recently unregistered)', title: 'C. Sorted Third', @@ -623,18 +621,18 @@ describe('both/state/selectors/conversations', () => { it('returns sorted contacts when there is no search term', () => { const state = getRootState(); - const result = getCandidateGroupContacts(state); + const result = getCandidateContactsForNewGroup(state); const ids = result.map(contact => contact.id); - assert.deepEqual(ids, ['convo-1', 'convo-2', 'convo-7', 'convo-6']); + assert.deepEqual(ids, ['convo-1', 'convo-6']); }); it('can search for contacts', () => { const state = getRootState('system contacts'); - const result = getCandidateGroupContacts(state); + const result = getCandidateContactsForNewGroup(state); const ids = result.map(contact => contact.id); - assert.deepEqual(ids, ['convo-1', 'convo-7']); + assert.deepEqual(ids, ['convo-1', 'convo-6']); }); }); diff --git a/ts/test-both/util/filterAndSortContacts_test.ts b/ts/test-both/util/filterAndSortContacts_test.ts new file mode 100644 index 0000000000..870d773567 --- /dev/null +++ b/ts/test-both/util/filterAndSortContacts_test.ts @@ -0,0 +1,41 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import { getDefaultConversation } from '../helpers/getDefaultConversation'; + +import { filterAndSortContacts } from '../../util/filterAndSortContacts'; + +describe('filterAndSortContacts', () => { + const conversations = [ + getDefaultConversation({ + title: '+16505551234', + firstName: undefined, + profileName: undefined, + }), + getDefaultConversation({ title: 'Carlos Santana' }), + getDefaultConversation({ title: 'Aaron Aardvark' }), + getDefaultConversation({ title: 'Belinda Beetle' }), + getDefaultConversation({ title: 'Belinda Zephyr' }), + ]; + + it('without a search term, sorts conversations by title', () => { + const titles = filterAndSortContacts(conversations, '').map( + contact => contact.title + ); + assert.deepEqual(titles, [ + '+16505551234', + 'Aaron Aardvark', + 'Belinda Beetle', + 'Belinda Zephyr', + 'Carlos Santana', + ]); + }); + + it('filters conversations a search terms', () => { + const titles = filterAndSortContacts(conversations, 'belind').map( + contact => contact.title + ); + assert.deepEqual(titles, ['Belinda Beetle', 'Belinda Zephyr']); + }); +}); diff --git a/ts/test-electron/state/ducks/conversations_test.ts b/ts/test-electron/state/ducks/conversations_test.ts index 1543cfbad0..dc8a81c086 100644 --- a/ts/test-electron/state/ducks/conversations_test.ts +++ b/ts/test-electron/state/ducks/conversations_test.ts @@ -1904,7 +1904,7 @@ describe('both/state/ducks/conversations', () => { const action = getAction(uuid(), state); const result = reducer(state, action); - assert.strictEqual(result, state); + assert.deepEqual(result, state); }); it('defaults the maximum group size to 1001 if the recommended maximum is smaller', () => { diff --git a/ts/util/filterAndSortContacts.ts b/ts/util/filterAndSortContacts.ts new file mode 100644 index 0000000000..ddf398b0cb --- /dev/null +++ b/ts/util/filterAndSortContacts.ts @@ -0,0 +1,27 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import Fuse, { FuseOptions } from 'fuse.js'; + +import { ConversationType } from '../state/ducks/conversations'; + +const FUSE_OPTIONS: FuseOptions = { + // A small-but-nonzero threshold lets us match parts of E164s better, and makes the + // search a little more forgiving. + threshold: 0.05, + keys: ['title', 'name', 'e164'], +}; + +const collator = new Intl.Collator(); + +export function filterAndSortContacts( + contacts: ReadonlyArray, + searchTerm: string +): Array { + if (searchTerm.length) { + return new Fuse(contacts, FUSE_OPTIONS).search( + searchTerm + ); + } + return contacts.concat().sort((a, b) => collator.compare(a.title, b.title)); +} diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 8ac070eee2..cd58f5419c 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -15077,11 +15077,20 @@ "updated": "2019-07-31T00:19:18.696Z", "reasonDetail": "Timeline needs to interact with its child List directly" }, + { + "rule": "React-useRef", + "path": "ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.js", + "line": " const inputRef = react_1.useRef(null);", + "lineNumber": 41, + "reasonCategory": "usageTrusted", + "updated": "2021-03-11T20:49:17.292Z", + "reasonDetail": "Used to focus an input." + }, { "rule": "React-useRef", "path": "ts/components/conversation/conversation-details/EditConversationAttributesModal.js", "line": " const startingTitleRef = react_1.useRef(externalTitle);", - "lineNumber": 42, + "lineNumber": 37, "reasonCategory": "usageTrusted", "updated": "2021-03-05T22:52:40.572Z", "reasonDetail": "Doesn't interact with the DOM." @@ -15090,7 +15099,7 @@ "rule": "React-useRef", "path": "ts/components/conversation/conversation-details/EditConversationAttributesModal.js", "line": " const startingAvatarPathRef = react_1.useRef(externalAvatarPath);", - "lineNumber": 43, + "lineNumber": 38, "reasonCategory": "usageTrusted", "updated": "2021-03-05T22:52:40.572Z", "reasonDetail": "Doesn't interact with the DOM." diff --git a/ts/util/makeLookup.ts b/ts/util/makeLookup.ts index a76b2d89fd..264b578c67 100644 --- a/ts/util/makeLookup.ts +++ b/ts/util/makeLookup.ts @@ -1,8 +1,8 @@ -// Copyright 2019-2020 Signal Messenger, LLC +// Copyright 2019-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only export function makeLookup( - items: Array, + items: ReadonlyArray, key: keyof T ): Record { return (items || []).reduce((lookup, item) => { diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index 555e3c1f8c..6587ae6474 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -2887,6 +2887,7 @@ Whisper.ConversationView = Whisper.View.extend({ ACCESS_ENUM.UNSATISFIABLE; const props = { + addMembers: conversation.addMembersV2.bind(conversation), conversationId: conversation.get('id'), hasGroupLink, loadRecentMediaItems: this.loadRecentMediaItems.bind(this), From f7d4f84736c81367cef32fdfdb5c90cc19c01030 Mon Sep 17 00:00:00 2001 From: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com> Date: Thu, 11 Mar 2021 16:59:10 -0600 Subject: [PATCH 064/181] Enable New Group settings for everyone; remove desktop.gv2Admin feature flag --- ts/RemoteConfig.ts | 1 - ts/components/conversation/ConversationHeader.tsx | 9 ++------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/ts/RemoteConfig.ts b/ts/RemoteConfig.ts index 1fc29b3beb..09853316c4 100644 --- a/ts/RemoteConfig.ts +++ b/ts/RemoteConfig.ts @@ -10,7 +10,6 @@ export type ConfigKeyType = | 'desktop.disableGV1' | 'desktop.groupCalling' | 'desktop.gv2' - | 'desktop.gv2Admin' | 'desktop.mandatoryProfileSharing' | 'desktop.messageRequests' | 'desktop.storage' diff --git a/ts/components/conversation/ConversationHeader.tsx b/ts/components/conversation/ConversationHeader.tsx index 10b11e53df..92a7a33ff7 100644 --- a/ts/components/conversation/ConversationHeader.tsx +++ b/ts/components/conversation/ConversationHeader.tsx @@ -411,10 +411,7 @@ export class ConversationHeader extends React.Component { isMissingMandatoryProfileSharing ); - const hasGV2AdminEnabled = - isGroup && - groupVersion === 2 && - window.Signal.RemoteConfig.isEnabled('desktop.gv2Admin'); + const hasGV2AdminEnabled = isGroup && groupVersion === 2; return ( @@ -522,9 +519,7 @@ export class ConversationHeader extends React.Component { }; break; case 'group': { - const hasGV2AdminEnabled = - groupVersion === 2 && - window.Signal.RemoteConfig.isEnabled('desktop.gv2Admin'); + const hasGV2AdminEnabled = groupVersion === 2; onClick = hasGV2AdminEnabled ? () => { onShowConversationDetails(); From 053c18f29537b6d1f08f3f2102a841f6b47429e3 Mon Sep 17 00:00:00 2001 From: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com> Date: Thu, 11 Mar 2021 16:59:53 -0600 Subject: [PATCH 065/181] Increase font sizes for body 2, subtitle, and caption --- stylesheets/_mixins.scss | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/stylesheets/_mixins.scss b/stylesheets/_mixins.scss index 63e414d323..5e1d3775a2 100644 --- a/stylesheets/_mixins.scss +++ b/stylesheets/_mixins.scss @@ -41,9 +41,9 @@ @mixin font-body-2 { font-family: $inter; - font-size: 12px; - line-height: 16px; - letter-spacing: 0; + font-size: 13px; + line-height: 18px; + letter-spacing: -0.03px; } @mixin font-body-2-bold { @include font-body-2; @@ -62,15 +62,15 @@ @mixin font-subtitle { font-family: $inter; font-size: 11px; - line-height: 14px; - letter-spacing: 0.06px; + line-height: 16px; + letter-spacing: 0; } @mixin font-caption { font-family: $inter; - font-size: 10px; - line-height: 12px; - letter-spacing: 0.1px; + font-size: 11px; + line-height: 14px; + letter-spacing: 0.06px; } @mixin font-caption-bold { @include font-caption; From 45dbe4d145ad4c31efc6f8054591c2572631adc3 Mon Sep 17 00:00:00 2001 From: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com> Date: Thu, 11 Mar 2021 17:33:12 -0600 Subject: [PATCH 066/181] In left pane and "add group member", sort E164-only contacts to the bottom --- .../util/filterAndSortContacts_test.ts | 39 ++++++++++++++----- ts/util/filterAndSortContacts.ts | 31 ++++++++++++++- 2 files changed, 59 insertions(+), 11 deletions(-) diff --git a/ts/test-both/util/filterAndSortContacts_test.ts b/ts/test-both/util/filterAndSortContacts_test.ts index 870d773567..dbd7bcb208 100644 --- a/ts/test-both/util/filterAndSortContacts_test.ts +++ b/ts/test-both/util/filterAndSortContacts_test.ts @@ -10,32 +10,53 @@ describe('filterAndSortContacts', () => { const conversations = [ getDefaultConversation({ title: '+16505551234', - firstName: undefined, + e164: '+16505551234', + name: undefined, profileName: undefined, }), - getDefaultConversation({ title: 'Carlos Santana' }), - getDefaultConversation({ title: 'Aaron Aardvark' }), - getDefaultConversation({ title: 'Belinda Beetle' }), - getDefaultConversation({ title: 'Belinda Zephyr' }), + getDefaultConversation({ + name: 'Carlos Santana', + title: 'Carlos Santana', + e164: '+16505559876', + }), + getDefaultConversation({ + name: 'Aaron Aardvark', + title: 'Aaron Aardvark', + }), + getDefaultConversation({ + name: 'Belinda Beetle', + title: 'Belinda Beetle', + }), + getDefaultConversation({ + name: 'Belinda Zephyr', + title: 'Belinda Zephyr', + }), ]; - it('without a search term, sorts conversations by title', () => { + it('without a search term, sorts conversations by title (but puts no-name contacts at the bottom)', () => { const titles = filterAndSortContacts(conversations, '').map( contact => contact.title ); assert.deepEqual(titles, [ - '+16505551234', 'Aaron Aardvark', 'Belinda Beetle', 'Belinda Zephyr', 'Carlos Santana', + '+16505551234', ]); }); - it('filters conversations a search terms', () => { + it('can search for contacts by title', () => { const titles = filterAndSortContacts(conversations, 'belind').map( contact => contact.title ); - assert.deepEqual(titles, ['Belinda Beetle', 'Belinda Zephyr']); + assert.sameMembers(titles, ['Belinda Beetle', 'Belinda Zephyr']); + }); + + it('can search for contacts by phone number (and puts no-name contacts at the bottom)', () => { + const titles = filterAndSortContacts(conversations, '650555').map( + contact => contact.title + ); + assert.sameMembers(titles, ['Carlos Santana', '+16505551234']); }); }); diff --git a/ts/util/filterAndSortContacts.ts b/ts/util/filterAndSortContacts.ts index ddf398b0cb..c47cc3219b 100644 --- a/ts/util/filterAndSortContacts.ts +++ b/ts/util/filterAndSortContacts.ts @@ -9,7 +9,20 @@ const FUSE_OPTIONS: FuseOptions = { // A small-but-nonzero threshold lets us match parts of E164s better, and makes the // search a little more forgiving. threshold: 0.05, - keys: ['title', 'name', 'e164'], + keys: [ + { + name: 'title', + weight: 1, + }, + { + name: 'name', + weight: 1, + }, + { + name: 'e164', + weight: 0.5, + }, + ], }; const collator = new Intl.Collator(); @@ -23,5 +36,19 @@ export function filterAndSortContacts( searchTerm ); } - return contacts.concat().sort((a, b) => collator.compare(a.title, b.title)); + + return contacts.concat().sort((a, b) => { + const aHasName = hasName(a); + const bHasName = hasName(b); + + if (aHasName === bHasName) { + return collator.compare(a.title, b.title); + } + + return aHasName && !bHasName ? -1 : 1; + }); +} + +function hasName(contact: Readonly): boolean { + return Boolean(contact.name || contact.profileName); } From 58bdf362542394b9ad051604b5dbbf70e5564a89 Mon Sep 17 00:00:00 2001 From: Josh Perez <60019601+josh-signal@users.noreply.github.com> Date: Thu, 11 Mar 2021 19:40:59 -0500 Subject: [PATCH 067/181] Use classical rotation scheme for log rotation --- ts/logging/main_process_logging.ts | 2 +- ts/logging/set_up_renderer_logging.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ts/logging/main_process_logging.ts b/ts/logging/main_process_logging.ts index c2b6a6be0d..84c6d10e8e 100644 --- a/ts/logging/main_process_logging.ts +++ b/ts/logging/main_process_logging.ts @@ -66,7 +66,7 @@ export async function initialize(): Promise { const logFile = path.join(logPath, 'main.log'); const stream = createStream(logFile, { interval: '1d', - maxFiles: 3, + rotate: 3, }); const streams: pinoms.Streams = []; diff --git a/ts/logging/set_up_renderer_logging.ts b/ts/logging/set_up_renderer_logging.ts index 8584ef467e..c4d83508bc 100644 --- a/ts/logging/set_up_renderer_logging.ts +++ b/ts/logging/set_up_renderer_logging.ts @@ -109,7 +109,7 @@ export function initialize(): void { const logFile = path.join(basePath, 'logs', 'app.log'); const stream = createStream(logFile, { interval: '1d', - maxFiles: 3, + rotate: 3, }); globalLogger = pino( From 0fd0fac262b6176d4e94e18531faae2235b4794f Mon Sep 17 00:00:00 2001 From: Josh Perez <60019601+josh-signal@users.noreply.github.com> Date: Thu, 11 Mar 2021 20:56:01 -0500 Subject: [PATCH 068/181] Check for conversation conflicts before writing --- ts/RemoteConfig.ts | 2 +- ts/models/conversations.ts | 4 ++-- ts/services/storage.ts | 2 ++ ts/storage/isFeatureEnabled.ts | 2 +- .../components/leftPane/LeftPaneComposeHelper_test.ts | 6 +++--- 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/ts/RemoteConfig.ts b/ts/RemoteConfig.ts index 09853316c4..65fb26265b 100644 --- a/ts/RemoteConfig.ts +++ b/ts/RemoteConfig.ts @@ -13,7 +13,7 @@ export type ConfigKeyType = | 'desktop.mandatoryProfileSharing' | 'desktop.messageRequests' | 'desktop.storage' - | 'desktop.storageWrite2' + | 'desktop.storageWrite3' | 'global.groupsv2.maxGroupSize' | 'global.groupsv2.groupSizeHardLimit'; type ConfigValueType = { diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index ade463f40d..c14452a796 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -4895,9 +4895,9 @@ export class ConversationModel extends window.Backbone.Model< // [X] archived // [X] markedUnread captureChange(logMessage: string): void { - if (!window.Signal.RemoteConfig.isEnabled('desktop.storageWrite2')) { + if (!window.Signal.RemoteConfig.isEnabled('desktop.storageWrite3')) { window.log.info( - 'conversation.captureChange: Returning early; desktop.storageWrite2 is falsey' + 'conversation.captureChange: Returning early; desktop.storageWrite3 is falsey' ); return; diff --git a/ts/services/storage.ts b/ts/services/storage.ts index 053aab740f..cd21ceb448 100644 --- a/ts/services/storage.ts +++ b/ts/services/storage.ts @@ -127,6 +127,8 @@ async function generateManifest( isNewManifest ); + await window.ConversationController.checkForConflicts(); + const ITEM_TYPE = window.textsecure.protobuf.ManifestRecord.Identifier.Type; const conversationsToUpdate = []; diff --git a/ts/storage/isFeatureEnabled.ts b/ts/storage/isFeatureEnabled.ts index 7cc7b12936..0874e84a05 100644 --- a/ts/storage/isFeatureEnabled.ts +++ b/ts/storage/isFeatureEnabled.ts @@ -8,5 +8,5 @@ function isStorageFeatureEnabled(): boolean { } export function isStorageWriteFeatureEnabled(): boolean { - return isStorageFeatureEnabled() && isEnabled('desktop.storageWrite2'); + return isStorageFeatureEnabled() && isEnabled('desktop.storageWrite3'); } diff --git a/ts/test-node/components/leftPane/LeftPaneComposeHelper_test.ts b/ts/test-node/components/leftPane/LeftPaneComposeHelper_test.ts index a17d3f9305..342764cf1f 100644 --- a/ts/test-node/components/leftPane/LeftPaneComposeHelper_test.ts +++ b/ts/test-node/components/leftPane/LeftPaneComposeHelper_test.ts @@ -27,7 +27,7 @@ describe('LeftPaneComposeHelper', () => { .stub(remoteConfig, 'isEnabled') .withArgs('desktop.storage') .returns(true) - .withArgs('desktop.storageWrite2') + .withArgs('desktop.storageWrite3') .returns(true); }); @@ -143,7 +143,7 @@ describe('LeftPaneComposeHelper', () => { remoteConfigStub .withArgs('desktop.storage') .returns(false) - .withArgs('desktop.storageWrite2') + .withArgs('desktop.storageWrite3') .returns(false); assert.isUndefined( @@ -157,7 +157,7 @@ describe('LeftPaneComposeHelper', () => { remoteConfigStub .withArgs('desktop.storage') .returns(true) - .withArgs('desktop.storageWrite2') + .withArgs('desktop.storageWrite3') .returns(false); assert.isUndefined( From 8dc8a64229b935777f203f22d769f9dd2a4a3568 Mon Sep 17 00:00:00 2001 From: Josh Perez <60019601+josh-signal@users.noreply.github.com> Date: Fri, 12 Mar 2021 12:38:43 -0500 Subject: [PATCH 069/181] Storage sync before writing --- ts/services/storage.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/ts/services/storage.ts b/ts/services/storage.ts index cd21ceb448..ee9e47de46 100644 --- a/ts/services/storage.ts +++ b/ts/services/storage.ts @@ -925,7 +925,7 @@ async function sync(): Promise { const hasConflicts = await processManifest(manifest); if (hasConflicts) { - await upload(); + await upload(true); } // We now know that we've successfully completed a storage service fetch @@ -947,7 +947,7 @@ async function sync(): Promise { window.log.info('storageService.sync: complete'); } -async function upload(): Promise { +async function upload(fromSync = false): Promise { if (!isStorageWriteFeatureEnabled()) { window.log.info( 'storageService.upload: Not starting because the feature is not enabled' @@ -971,6 +971,10 @@ async function upload(): Promise { return; } + if (!fromSync) { + await sync(); + } + const localManifestVersion = window.storage.get('manifestVersion') || 0; const version = Number(localManifestVersion) + 1; From d5f2492ce5f9b8e3205ea2d7f5c77e5c86e76541 Mon Sep 17 00:00:00 2001 From: Josh Perez <60019601+josh-signal@users.noreply.github.com> Date: Fri, 12 Mar 2021 13:06:31 -0500 Subject: [PATCH 070/181] Fine tune the conversation load experience --- ts/components/conversation/Timeline.tsx | 2 +- ts/views/conversation_view.ts | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/ts/components/conversation/Timeline.tsx b/ts/components/conversation/Timeline.tsx index f48fd0e95b..e7759d8b45 100644 --- a/ts/components/conversation/Timeline.tsx +++ b/ts/components/conversation/Timeline.tsx @@ -28,7 +28,7 @@ const NEAR_BOTTOM_THRESHOLD = 15; const AT_TOP_THRESHOLD = 10; const LOAD_MORE_THRESHOLD = 30; const SCROLL_DOWN_BUTTON_THRESHOLD = 8; -export const LOAD_COUNTDOWN = 2 * 1000; +export const LOAD_COUNTDOWN = 1; export type PropsDataType = { haveNewest: boolean; diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index 6587ae6474..b6af9c56d1 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -835,7 +835,7 @@ Whisper.ConversationView = Whisper.View.extend({ receivedAt, sentAt, messageId: oldestMessageId, - limit: 500, + limit: 30, MessageCollection: Whisper.MessageCollection, }); @@ -890,7 +890,7 @@ Whisper.ConversationView = Whisper.View.extend({ const models = await getNewerMessagesByConversation(this.model.id, { receivedAt, sentAt, - limit: 500, + limit: 30, MessageCollection: Whisper.MessageCollection, }); @@ -1073,14 +1073,14 @@ Whisper.ConversationView = Whisper.View.extend({ const receivedAt = message.get('received_at'); const sentAt = message.get('sent_at'); const older = await getOlderMessagesByConversation(conversationId, { - limit: 250, + limit: 30, receivedAt, sentAt, messageId, MessageCollection: Whisper.MessageCollection, }); const newer = await getNewerMessagesByConversation(conversationId, { - limit: 250, + limit: 30, receivedAt, sentAt, MessageCollection: Whisper.MessageCollection, @@ -1147,7 +1147,7 @@ Whisper.ConversationView = Whisper.View.extend({ } const messages = await getOlderMessagesByConversation(conversationId, { - limit: 50, + limit: 30, MessageCollection: Whisper.MessageCollection, }); @@ -1157,7 +1157,7 @@ Whisper.ConversationView = Whisper.View.extend({ setFocus && metrics.newest ? metrics.newest.id : undefined; // Because our `getOlderMessages` fetch above didn't specify a receivedAt, we got - // the most recent 50 messages in the conversation. If it has a conflict with + // the most recent 30 messages in the conversation. If it has a conflict with // metrics, fetched a bit before, that's likely a race condition. So we tell our // reducer to trust the message set we just fetched for determining if we have // the newest message loaded. From 746e6781aedddbc47cdfca241bcbf1d1886c004b Mon Sep 17 00:00:00 2001 From: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com> Date: Fri, 12 Mar 2021 16:04:56 -0600 Subject: [PATCH 071/181] Fix styling for left pane: muted conversations and message requests --- ts/components/conversationList/BaseConversationListItem.tsx | 2 +- ts/components/conversationList/ConversationListItem.tsx | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/ts/components/conversationList/BaseConversationListItem.tsx b/ts/components/conversationList/BaseConversationListItem.tsx index 37580dbaaf..aeaae639c3 100644 --- a/ts/components/conversationList/BaseConversationListItem.tsx +++ b/ts/components/conversationList/BaseConversationListItem.tsx @@ -18,7 +18,7 @@ const CONTENT_CLASS_NAME = `${BASE_CLASS_NAME}__content`; const HEADER_CLASS_NAME = `${CONTENT_CLASS_NAME}__header`; export const DATE_CLASS_NAME = `${HEADER_CLASS_NAME}__date`; const TIMESTAMP_CLASS_NAME = `${DATE_CLASS_NAME}__timestamp`; -export const MESSAGE_CLASS_NAME = `${CONTENT_CLASS_NAME}__message`; +const MESSAGE_CLASS_NAME = `${CONTENT_CLASS_NAME}__message`; export const MESSAGE_TEXT_CLASS_NAME = `${MESSAGE_CLASS_NAME}__text`; const CHECKBOX_CLASS_NAME = `${BASE_CLASS_NAME}__checkbox`; diff --git a/ts/components/conversationList/ConversationListItem.tsx b/ts/components/conversationList/ConversationListItem.tsx index 86ac06dfdf..c6dfdfd23b 100644 --- a/ts/components/conversationList/ConversationListItem.tsx +++ b/ts/components/conversationList/ConversationListItem.tsx @@ -11,7 +11,6 @@ import classNames from 'classnames'; import { BaseConversationListItem, - MESSAGE_CLASS_NAME, MESSAGE_TEXT_CLASS_NAME, } from './BaseConversationListItem'; import { MessageBody } from '../conversation/MessageBody'; @@ -123,10 +122,10 @@ export const ConversationListItem: FunctionComponent = React.memo( messageText = ( <> {muteExpiresAt && Date.now() < muteExpiresAt && ( - + )} {!acceptedMessageRequest ? ( - + {i18n('ConversationListItem--message-request')} ) : typingContact ? ( From 246ca8631953b64f083d779e7a16a6983703a301 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Fri, 12 Mar 2021 14:16:38 -0800 Subject: [PATCH 072/181] Chat Session Refreshed: Include schemaVersion to prevent later upgrade --- ts/models/conversations.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index c14452a796..b201f267fb 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -2357,6 +2357,7 @@ export class ConversationModel extends window.Backbone.Model< received_at_ms: timestamp, key_changed: keyChangedId, unread: 1, + schemaVersion: Message.VERSION_NEEDED_FOR_DISPLAY, // TODO: DESKTOP-722 // this type does not fully implement the interface it is expected to } as unknown) as typeof window.Whisper.MessageAttributesType; From 021b54cb75e526260af86924cde2b0d3b5ae653d Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Fri, 12 Mar 2021 14:18:24 -0800 Subject: [PATCH 073/181] Fix sqlite3 threadpool exhaustion with db.serialize() on startup --- ts/sql/Server.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 6b5442a6ae..165fdd7351 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -236,6 +236,9 @@ async function openDatabase(filePath: string): Promise { }; instance = new sql.Database(filePath, callback); + + // See: https://github.com/mapbox/node-sqlite3/issues/1395 + instance.serialize(); }); } From 6a72879c8793120f4b52a60bdab0ed01541ec48c Mon Sep 17 00:00:00 2001 From: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com> Date: Fri, 12 Mar 2021 17:16:22 -0600 Subject: [PATCH 074/181] Group settings screen: fix focus styling for panel rows --- stylesheets/_modules.scss | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index e7c46c16bb..7c77536eae 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -3231,6 +3231,8 @@ button.module-conversation-details__action-button { &-panel-row { &__root { align-items: center; + border-radius: 5px; + border: 2px solid transparent; display: flex; padding: 16px 24px; user-select: none; @@ -3239,7 +3241,6 @@ button.module-conversation-details__action-button { &--button { color: inherit; background: none; - border: none; } &:hover { @@ -3255,6 +3256,23 @@ button.module-conversation-details__action-button { opacity: 1; } } + + &:focus { + outline: none; + } + + @mixin keyboard-focus-state($color) { + &:focus { + border-color: $color; + } + } + + @include keyboard-mode { + @include keyboard-focus-state($ultramarine-ui-light); + } + @include dark-keyboard-mode { + @include keyboard-focus-state($ultramarine-ui-dark); + } } &__icon { From e09fb6cce4f0bc13e8bafc479680bd757acae052 Mon Sep 17 00:00:00 2001 From: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com> Date: Fri, 12 Mar 2021 17:31:47 -0600 Subject: [PATCH 075/181] Improve types in `ConversationView#showConversationDetails` --- .../ConversationDetails.stories.tsx | 6 ++++-- .../conversation-details/ConversationDetails.tsx | 2 +- ts/state/smart/ConversationDetails.tsx | 4 ++-- ts/views/conversation_view.ts | 10 ++++++---- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx b/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx index 806a2a1dcf..7d5327fc8d 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx @@ -1,4 +1,4 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; @@ -65,7 +65,9 @@ const createProps = (hasGroupLink = false): Props => ({ showGroupV2Permissions: action('showGroupV2Permissions'), showPendingInvites: action('showPendingInvites'), showLightboxForMedia: action('showLightboxForMedia'), - updateGroupAttributes: action('updateGroupAttributes'), + updateGroupAttributes: async () => { + action('updateGroupAttributes')(); + }, onBlockAndDelete: action('onBlockAndDelete'), onDelete: action('onDelete'), }); diff --git a/ts/components/conversation/conversation-details/ConversationDetails.tsx b/ts/components/conversation/conversation-details/ConversationDetails.tsx index 73afc69428..6acc62bee2 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.tsx @@ -54,7 +54,7 @@ export type StateProps = { avatar?: undefined | ArrayBuffer; title?: string; }> - ) => void; + ) => Promise; onBlockAndDelete: () => void; onDelete: () => void; }; diff --git a/ts/state/smart/ConversationDetails.tsx b/ts/state/smart/ConversationDetails.tsx index 1437689f64..e04bbbb369 100644 --- a/ts/state/smart/ConversationDetails.tsx +++ b/ts/state/smart/ConversationDetails.tsx @@ -1,4 +1,4 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { connect } from 'react-redux'; @@ -35,7 +35,7 @@ export type SmartConversationDetailsProps = { avatar?: undefined | ArrayBuffer; title?: string; }> - ) => void; + ) => Promise; onBlockAndDelete: () => void; onDelete: () => void; }; diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index b6af9c56d1..a1351270d6 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -7,6 +7,7 @@ import { AttachmentType } from '../types/Attachment'; import { GroupV2PendingMemberType } from '../model-types.d'; import { MediaItemType } from '../components/LightboxGallery'; import { MessageType } from '../state/ducks/conversations'; +import { ConversationModel } from '../models/conversations'; type GetLinkPreviewImageResult = { data: ArrayBuffer; @@ -2851,7 +2852,7 @@ Whisper.ConversationView = Whisper.View.extend({ }, showConversationDetails() { - const conversation = this.model; + const conversation: ConversationModel = this.model; const messageRequestEnum = window.textsecure.protobuf.SyncMessage.MessageRequestResponse.Type; @@ -2881,10 +2882,11 @@ Whisper.ConversationView = Whisper.View.extend({ const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired; - const hasGroupLink = + const hasGroupLink = Boolean( conversation.get('groupInviteLinkPassword') && - conversation.get('accessControl')?.addFromInviteLink !== - ACCESS_ENUM.UNSATISFIABLE; + conversation.get('accessControl')?.addFromInviteLink !== + ACCESS_ENUM.UNSATISFIABLE + ); const props = { addMembers: conversation.addMembersV2.bind(conversation), From 55f0beaa6d00a2f04ad50e5edb8d771e52678dc7 Mon Sep 17 00:00:00 2001 From: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com> Date: Fri, 12 Mar 2021 17:49:54 -0600 Subject: [PATCH 076/181] Speed up `yarn dev` TypeScript transpilation --- Gruntfile.js | 4 ---- package.json | 1 + 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/Gruntfile.js b/Gruntfile.js index 6cec5e6ecc..407e14a4dc 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -116,10 +116,6 @@ module.exports = grunt => { files: ['./stylesheets/*.scss', './stylesheets/**/*.scss'], tasks: ['sass'], }, - transpile: { - files: ['./ts/**/*.ts', './ts/**/*.tsx'], - tasks: ['exec:transpile'], - }, }, exec: { 'tx-pull-new': { diff --git a/package.json b/package.json index ba2f8d423d..f39325abb1 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "ready": "npm-run-all --print-label clean-transpile grunt --parallel lint lint-deps test-node test-electron", "dev": "run-p --print-label dev:*", "dev:grunt": "yarn grunt dev", + "dev:transpile": "yarn run transpile --watch --preserveWatchOutput", "dev:webpack": "cross-env NODE_ENV=development webpack-dev-server --hot", "dev:typed-scss": "yarn build:typed-scss -w", "dev:storybook": "cross-env SIGNAL_ENV=storybook start-storybook -p 6006 -s ./", From 62e04a1bbdd30d2d6a1b66bf84995e95aeef84bb Mon Sep 17 00:00:00 2001 From: Josh Perez <60019601+josh-signal@users.noreply.github.com> Date: Fri, 12 Mar 2021 20:22:36 -0500 Subject: [PATCH 077/181] Fix for unread syncs and ooo reactions --- js/read_syncs.js | 26 +++- ts/background.ts | 21 +++- ts/models/messages.ts | 197 +++++++++++++++++-------------- ts/textsecure/MessageReceiver.ts | 2 +- ts/util/StartupQueue.ts | 30 +++++ ts/util/index.ts | 2 + ts/window.d.ts | 2 + 7 files changed, 178 insertions(+), 102 deletions(-) create mode 100644 ts/util/StartupQueue.ts diff --git a/js/read_syncs.js b/js/read_syncs.js index 9b6f05eef7..c90063c652 100644 --- a/js/read_syncs.js +++ b/js/read_syncs.js @@ -69,12 +69,26 @@ if (message.isUnread()) { await message.markRead(readAt, { skipSave: true }); - // onReadMessage may result in messages older than this one being - // marked read. We want those messages to have the same expire timer - // start time as this one, so we pass the readAt value through. - const conversation = message.getConversation(); - if (conversation) { - conversation.onReadMessage(message, readAt); + const updateConversation = () => { + // onReadMessage may result in messages older than this one being + // marked read. We want those messages to have the same expire timer + // start time as this one, so we pass the readAt value through. + const conversation = message.getConversation(); + if (conversation) { + conversation.onReadMessage(message, readAt); + } + }; + + if (window.startupProcessingQueue) { + const conversation = message.getConversation(); + if (conversation) { + window.startupProcessingQueue.add( + conversation.get('id'), + updateConversation + ); + } + } else { + updateConversation(); } } else { const now = Date.now(); diff --git a/ts/background.ts b/ts/background.ts index fefd8657bd..b827ab48df 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -9,6 +9,7 @@ import { isWindowDragElement } from './util/isWindowDragElement'; import { assert } from './util/assert'; export async function startApp(): Promise { + window.startupProcessingQueue = new window.Signal.Util.StartupQueue(); window.attachmentDownloadQueue = []; try { window.log.info('Initializing SQL in renderer'); @@ -2061,13 +2062,18 @@ export async function startApp(): Promise { clearInterval(interval!); interval = null; view.onEmpty(); + window.logAppLoadedEvent(); - window.log.info( - 'App loaded - messages:', - messageReceiver.getProcessedCount() - ); + if (messageReceiver) { + window.log.info( + 'App loaded - messages:', + messageReceiver.getProcessedCount() + ); + } + window.sqlInitializer.goBackToMainProcess(); window.Signal.Util.setBatchingStrategy(false); + const attachmentDownloadQueue = window.attachmentDownloadQueue || []; const THREE_DAYS_AGO = Date.now() - 3600 * 72 * 1000; const MAX_ATTACHMENT_MSGS_TO_DOWNLOAD = 250; @@ -2081,7 +2087,12 @@ export async function startApp(): Promise { attachmentsToDownload.length, attachmentDownloadQueue.length ); - window.attachmentDownloadQueue = undefined; + + if (window.startupProcessingQueue) { + window.startupProcessingQueue.flush(); + window.startupProcessingQueue = undefined; + } + const messagesWithDownloads = await Promise.all( attachmentsToDownload.map(message => message.queueAttachmentDownloads() diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 6a8eed3a3f..471b8626fe 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -3532,21 +3532,6 @@ export class MessageModel extends window.Backbone.Model { return; } - if (type === 'outgoing') { - const receipts = window.Whisper.DeliveryReceipts.forMessage( - conversation, - message - ); - receipts.forEach(receipt => - message.set({ - delivered: (message.get('delivered') || 0) + 1, - delivered_to: _.union(message.get('delivered_to') || [], [ - receipt.get('deliveredTo'), - ]), - }) - ); - } - attributes.active_at = now; conversation.set(attributes); @@ -3608,59 +3593,6 @@ export class MessageModel extends window.Backbone.Model { } } - if (type === 'incoming') { - const readSync = window.Whisper.ReadSyncs.forMessage(message); - if (readSync) { - if ( - message.get('expireTimer') && - !message.get('expirationStartTimestamp') - ) { - message.set( - 'expirationStartTimestamp', - Math.min(readSync.get('read_at'), Date.now()) - ); - } - } - if (readSync || message.isExpirationTimerUpdate()) { - message.unset('unread'); - // This is primarily to allow the conversation to mark all older - // messages as read, as is done when we receive a read sync for - // a message we already know about. - const c = message.getConversation(); - if (c) { - c.onReadMessage(message); - } - } else { - conversation.set({ - unreadCount: (conversation.get('unreadCount') || 0) + 1, - isArchived: false, - }); - } - } - - if (type === 'outgoing') { - const reads = window.Whisper.ReadReceipts.forMessage( - conversation, - message - ); - if (reads.length) { - const readBy = reads.map(receipt => receipt.get('reader')); - message.set({ - read_by: _.union(message.get('read_by'), readBy), - }); - } - - // A sync'd message to ourself is automatically considered read/delivered - if (conversation.isMe()) { - message.set({ - read_by: conversation.getRecipients(), - delivered_to: conversation.getRecipients(), - }); - } - - message.set({ recipients: conversation.getRecipients() }); - } - if (dataMessage.profileKey) { const profileKey = dataMessage.profileKey.toString('base64'); if ( @@ -3699,13 +3631,6 @@ export class MessageModel extends window.Backbone.Model { }); await message.eraseContents(); } - // Check for out-of-order view syncs - if (type === 'incoming' && message.isTapToView()) { - const viewSync = window.Whisper.ViewSyncs.forMessage(message); - if (viewSync) { - await message.markViewed({ fromSync: true }); - } - } } const conversationTimestamp = conversation.get('timestamp'); @@ -3750,26 +3675,13 @@ export class MessageModel extends window.Backbone.Model { } } - // Does this message have any pending, previously-received associated reactions? - const reactions = window.Whisper.Reactions.forMessage(message); - await Promise.all( - reactions.map(reaction => message.handleReaction(reaction, false)) - ); - - // Does this message have any pending, previously-received associated - // delete for everyone messages? - const deletes = window.Whisper.Deletes.forMessage(message); - await Promise.all( - deletes.map(del => - window.Signal.Util.deleteForEveryone(message, del, false) - ) - ); + await this.modifyTargetMessage(conversation, isGroupV2); window.log.info( 'handleDataMessage: Batching save for', message.get('sent_at') ); - this.saveAndNotify(conversation, confirm); + this.saveAndNotify(conversation, isGroupV2, confirm); } catch (error) { const errorForLog = error && error.stack ? error.stack : error; window.log.error( @@ -3785,6 +3697,7 @@ export class MessageModel extends window.Backbone.Model { async saveAndNotify( conversation: ConversationModel, + isGroupV2: boolean, confirm: () => void ): Promise { await window.Signal.Util.saveNewMessageBatcher.add(this.attributes); @@ -3793,6 +3706,8 @@ export class MessageModel extends window.Backbone.Model { conversation.trigger('newmessage', this); + await this.modifyTargetMessage(conversation, isGroupV2); + if (this.get('unread')) { await conversation.notify(this); } @@ -3806,6 +3721,108 @@ export class MessageModel extends window.Backbone.Model { confirm(); } + async modifyTargetMessage( + conversation: ConversationModel, + isGroupV2: boolean + ): Promise { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const message = this; + const type = message.get('type'); + + if (type === 'outgoing') { + const receipts = window.Whisper.DeliveryReceipts.forMessage( + conversation, + message + ); + receipts.forEach(receipt => + message.set({ + delivered: (message.get('delivered') || 0) + 1, + delivered_to: _.union(message.get('delivered_to') || [], [ + receipt.get('deliveredTo'), + ]), + }) + ); + } + + if (!isGroupV2) { + if (type === 'incoming') { + const readSync = window.Whisper.ReadSyncs.forMessage(message); + if (readSync) { + if ( + message.get('expireTimer') && + !message.get('expirationStartTimestamp') + ) { + message.set( + 'expirationStartTimestamp', + Math.min(readSync.get('read_at'), Date.now()) + ); + } + } + if (readSync || message.isExpirationTimerUpdate()) { + message.unset('unread'); + // This is primarily to allow the conversation to mark all older + // messages as read, as is done when we receive a read sync for + // a message we already know about. + const c = message.getConversation(); + if (c) { + c.onReadMessage(message); + } + } else { + conversation.set({ + unreadCount: (conversation.get('unreadCount') || 0) + 1, + isArchived: false, + }); + } + } + + if (type === 'outgoing') { + const reads = window.Whisper.ReadReceipts.forMessage( + conversation, + message + ); + if (reads.length) { + const readBy = reads.map(receipt => receipt.get('reader')); + message.set({ + read_by: _.union(message.get('read_by'), readBy), + }); + } + + // A sync'd message to ourself is automatically considered read/delivered + if (conversation.isMe()) { + message.set({ + read_by: conversation.getRecipients(), + delivered_to: conversation.getRecipients(), + }); + } + + message.set({ recipients: conversation.getRecipients() }); + } + + // Check for out-of-order view syncs + if (type === 'incoming' && message.isTapToView()) { + const viewSync = window.Whisper.ViewSyncs.forMessage(message); + if (viewSync) { + await message.markViewed({ fromSync: true }); + } + } + } + + // Does this message have any pending, previously-received associated reactions? + const reactions = window.Whisper.Reactions.forMessage(message); + await Promise.all( + reactions.map(reaction => message.handleReaction(reaction, false)) + ); + + // Does this message have any pending, previously-received associated + // delete for everyone messages? + const deletes = window.Whisper.Deletes.forMessage(message); + await Promise.all( + deletes.map(del => + window.Signal.Util.deleteForEveryone(message, del, false) + ) + ); + } + async handleReaction( reaction: typeof window.WhatIsThis, shouldPersist = true diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index 344ea1dc8e..a544696b80 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -441,6 +441,7 @@ class MessageReceiverInner extends EventTarget { ); this.cacheAndHandle(envelope, plaintext, request); + this.processedCount += 1; } catch (e) { request.respond(500, 'Bad encrypted websocket message'); window.log.error( @@ -787,7 +788,6 @@ class MessageReceiverInner extends EventTarget { removeFromCache(envelope: EnvelopeClass) { const { id } = envelope; this.cacheRemoveBatcher.add(id); - this.processedCount += 1; } // Same as handleEnvelope, just without the decryption step. Necessary for handling diff --git a/ts/util/StartupQueue.ts b/ts/util/StartupQueue.ts new file mode 100644 index 0000000000..3c61ce3c4c --- /dev/null +++ b/ts/util/StartupQueue.ts @@ -0,0 +1,30 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export class StartupQueue { + set: Set; + + items: Array<() => void>; + + constructor() { + this.set = new Set(); + this.items = []; + } + + add(id: string, f: () => void): void { + if (this.set.has(id)) { + return; + } + + this.items.push(f); + this.set.add(id); + } + + flush(): void { + const { items } = this; + window.log.info('StartupQueue: Processing', items.length, 'actions'); + items.forEach(f => f()); + this.items = []; + this.set.clear(); + } +} diff --git a/ts/util/index.ts b/ts/util/index.ts index 23801bd0dc..ca775d6c68 100644 --- a/ts/util/index.ts +++ b/ts/util/index.ts @@ -33,10 +33,12 @@ import { sessionStructureToArrayBuffer, } from './sessionTranslation'; import * as zkgroup from './zkgroup'; +import { StartupQueue } from './StartupQueue'; export { GoogleChrome, Registration, + StartupQueue, arrayBufferToObjectURL, combineNames, createBatcher, diff --git a/ts/window.d.ts b/ts/window.d.ts index 8e7704f72b..096942f19d 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -94,6 +94,7 @@ import { StagedLinkPreview } from './components/conversation/StagedLinkPreview'; import { MIMEType } from './types/MIME'; import { ElectronLocaleType } from './util/mapToSupportLocale'; import { SignalProtocolStore } from './LibSignalStore'; +import { StartupQueue } from './util/StartupQueue'; export { Long } from 'long'; @@ -138,6 +139,7 @@ declare global { WhatIsThis: WhatIsThis; attachmentDownloadQueue: Array | undefined; + startupProcessingQueue: StartupQueue | undefined; baseAttachmentsPath: string; baseStickersPath: string; baseTempPath: string; From 8ee653f2523dd27544fd0077a94a8b88cbe510f4 Mon Sep 17 00:00:00 2001 From: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com> Date: Mon, 15 Mar 2021 13:50:22 -0500 Subject: [PATCH 078/181] Respect users's "reduced motion" preference for smooth scrolling --- stylesheets/_mixins.scss | 10 ++++++++++ stylesheets/_modules.scss | 5 +++-- stylesheets/components/ContactPills.scss | 3 ++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/stylesheets/_mixins.scss b/stylesheets/_mixins.scss index 5e1d3775a2..1c90794c4a 100644 --- a/stylesheets/_mixins.scss +++ b/stylesheets/_mixins.scss @@ -106,6 +106,16 @@ } } +// Smooth scrolling + +@mixin smooth-scroll() { + scroll-behavior: smooth; + + @media (prefers-reduced-motion) { + scroll-behavior: auto; + } +} + // Icons @mixin color-svg($svg, $color, $stretch: true) { diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 7c77536eae..96257fa620 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -6308,12 +6308,13 @@ button.module-image__border-overlay:focus { &__scroll-marker { $scroll-marker-selector: &; + @include smooth-scroll; + display: flex; justify-content: center; left: 0; opacity: 1; position: absolute; - scroll-behavior: smooth; transition: opacity 200ms ease-out; width: 100%; z-index: 1; @@ -6896,7 +6897,7 @@ button.module-image__border-overlay:focus { // Module: conversation list .module-conversation-list { - scroll-behavior: smooth; + @include smooth-scroll; &__item { &--archive-button { diff --git a/stylesheets/components/ContactPills.scss b/stylesheets/components/ContactPills.scss index c0ccc04b4a..a8f8275920 100644 --- a/stylesheets/components/ContactPills.scss +++ b/stylesheets/components/ContactPills.scss @@ -2,6 +2,8 @@ // SPDX-License-Identifier: AGPL-3.0-only .module-ContactPills { + @include smooth-scroll; + display: flex; flex-wrap: wrap; margin-bottom: 10px; @@ -9,7 +11,6 @@ overflow-x: hidden; overflow-y: scroll; padding-left: 12px; - scroll-behavior: smooth; .module-ContactPill { margin: 4px 6px; From 59d1159c0655a6825da60935804c5d30067f45f3 Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Mon, 15 Mar 2021 13:18:25 -0700 Subject: [PATCH 079/181] Set `attachmentDownloadQueue = undefined` on empty --- ts/background.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ts/background.ts b/ts/background.ts index b827ab48df..936c70372e 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -2075,6 +2075,11 @@ export async function startApp(): Promise { window.Signal.Util.setBatchingStrategy(false); const attachmentDownloadQueue = window.attachmentDownloadQueue || []; + + // NOTE: ts/models/messages.ts expects this global to become undefined + // once we stop processing the queue. + window.attachmentDownloadQueue = undefined; + const THREE_DAYS_AGO = Date.now() - 3600 * 72 * 1000; const MAX_ATTACHMENT_MSGS_TO_DOWNLOAD = 250; const attachmentsToDownload = attachmentDownloadQueue.filter( From b93164a8c0ff890cefb505d249e31b60754c8375 Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Mon, 15 Mar 2021 14:26:21 -0700 Subject: [PATCH 080/181] Reduce size of asar file and unpacked asar folder --- package.json | 10 +- ts/util/lint/exceptions.json | 299 +---------------------------------- yarn.lock | 14 +- 3 files changed, 14 insertions(+), 309 deletions(-) diff --git a/package.json b/package.json index f39325abb1..1488e0a1da 100644 --- a/package.json +++ b/package.json @@ -221,7 +221,7 @@ "babel-loader": "8.0.6", "babel-plugin-lodash": "3.3.4", "chai": "4.1.2", - "core-js": "2.4.1", + "core-js": "2.6.9", "cross-env": "5.2.0", "css-loader": "3.2.0", "electron": "11.2.3", @@ -398,7 +398,7 @@ "!node_modules/emoji-datasource-apple/emoji_pretty.json", "!node_modules/emoji-datasource-apple/img/apple/sheets*", "!node_modules/spellchecker/vendor/hunspell/**/*", - "!**/node_modules/*/{CHANGELOG.md,README.md,README,readme.md,readme,test,__tests__,tests,powered-test,example,examples,*.d.ts,.snyk-*.flag}", + "!**/node_modules/*/{CHANGELOG.md,README.md,README,readme.md,readme,test,__tests__,tests,powered-test,example,examples,*.d.ts,.snyk-*.flag,benchmark}", "!**/node_modules/.bin", "!**/node_modules/*/build/**", "!**/*.{o,hprof,orig,pyc,pyo,rbc}", @@ -419,8 +419,10 @@ "!node_modules/@journeyapps/sqlcipher/build/*", "!node_modules/@journeyapps/sqlcipher/build-tmp-napi-*", "!node_modules/@journeyapps/sqlcipher/lib/binding/node-*", - "node_modules/libsignal-client/build/*.node", - "node_modules/ringrtc/build/${platform}/**" + "node_modules/libsignal-client/build/*${platform}*.node", + "node_modules/ringrtc/build/${platform}/**", + "!**/node_modules/ffi-napi/deps", + "!**/node_modules/react-dom/*/*.development.js" ] } } diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index cd58f5419c..69164d720e 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -1954,303 +1954,6 @@ "reasonCategory": "falseMatch", "updated": "2018-09-19T18:13:29.628Z" }, - { - "rule": "jQuery-$(", - "path": "node_modules/attr-accept/node_modules/core-js/build/index.js", - "line": " if (name.indexOf(ns + \".\") === 0 && !in$(name, experimental)) {", - "lineNumber": 36, - "reasonCategory": "falseMatch", - "updated": "2019-12-11T01:10:06.091Z" - }, - { - "rule": "jQuery-$(", - "path": "node_modules/attr-accept/node_modules/core-js/build/index.js", - "line": " function in$(x, xs){", - "lineNumber": 99, - "reasonCategory": "falseMatch", - "updated": "2019-12-11T01:10:06.091Z" - }, - { - "rule": "jQuery-$(", - "path": "node_modules/attr-accept/node_modules/core-js/client/core.js", - "line": " reCopy = new RegExp('^' + re.source + '$(?!\\\\s)', regexpFlags.call(re));", - "lineNumber": 2498, - "reasonCategory": "falseMatch", - "updated": "2019-12-11T01:10:06.091Z" - }, - { - "rule": "jQuery-wrap(", - "path": "node_modules/attr-accept/node_modules/core-js/client/core.js", - "line": " return wrap(tag);", - "lineNumber": 4362, - "reasonCategory": "falseMatch", - "updated": "2019-12-11T01:10:06.091Z" - }, - { - "rule": "jQuery-wrap(", - "path": "node_modules/attr-accept/node_modules/core-js/client/core.js", - "line": " return wrap(wks(name));", - "lineNumber": 4379, - "reasonCategory": "falseMatch", - "updated": "2019-12-11T01:10:06.091Z" - }, - { - "rule": "jQuery-$(", - "path": "node_modules/attr-accept/node_modules/core-js/client/core.js", - "line": "var SUBSTITUTION_SYMBOLS = /\\$([$&`']|\\d\\d?|<[^>]*>)/g;", - "lineNumber": 6331, - "reasonCategory": "falseMatch", - "updated": "2019-12-11T01:10:06.091Z" - }, - { - "rule": "jQuery-$(", - "path": "node_modules/attr-accept/node_modules/core-js/client/core.js", - "line": "var SUBSTITUTION_SYMBOLS_NO_NAMED = /\\$([$&`']|\\d\\d?)/g;", - "lineNumber": 6332, - "reasonCategory": "falseMatch", - "updated": "2019-12-11T01:10:06.091Z" - }, - { - "rule": "jQuery-wrap(", - "path": "node_modules/attr-accept/node_modules/core-js/client/core.js", - "line": " setTimeout: wrap(global.setTimeout),", - "lineNumber": 8752, - "reasonCategory": "falseMatch", - "updated": "2019-12-11T01:10:06.091Z" - }, - { - "rule": "jQuery-wrap(", - "path": "node_modules/attr-accept/node_modules/core-js/client/core.js", - "line": " setInterval: wrap(global.setInterval)", - "lineNumber": 8753, - "reasonCategory": "falseMatch", - "updated": "2020-09-11T17:24:56.124Z" - }, - { - "rule": "jQuery-$(", - "path": "node_modules/attr-accept/node_modules/core-js/client/core.min.js", - "lineNumber": 7, - "reasonCategory": "falseMatch", - "updated": "2019-12-11T01:10:06.091Z" - }, - { - "rule": "jQuery-$(", - "path": "node_modules/attr-accept/node_modules/core-js/client/core.min.js", - "lineNumber": 8, - "reasonCategory": "falseMatch", - "updated": "2020-09-11T17:24:56.124Z" - }, - { - "rule": "jQuery-$(", - "path": "node_modules/attr-accept/node_modules/core-js/client/core.min.js", - "lineNumber": 9, - "reasonCategory": "falseMatch", - "updated": "2019-12-11T01:10:06.091Z" - }, - { - "rule": "jQuery-wrap(", - "path": "node_modules/attr-accept/node_modules/core-js/client/library.js", - "line": " return wrap(tag);", - "lineNumber": 4059, - "reasonCategory": "falseMatch", - "updated": "2019-12-11T01:10:06.091Z" - }, - { - "rule": "jQuery-wrap(", - "path": "node_modules/attr-accept/node_modules/core-js/client/library.js", - "line": " return wrap(wks(name));", - "lineNumber": 4076, - "reasonCategory": "falseMatch", - "updated": "2019-12-11T01:10:06.091Z" - }, - { - "rule": "jQuery-wrap(", - "path": "node_modules/attr-accept/node_modules/core-js/client/library.js", - "line": " setTimeout: wrap(global.setTimeout),", - "lineNumber": 7820, - "reasonCategory": "falseMatch", - "updated": "2019-12-11T01:10:06.091Z" - }, - { - "rule": "jQuery-wrap(", - "path": "node_modules/attr-accept/node_modules/core-js/client/library.js", - "line": " setInterval: wrap(global.setInterval)", - "lineNumber": 7821, - "reasonCategory": "falseMatch", - "updated": "2020-09-11T17:24:56.124Z" - }, - { - "rule": "jQuery-$(", - "path": "node_modules/attr-accept/node_modules/core-js/client/library.min.js", - "lineNumber": 7, - "reasonCategory": "falseMatch", - "updated": "2019-12-11T01:10:06.091Z" - }, - { - "rule": "jQuery-$(", - "path": "node_modules/attr-accept/node_modules/core-js/client/library.min.js", - "lineNumber": 8, - "reasonCategory": "falseMatch", - "updated": "2019-12-11T01:10:06.091Z" - }, - { - "rule": "jQuery-$(", - "path": "node_modules/attr-accept/node_modules/core-js/client/shim.js", - "line": " reCopy = new RegExp('^' + re.source + '$(?!\\\\s)', regexpFlags.call(re));", - "lineNumber": 2422, - "reasonCategory": "falseMatch", - "updated": "2019-12-11T01:10:06.091Z" - }, - { - "rule": "jQuery-wrap(", - "path": "node_modules/attr-accept/node_modules/core-js/client/shim.js", - "line": " return wrap(tag);", - "lineNumber": 4260, - "reasonCategory": "falseMatch", - "updated": "2019-12-11T01:10:06.091Z" - }, - { - "rule": "jQuery-wrap(", - "path": "node_modules/attr-accept/node_modules/core-js/client/shim.js", - "line": " return wrap(wks(name));", - "lineNumber": 4277, - "reasonCategory": "falseMatch", - "updated": "2019-12-11T01:10:06.091Z" - }, - { - "rule": "jQuery-$(", - "path": "node_modules/attr-accept/node_modules/core-js/client/shim.js", - "line": "var SUBSTITUTION_SYMBOLS = /\\$([$&`']|\\d\\d?|<[^>]*>)/g;", - "lineNumber": 6229, - "reasonCategory": "falseMatch", - "updated": "2019-12-11T01:10:06.091Z" - }, - { - "rule": "jQuery-$(", - "path": "node_modules/attr-accept/node_modules/core-js/client/shim.js", - "line": "var SUBSTITUTION_SYMBOLS_NO_NAMED = /\\$([$&`']|\\d\\d?)/g;", - "lineNumber": 6230, - "reasonCategory": "falseMatch", - "updated": "2019-12-11T01:10:06.091Z" - }, - { - "rule": "jQuery-wrap(", - "path": "node_modules/attr-accept/node_modules/core-js/client/shim.js", - "line": " setTimeout: wrap(global.setTimeout),", - "lineNumber": 8650, - "reasonCategory": "falseMatch", - "updated": "2019-12-11T01:10:06.091Z" - }, - { - "rule": "jQuery-wrap(", - "path": "node_modules/attr-accept/node_modules/core-js/client/shim.js", - "line": " setInterval: wrap(global.setInterval)", - "lineNumber": 8651, - "reasonCategory": "falseMatch", - "updated": "2020-09-11T17:24:56.124Z" - }, - { - "rule": "jQuery-$(", - "path": "node_modules/attr-accept/node_modules/core-js/client/shim.min.js", - "lineNumber": 7, - "reasonCategory": "falseMatch", - "updated": "2019-12-11T01:10:06.091Z" - }, - { - "rule": "jQuery-$(", - "path": "node_modules/attr-accept/node_modules/core-js/client/shim.min.js", - "lineNumber": 8, - "reasonCategory": "falseMatch", - "updated": "2019-12-11T01:10:06.091Z" - }, - { - "rule": "jQuery-wrap(", - "path": "node_modules/attr-accept/node_modules/core-js/library/modules/es6.symbol.js", - "line": " return wrap(tag);", - "lineNumber": 144, - "reasonCategory": "falseMatch", - "updated": "2019-12-11T01:10:06.091Z" - }, - { - "rule": "jQuery-wrap(", - "path": "node_modules/attr-accept/node_modules/core-js/library/modules/es6.symbol.js", - "line": " return wrap(wks(name));", - "lineNumber": 161, - "reasonCategory": "falseMatch", - "updated": "2019-12-11T01:10:06.091Z" - }, - { - "rule": "jQuery-wrap(", - "path": "node_modules/attr-accept/node_modules/core-js/library/modules/web.timers.js", - "line": " setTimeout: wrap(global.setTimeout),", - "lineNumber": 18, - "reasonCategory": "falseMatch", - "updated": "2019-12-11T01:10:06.091Z" - }, - { - "rule": "jQuery-wrap(", - "path": "node_modules/attr-accept/node_modules/core-js/library/modules/web.timers.js", - "line": " setInterval: wrap(global.setInterval)", - "lineNumber": 19, - "reasonCategory": "falseMatch", - "updated": "2020-09-11T17:24:56.124Z" - }, - { - "rule": "jQuery-$(", - "path": "node_modules/attr-accept/node_modules/core-js/modules/_regexp-exec.js", - "line": " reCopy = new RegExp('^' + re.source + '$(?!\\\\s)', regexpFlags.call(re));", - "lineNumber": 34, - "reasonCategory": "falseMatch", - "updated": "2019-12-11T01:10:06.091Z" - }, - { - "rule": "jQuery-$(", - "path": "node_modules/attr-accept/node_modules/core-js/modules/es6.regexp.replace.js", - "line": "var SUBSTITUTION_SYMBOLS = /\\$([$&`']|\\d\\d?|<[^>]*>)/g;", - "lineNumber": 12, - "reasonCategory": "falseMatch", - "updated": "2019-12-11T01:10:06.091Z" - }, - { - "rule": "jQuery-$(", - "path": "node_modules/attr-accept/node_modules/core-js/modules/es6.regexp.replace.js", - "line": "var SUBSTITUTION_SYMBOLS_NO_NAMED = /\\$([$&`']|\\d\\d?)/g;", - "lineNumber": 13, - "reasonCategory": "falseMatch", - "updated": "2019-12-11T01:10:06.091Z" - }, - { - "rule": "jQuery-wrap(", - "path": "node_modules/attr-accept/node_modules/core-js/modules/es6.symbol.js", - "line": " return wrap(tag);", - "lineNumber": 144, - "reasonCategory": "falseMatch", - "updated": "2019-12-11T01:10:06.091Z" - }, - { - "rule": "jQuery-wrap(", - "path": "node_modules/attr-accept/node_modules/core-js/modules/es6.symbol.js", - "line": " return wrap(wks(name));", - "lineNumber": 161, - "reasonCategory": "falseMatch", - "updated": "2019-12-11T01:10:06.091Z" - }, - { - "rule": "jQuery-wrap(", - "path": "node_modules/attr-accept/node_modules/core-js/modules/web.timers.js", - "line": " setTimeout: wrap(global.setTimeout),", - "lineNumber": 18, - "reasonCategory": "falseMatch", - "updated": "2019-12-11T01:10:06.091Z" - }, - { - "rule": "jQuery-wrap(", - "path": "node_modules/attr-accept/node_modules/core-js/modules/web.timers.js", - "line": " setInterval: wrap(global.setInterval)", - "lineNumber": 19, - "reasonCategory": "falseMatch", - "updated": "2020-09-11T17:24:56.124Z" - }, { "rule": "jQuery-wrap(", "path": "node_modules/axe-core/axe.js", @@ -15480,4 +15183,4 @@ "updated": "2021-01-08T15:46:32.143Z", "reasonDetail": "Doesn't manipulate the DOM. This is just a function." } -] \ No newline at end of file +] diff --git a/yarn.lock b/yarn.lock index 703870dd0d..02670e08c3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5421,18 +5421,18 @@ core-js-pure@^3.0.1: resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.2.1.tgz#879a23699cff46175bfd2d09158b5c50645a3c45" integrity sha512-+qpvnYrsi/JDeQTArB7NnNc2VoMYLE1YSkziCDHgjexC2KH7OFiGhLUd3urxfyWmNjSwSW7NYXPWHMhuIJx9Ow== -core-js@2.4.1, core-js@^2.4.0: - version "2.4.1" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.4.1.tgz#4de911e667b0eae9124e34254b53aea6fc618d3e" +core-js@2.6.9, core-js@^2.5.0: + version "2.6.9" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.9.tgz#6b4b214620c834152e179323727fc19741b084f2" + integrity sha512-HOpZf6eXmnl7la+cUdMnLvUxKNqLUzJvgIziQ0DiF3JwSImNphIqdGqzj6hIKyX04MmV0poclQ7+wjWvxQyR2A== core-js@^1.0.0: version "1.2.7" resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636" -core-js@^2.5.0: - version "2.6.9" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.9.tgz#6b4b214620c834152e179323727fc19741b084f2" - integrity sha512-HOpZf6eXmnl7la+cUdMnLvUxKNqLUzJvgIziQ0DiF3JwSImNphIqdGqzj6hIKyX04MmV0poclQ7+wjWvxQyR2A== +core-js@^2.4.0: + version "2.4.1" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.4.1.tgz#4de911e667b0eae9124e34254b53aea6fc618d3e" core-js@^3.0.1, core-js@^3.0.4: version "3.2.1" From 3a94d2c75ed3bd8cb3d31f04a612984961392482 Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Mon, 15 Mar 2021 14:27:25 -0700 Subject: [PATCH 081/181] Generate blurHash for outgoing link previews --- ts/views/conversation_view.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index a1351270d6..d0480d71ab 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -15,6 +15,7 @@ type GetLinkPreviewImageResult = { contentType: string; width?: number; height?: number; + blurHash: string; }; type GetLinkPreviewResult = { @@ -3758,6 +3759,11 @@ Whisper.ConversationView = Whisper.View.extend({ data, size: data.byteLength, contentType: 'image/jpeg', + blurHash: await window.imageToBlurHash( + new Blob([data], { + type: 'image/jpeg', + }) + ), }; } catch (error) { const errorString = error && error.stack ? error.stack : error; @@ -3833,6 +3839,8 @@ Whisper.ConversationView = Whisper.View.extend({ const data = await this.arrayBufferFromFile(withBlob.file); objectUrl = URL.createObjectURL(withBlob.file); + const blurHash = await window.imageToBlurHash(withBlob.file); + const dimensions = await VisualAttachment.getImageDimensions({ objectUrl, logger: window.log, @@ -3843,6 +3851,7 @@ Whisper.ConversationView = Whisper.View.extend({ size: data.byteLength, ...dimensions, contentType: withBlob.file.type, + blurHash, }; } catch (error) { // We still want to show the preview if we failed to get an image From 1dcbee4e2a2cf85d85eb717af352af3635cf1829 Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Mon, 15 Mar 2021 14:44:12 -0700 Subject: [PATCH 082/181] Use eslint --cache CLI argument to speed up linting --- .gitignore | 1 + package.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index ae9ef166bf..762c7833fd 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ release/ *.sublime* /sql/ /start.sh +.eslintcache # generated files js/components.js diff --git a/package.json b/package.json index 1488e0a1da..406ce173b8 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "test-electron": "yarn grunt test", "test-node": "electron-mocha --file test/setup-test-node.js --recursive test/app test/modules ts/test-node ts/test-both", "test-node-coverage": "nyc --reporter=lcov --reporter=text mocha --recursive test/app test/modules ts/test-node ts/test-both", - "eslint": "eslint .", + "eslint": "eslint --cache .", "lint": "yarn format --list-different && yarn eslint", "lint-deps": "node ts/util/lint/linter.js", "lint-license-comments": "ts-node ts/util/lint/license_comments.ts", From 2d051e239067e58416f0575ea233195a4c01f0ec Mon Sep 17 00:00:00 2001 From: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com> Date: Mon, 15 Mar 2021 16:44:59 -0500 Subject: [PATCH 083/181] Check for conflicts with group IDs --- ts/ConversationController.ts | 167 +++++++++++++++++++++++------------ 1 file changed, 112 insertions(+), 55 deletions(-) diff --git a/ts/ConversationController.ts b/ts/ConversationController.ts index f891fd8698..6c1e14039c 100644 --- a/ts/ConversationController.ts +++ b/ts/ConversationController.ts @@ -1,4 +1,4 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { debounce, reduce, uniq, without } from 'lodash'; @@ -13,6 +13,7 @@ import { import { SendOptionsType, CallbackResultType } from './textsecure/SendMessage'; import { ConversationModel } from './models/conversations'; import { maybeDeriveGroupV2Id } from './groups'; +import { assert } from './util/assert'; const MAX_MESSAGE_BODY_LENGTH = 64 * 1024; @@ -442,7 +443,7 @@ export class ConversationController { convoUuid.updateE164(e164); // `then` is used to trigger async updates, not affecting return value // eslint-disable-next-line more/no-then - this.combineContacts(convoUuid, convoE164) + this.combineConversations(convoUuid, convoE164) .then(() => { // If the old conversation was currently displayed, we load the new one window.Whisper.events.trigger('refreshConversation', { @@ -465,14 +466,21 @@ export class ConversationController { window.log.info('checkForConflicts: starting...'); const byUuid = Object.create(null); const byE164 = Object.create(null); + const byGroupV2Id = Object.create(null); + // We also want to find duplicate GV1 IDs. You might expect to see a "byGroupV1Id" map + // here. Instead, we check for duplicates on the derived GV2 ID. + + const { models } = this._conversations; // We iterate from the oldest conversations to the newest. This allows us, in a // conflict case, to keep the one with activity the most recently. - const models = [...this._conversations.models.reverse()]; - - const max = models.length; - for (let i = 0; i < max; i += 1) { + for (let i = models.length - 1; i >= 0; i -= 1) { const conversation = models[i]; + assert( + conversation, + 'Expected conversation to be found in array during iteration' + ); + const uuid = conversation.get('uuid'); const e164 = conversation.get('e164'); @@ -489,12 +497,12 @@ export class ConversationController { if (conversation.get('e164')) { // Keep new one // eslint-disable-next-line no-await-in-loop - await this.combineContacts(conversation, existing); + await this.combineConversations(conversation, existing); byUuid[uuid] = conversation; } else { // Keep existing - note that this applies if neither had an e164 // eslint-disable-next-line no-await-in-loop - await this.combineContacts(existing, conversation); + await this.combineConversations(existing, conversation); } } } @@ -531,12 +539,49 @@ export class ConversationController { if (conversation.get('uuid')) { // Keep new one // eslint-disable-next-line no-await-in-loop - await this.combineContacts(conversation, existing); + await this.combineConversations(conversation, existing); byE164[e164] = conversation; } else { // Keep existing - note that this applies if neither had a UUID // eslint-disable-next-line no-await-in-loop - await this.combineContacts(existing, conversation); + await this.combineConversations(existing, conversation); + } + } + } + + let groupV2Id: undefined | string; + if (conversation.isGroupV1()) { + // eslint-disable-next-line no-await-in-loop + await maybeDeriveGroupV2Id(conversation); + groupV2Id = conversation.get('derivedGroupV2Id'); + assert( + groupV2Id, + 'checkForConflicts: expected the group V2 ID to have been derived, but it was falsy' + ); + } else if (conversation.isGroupV2()) { + groupV2Id = conversation.get('groupId'); + } + + if (groupV2Id) { + const existing = byGroupV2Id[groupV2Id]; + if (!existing) { + byGroupV2Id[groupV2Id] = conversation; + } else { + const logParenthetical = conversation.isGroupV1() + ? ' (derived from a GV1 group ID)' + : ''; + window.log.warn( + `checkForConflicts: Found conflict with group V2 ID ${groupV2Id}${logParenthetical}` + ); + + // Prefer the GV2 group. + if (conversation.isGroupV2() && !existing.isGroupV2()) { + // eslint-disable-next-line no-await-in-loop + await this.combineConversations(conversation, existing); + byGroupV2Id[groupV2Id] = conversation; + } else { + // eslint-disable-next-line no-await-in-loop + await this.combineConversations(existing, conversation); } } } @@ -545,82 +590,94 @@ export class ConversationController { window.log.info('checkForConflicts: complete!'); } - async combineContacts( + async combineConversations( current: ConversationModel, obsolete: ConversationModel ): Promise { + const conversationType = current.get('type'); + + if (obsolete.get('type') !== conversationType) { + assert( + false, + 'combineConversations cannot combine a private and group conversation. Doing nothing' + ); + return; + } + const obsoleteId = obsolete.get('id'); const currentId = current.get('id'); - window.log.warn('combineContacts: Combining two conversations', { + window.log.warn('combineConversations: Combining two conversations', { obsolete: obsoleteId, current: currentId, }); - if (!current.get('profileKey') && obsolete.get('profileKey')) { + if (conversationType === 'private') { + if (!current.get('profileKey') && obsolete.get('profileKey')) { + window.log.warn( + 'combineConversations: Copying profile key from old to new contact' + ); + + const profileKey = obsolete.get('profileKey'); + + if (profileKey) { + await current.setProfileKey(profileKey); + } + } + window.log.warn( - 'combineContacts: Copying profile key from old to new contact' + 'combineConversations: Delete all sessions tied to old conversationId' + ); + const deviceIds = await window.textsecure.storage.protocol.getDeviceIds( + obsoleteId + ); + await Promise.all( + deviceIds.map(async deviceId => { + await window.textsecure.storage.protocol.removeSession( + `${obsoleteId}.${deviceId}` + ); + }) ); - const profileKey = obsolete.get('profileKey'); + window.log.warn( + 'combineConversations: Delete all identity information tied to old conversationId' + ); + await window.textsecure.storage.protocol.removeIdentityKey(obsoleteId); - if (profileKey) { - await current.setProfileKey(profileKey); - } - } + window.log.warn( + 'combineConversations: Ensure that all V1 groups have new conversationId instead of old' + ); + const groups = await this.getAllGroupsInvolvingId(obsoleteId); + groups.forEach(group => { + const members = group.get('members'); + const withoutObsolete = without(members, obsoleteId); + const currentAdded = uniq([...withoutObsolete, currentId]); - window.log.warn( - 'combineContacts: Delete all sessions tied to old conversationId' - ); - const deviceIds = await window.textsecure.storage.protocol.getDeviceIds( - obsoleteId - ); - await Promise.all( - deviceIds.map(async deviceId => { - await window.textsecure.storage.protocol.removeSession( - `${obsoleteId}.${deviceId}` - ); - }) - ); - - window.log.warn( - 'combineContacts: Delete all identity information tied to old conversationId' - ); - await window.textsecure.storage.protocol.removeIdentityKey(obsoleteId); - - window.log.warn( - 'combineContacts: Ensure that all V1 groups have new conversationId instead of old' - ); - const groups = await this.getAllGroupsInvolvingId(obsoleteId); - groups.forEach(group => { - const members = group.get('members'); - const withoutObsolete = without(members, obsoleteId); - const currentAdded = uniq([...withoutObsolete, currentId]); - - group.set({ - members: currentAdded, + group.set({ + members: currentAdded, + }); + updateConversation(group.attributes); }); - updateConversation(group.attributes); - }); + } // Note: we explicitly don't want to update V2 groups window.log.warn( - 'combineContacts: Delete the obsolete conversation from the database' + 'combineConversations: Delete the obsolete conversation from the database' ); await removeConversation(obsoleteId, { Conversation: window.Whisper.Conversation, }); - window.log.warn('combineContacts: Update messages table'); + window.log.warn('combineConversations: Update messages table'); await migrateConversationMessages(obsoleteId, currentId); window.log.warn( - 'combineContacts: Eliminate old conversation from ConversationController lookups' + 'combineConversations: Eliminate old conversation from ConversationController lookups' ); this._conversations.remove(obsolete); this._conversations.resetLookups(); - window.log.warn('combineContacts: Complete!', { + window.log.warn('combineConversations: Complete!', { obsolete: obsoleteId, current: currentId, }); From 98c9992705b3c2defc63609053437abf0582782b Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Mon, 15 Mar 2021 14:52:16 -0700 Subject: [PATCH 084/181] Fix out-of-order message changes; remove isGroupV2, and double-update --- ts/models/messages.ts | 120 +++++++++++++++++++++--------------------- 1 file changed, 61 insertions(+), 59 deletions(-) diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 471b8626fe..5c7f1ee9eb 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -3675,13 +3675,14 @@ export class MessageModel extends window.Backbone.Model { } } - await this.modifyTargetMessage(conversation, isGroupV2); + const isFirstRun = true; + await this.modifyTargetMessage(conversation, isFirstRun); window.log.info( 'handleDataMessage: Batching save for', message.get('sent_at') ); - this.saveAndNotify(conversation, isGroupV2, confirm); + this.saveAndNotify(conversation, confirm); } catch (error) { const errorForLog = error && error.stack ? error.stack : error; window.log.error( @@ -3697,7 +3698,6 @@ export class MessageModel extends window.Backbone.Model { async saveAndNotify( conversation: ConversationModel, - isGroupV2: boolean, confirm: () => void ): Promise { await window.Signal.Util.saveNewMessageBatcher.add(this.attributes); @@ -3706,7 +3706,8 @@ export class MessageModel extends window.Backbone.Model { conversation.trigger('newmessage', this); - await this.modifyTargetMessage(conversation, isGroupV2); + const isFirstRun = false; + await this.modifyTargetMessage(conversation, isFirstRun); if (this.get('unread')) { await conversation.notify(this); @@ -3721,9 +3722,12 @@ export class MessageModel extends window.Backbone.Model { confirm(); } + // This function is called twice - once from handleDataMessage, and then again from + // saveAndNotify, a function called at the end of handleDataMessage as a cleanup for + // any missed out-of-order events. async modifyTargetMessage( conversation: ConversationModel, - isGroupV2: boolean + isFirstRun: boolean ): Promise { // eslint-disable-next-line @typescript-eslint/no-this-alias const message = this; @@ -3744,66 +3748,64 @@ export class MessageModel extends window.Backbone.Model { ); } - if (!isGroupV2) { - if (type === 'incoming') { - const readSync = window.Whisper.ReadSyncs.forMessage(message); - if (readSync) { - if ( - message.get('expireTimer') && - !message.get('expirationStartTimestamp') - ) { - message.set( - 'expirationStartTimestamp', - Math.min(readSync.get('read_at'), Date.now()) - ); - } - } - if (readSync || message.isExpirationTimerUpdate()) { - message.unset('unread'); - // This is primarily to allow the conversation to mark all older - // messages as read, as is done when we receive a read sync for - // a message we already know about. - const c = message.getConversation(); - if (c) { - c.onReadMessage(message); - } - } else { - conversation.set({ - unreadCount: (conversation.get('unreadCount') || 0) + 1, - isArchived: false, - }); + if (type === 'incoming') { + const readSync = window.Whisper.ReadSyncs.forMessage(message); + if (readSync) { + if ( + message.get('expireTimer') && + !message.get('expirationStartTimestamp') + ) { + message.set( + 'expirationStartTimestamp', + Math.min(readSync.get('read_at'), Date.now()) + ); } } - - if (type === 'outgoing') { - const reads = window.Whisper.ReadReceipts.forMessage( - conversation, - message - ); - if (reads.length) { - const readBy = reads.map(receipt => receipt.get('reader')); - message.set({ - read_by: _.union(message.get('read_by'), readBy), - }); + if (readSync || message.isExpirationTimerUpdate()) { + message.unset('unread'); + // This is primarily to allow the conversation to mark all older + // messages as read, as is done when we receive a read sync for + // a message we already know about. + const c = message.getConversation(); + if (c) { + c.onReadMessage(message); } + } else if (isFirstRun) { + conversation.set({ + unreadCount: (conversation.get('unreadCount') || 0) + 1, + isArchived: false, + }); + } + } - // A sync'd message to ourself is automatically considered read/delivered - if (conversation.isMe()) { - message.set({ - read_by: conversation.getRecipients(), - delivered_to: conversation.getRecipients(), - }); - } - - message.set({ recipients: conversation.getRecipients() }); + if (type === 'outgoing') { + const reads = window.Whisper.ReadReceipts.forMessage( + conversation, + message + ); + if (reads.length) { + const readBy = reads.map(receipt => receipt.get('reader')); + message.set({ + read_by: _.union(message.get('read_by'), readBy), + }); } - // Check for out-of-order view syncs - if (type === 'incoming' && message.isTapToView()) { - const viewSync = window.Whisper.ViewSyncs.forMessage(message); - if (viewSync) { - await message.markViewed({ fromSync: true }); - } + // A sync'd message to ourself is automatically considered read/delivered + if (conversation.isMe()) { + message.set({ + read_by: conversation.getRecipients(), + delivered_to: conversation.getRecipients(), + }); + } + + message.set({ recipients: conversation.getRecipients() }); + } + + // Check for out-of-order view syncs + if (type === 'incoming' && message.isTapToView()) { + const viewSync = window.Whisper.ViewSyncs.forMessage(message); + if (viewSync) { + await message.markViewed({ fromSync: true }); } } From 529dd1a3cc5f34b99572b7d1ce6006987d96d092 Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Mon, 15 Mar 2021 15:57:02 -0700 Subject: [PATCH 085/181] Show download button for pending Link Preview images --- stylesheets/_modules.scss | 2 +- ts/components/conversation/Message.tsx | 19 ++++++++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 96257fa620..c217e3fd01 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -847,7 +847,7 @@ } .module-message__link-preview { - @include button-reset; + cursor: pointer; &--nonclickable { cursor: inherit; diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index d1cbc004e4..37f4f76f7f 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -846,6 +846,7 @@ export class Message extends React.PureComponent { public renderPreview(): JSX.Element | null { const { + id, attachments, conversationType, direction, @@ -854,6 +855,7 @@ export class Message extends React.PureComponent { previews, quote, theme, + kickOffAttachmentDownload, } = this.props; // Attachments take precedence over Link Previews @@ -889,6 +891,14 @@ export class Message extends React.PureComponent { 'module-message__link-preview--nonclickable': !isClickable, } ); + const onPreviewImageClick = () => { + if (first.image && hasNotDownloaded(first.image)) { + kickOffAttachmentDownload({ + attachment: first.image, + messageId: id, + }); + } + }; const contents = ( <> {first.image && previewHasImage && isFullSizeImage ? ( @@ -899,6 +909,7 @@ export class Message extends React.PureComponent { onError={this.handleImageError} i18n={i18n} theme={theme} + onClick={onPreviewImageClick} /> ) : null}
@@ -916,6 +927,7 @@ export class Message extends React.PureComponent { attachment={first.image} onError={this.handleImageError} i18n={i18n} + onClick={onPreviewImageClick} />
) : null} @@ -950,8 +962,9 @@ export class Message extends React.PureComponent { ); return isClickable ? ( - +
) : (
{contents}
); From 6df82867a0c1f8e206fd1e2f024577fe3c50b5e1 Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Mon, 15 Mar 2021 16:36:17 -0700 Subject: [PATCH 086/181] use `incremental` build mode for `tsc` --- .gitignore | 1 + tsconfig.json | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 762c7833fd..a7e119ee03 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ release/ /sql/ /start.sh .eslintcache +tsconfig.tsbuildinfo # generated files js/components.js diff --git a/tsconfig.json b/tsconfig.json index 75d1249a1b..c172487b00 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,7 @@ "dom", // Required to access `window` "es2017" // Required by `@sindresorhus/is` ], + "incremental": true, // "allowJs": true, // Allow javascript files to be compiled. // "checkJs": true, // Report errors in .js files. "jsx": "react", // Specify JSX code generation: 'preserve', 'react-native', or 'react'. @@ -15,7 +16,7 @@ // "sourceMap": true, // Generates corresponding '.map' file. // "outFile": "./", // Concatenate and emit output to single file. // "outDir": "./", // Redirect output structure to the directory. - "rootDir": "./ts", // Specify the root directory of input files. Use to control the output directory structure with --outDir. + "rootDir": "./", // Specify the root directory of input files. Use to control the output directory structure with --outDir. // "removeComments": true, // Do not emit comments to output. // "noEmit": true, // Do not emit outputs. // "importHelpers": true, // Import emit helpers from 'tslib'. From fd8339e2ff1140e641fde0b142ad13411a059c22 Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Mon, 15 Mar 2021 16:44:43 -0700 Subject: [PATCH 087/181] Fix for UnregisteredUserError handling when fetching UUIDs --- .../state/selectors/conversations_test.ts | 22 +++++++++---------- .../util/isConversationUnregistered_test.ts | 16 +++++++------- ts/textsecure/OutgoingMessage.ts | 5 +++++ ts/util/isConversationUnregistered.ts | 2 +- 4 files changed, 25 insertions(+), 20 deletions(-) diff --git a/ts/test-both/state/selectors/conversations_test.ts b/ts/test-both/state/selectors/conversations_test.ts index 5360b6f99f..167a8fd88a 100644 --- a/ts/test-both/state/selectors/conversations_test.ts +++ b/ts/test-both/state/selectors/conversations_test.ts @@ -501,7 +501,8 @@ describe('both/state/selectors/conversations', () => { 'convo-5': { ...getDefaultConversation('convo-5'), discoveredUnregisteredAt: new Date(1999, 3, 20).getTime(), - title: 'Should Be Dropped (unregistered)', + name: 'In System Contacts (and unregistered too long ago)', + title: 'B. Sorted Second', }, 'convo-6': { ...getDefaultConversation('convo-6'), @@ -511,8 +512,7 @@ describe('both/state/selectors/conversations', () => { 'convo-7': { ...getDefaultConversation('convo-7'), discoveredUnregisteredAt: Date.now(), - name: 'In System Contacts (and only recently unregistered)', - title: 'B. Sorted Second', + title: 'Should Be Dropped (unregistered)', }, }); return result; @@ -540,7 +540,7 @@ describe('both/state/selectors/conversations', () => { const ids = result.map(contact => contact.id); assert.deepEqual(ids, [ 'convo-1', - 'convo-7', + 'convo-5', 'convo-6', 'our-conversation-id', ]); @@ -551,7 +551,7 @@ describe('both/state/selectors/conversations', () => { const result = getComposeContacts(state); const ids = result.map(contact => contact.id); - assert.deepEqual(ids, ['convo-1', 'convo-7']); + assert.deepEqual(ids, ['convo-1', 'convo-5']); }); }); @@ -590,14 +590,14 @@ describe('both/state/selectors/conversations', () => { 'convo-5': { ...getDefaultConversation('convo-5'), discoveredUnregisteredAt: new Date(1999, 3, 20).getTime(), - name: 'My Name', - title: 'Should Be Dropped (unregistered)', + name: 'In System Contacts (and unregistered too long ago)', + title: 'C. Sorted Third', }, 'convo-6': { ...getDefaultConversation('convo-6'), discoveredUnregisteredAt: Date.now(), - name: 'In System Contacts (and only recently unregistered)', - title: 'C. Sorted Third', + name: 'My Name', + title: 'Should Be Dropped (unregistered)', }, }, composer: { @@ -624,7 +624,7 @@ describe('both/state/selectors/conversations', () => { const result = getCandidateContactsForNewGroup(state); const ids = result.map(contact => contact.id); - assert.deepEqual(ids, ['convo-1', 'convo-6']); + assert.deepEqual(ids, ['convo-1', 'convo-5']); }); it('can search for contacts', () => { @@ -632,7 +632,7 @@ describe('both/state/selectors/conversations', () => { const result = getCandidateContactsForNewGroup(state); const ids = result.map(contact => contact.id); - assert.deepEqual(ids, ['convo-1', 'convo-6']); + assert.deepEqual(ids, ['convo-1', 'convo-5']); }); }); diff --git a/ts/test-both/util/isConversationUnregistered_test.ts b/ts/test-both/util/isConversationUnregistered_test.ts index c00a3f5bd3..bed76f26f7 100644 --- a/ts/test-both/util/isConversationUnregistered_test.ts +++ b/ts/test-both/util/isConversationUnregistered_test.ts @@ -13,35 +13,35 @@ describe('isConversationUnregistered', () => { ); }); - it('returns false if passed a time fewer than 6 hours ago', () => { - assert.isFalse( + it('returns true if passed a time fewer than 6 hours ago', () => { + assert.isTrue( isConversationUnregistered({ discoveredUnregisteredAt: Date.now() }) ); const fiveHours = 1000 * 60 * 60 * 5; - assert.isFalse( + assert.isTrue( isConversationUnregistered({ discoveredUnregisteredAt: Date.now() - fiveHours, }) ); }); - it('returns false if passed a time in the future', () => { - assert.isFalse( + it('returns true if passed a time in the future', () => { + assert.isTrue( isConversationUnregistered({ discoveredUnregisteredAt: Date.now() + 123 }) ); }); - it('returns true if passed a time more than 6 hours ago', () => { + it('returns false if passed a time more than 6 hours ago', () => { const oneMinute = 1000 * 60; const sixHours = 1000 * 60 * 60 * 6; - assert.isTrue( + assert.isFalse( isConversationUnregistered({ discoveredUnregisteredAt: Date.now() - sixHours - oneMinute, }) ); - assert.isTrue( + assert.isFalse( isConversationUnregistered({ discoveredUnregisteredAt: new Date(1999, 3, 20).getTime(), }) diff --git a/ts/textsecure/OutgoingMessage.ts b/ts/textsecure/OutgoingMessage.ts index bb372fd2fb..5179002f6c 100644 --- a/ts/textsecure/OutgoingMessage.ts +++ b/ts/textsecure/OutgoingMessage.ts @@ -633,6 +633,11 @@ export default class OutgoingMessage { }); identifier = uuid; } else { + const c = window.ConversationController.get(identifier); + if (c) { + c.setUnregistered(); + } + throw new UnregisteredUserError( identifier, new Error('User is not registered') diff --git a/ts/util/isConversationUnregistered.ts b/ts/util/isConversationUnregistered.ts index e50b0f1197..5a324b7f12 100644 --- a/ts/util/isConversationUnregistered.ts +++ b/ts/util/isConversationUnregistered.ts @@ -8,6 +8,6 @@ export function isConversationUnregistered({ }: Readonly<{ discoveredUnregisteredAt?: number }>): boolean { return Boolean( discoveredUnregisteredAt && - discoveredUnregisteredAt < Date.now() - SIX_HOURS + discoveredUnregisteredAt > Date.now() - SIX_HOURS ); } From f98c3cba8cde5e6e2b9a49f4489abb2595986565 Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Mon, 15 Mar 2021 16:45:42 -0700 Subject: [PATCH 088/181] Preliminary support for better performance testing --- ts/textsecure/AccountManager.ts | 19 +++++++++++-------- ts/textsecure/WebAPI.ts | 4 +++- ts/util/lint/exceptions.json | 2 +- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/ts/textsecure/AccountManager.ts b/ts/textsecure/AccountManager.ts index 7b3691c836..befb493dd3 100644 --- a/ts/textsecure/AccountManager.ts +++ b/ts/textsecure/AccountManager.ts @@ -211,14 +211,17 @@ export default class AccountManager extends EventTarget { const proto = window.textsecure.protobuf.ProvisioningUuid.decode( request.body ); - setProvisioningUrl( - [ - 'tsdevice:/?uuid=', - proto.uuid, - '&pub_key=', - encodeURIComponent(btoa(utils.getString(pubKey))), - ].join('') - ); + const url = [ + 'tsdevice:/?uuid=', + proto.uuid, + '&pub_key=', + encodeURIComponent(btoa(utils.getString(pubKey))), + ].join(''); + + // eslint-disable-next-line no-console + console._log(`provisioning url ${url}`); + + setProvisioningUrl(url); request.respond(200, 'OK'); } else if ( request.path === '/v1/message' && diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index 653c742a53..39098b47d8 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -448,7 +448,9 @@ async function _promiseAjax( } else if ( (options.responseType === 'json' || options.responseType === 'jsonwithdetails') && - response.headers.get('Content-Type') === 'application/json' + /^application\/json(;.*)?$/.test( + response.headers.get('Content-Type') || '' + ) ) { resultPromise = response.json(); } else if ( diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 69164d720e..ba16ed64a7 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -15170,7 +15170,7 @@ "rule": "jQuery-wrap(", "path": "ts/textsecure/WebAPI.ts", "line": " const byteBuffer = window.dcodeIO.ByteBuffer.wrap(", - "lineNumber": 2230, + "lineNumber": 2232, "reasonCategory": "falseMatch", "updated": "2020-09-08T23:07:22.682Z" }, From 05f59f3db1958a0acf88d4e91aa4e8498b0a0513 Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Mon, 15 Mar 2021 17:59:48 -0700 Subject: [PATCH 089/181] Add download button and pending spinner for audio messages --- _locales/en/messages.json | 8 + images/icons/v2/arrow-down-20.svg | 1 + images/icons/v2/audio-spinner-arc-22.svg | 1 + stylesheets/components/MessageAudio.scss | 37 +-- .../conversation/Message.stories.tsx | 33 +++ ts/components/conversation/Message.tsx | 15 +- ts/components/conversation/MessageAudio.tsx | 241 ++++++++++++------ ts/state/smart/MessageAudio.tsx | 4 +- ts/util/lint/exceptions.json | 8 +- 9 files changed, 246 insertions(+), 102 deletions(-) create mode 100644 images/icons/v2/arrow-down-20.svg create mode 100644 images/icons/v2/audio-spinner-arc-22.svg diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 5fcecf5979..585136922b 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -5103,6 +5103,14 @@ "message": "Pause audio attachment", "description": "Aria label for audio attachment's Pause button" }, + "MessageAudio--download": { + "message": "Download audio attachment", + "description": "Aria label for audio attachment's Download button" + }, + "MessageAudio--pending": { + "message": "Downloading audio attachment...", + "description": "Aria label for pending audio attachment spinner" + }, "MessageAudio--slider": { "message": "Playback time of audio attachment", "description": "Aria label for audio attachment's playback time slider" diff --git a/images/icons/v2/arrow-down-20.svg b/images/icons/v2/arrow-down-20.svg new file mode 100644 index 0000000000..ab05d2080a --- /dev/null +++ b/images/icons/v2/arrow-down-20.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v2/audio-spinner-arc-22.svg b/images/icons/v2/audio-spinner-arc-22.svg new file mode 100644 index 0000000000..73d0d7d216 --- /dev/null +++ b/images/icons/v2/audio-spinner-arc-22.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/stylesheets/components/MessageAudio.scss b/stylesheets/components/MessageAudio.scss index c0b80ae612..dfb7befcf3 100644 --- a/stylesheets/components/MessageAudio.scss +++ b/stylesheets/components/MessageAudio.scss @@ -59,7 +59,8 @@ margin-top: 6px; } -.module-message__audio-attachment__button { +.module-message__audio-attachment__button, +.module-message__audio-attachment__spinner { flex-shrink: 0; width: 36px; height: 36px; @@ -75,22 +76,28 @@ content: ''; } - @mixin audio-icon($name, $color) { + @mixin audio-icon($name, $icon, $color) { &--#{$name}::before { - @include color-svg( - '../images/icons/v2/#{$name}-solid-20.svg', - $color, - false - ); + @include color-svg('../images/icons/v2/#{$icon}.svg', $color, false); } } + @mixin all-audio-icons($color) { + @include audio-icon(play, play-solid-20, $color); + @include audio-icon(pause, pause-solid-20, $color); + @include audio-icon(download, arrow-down-20, $color); + @include audio-icon(pending, audio-spinner-arc-22, $color); + } + + &--pending::before { + animation: spinner-arc-animation 1000ms linear infinite; + } + .module-message__audio-attachment--incoming & { @mixin android { background: $color-white-alpha-20; - @include audio-icon(play, $color-white); - @include audio-icon(pause, $color-white); + @include all-audio-icons($color-white); } @include light-theme { @@ -102,14 +109,12 @@ @include ios-theme { background: $color-white; - @include audio-icon(play, $color-gray-60); - @include audio-icon(pause, $color-gray-60); + @include all-audio-icons($color-gray-60); } @include ios-dark-theme { background: $color-gray-60; - @include audio-icon(play, $color-gray-15); - @include audio-icon(pause, $color-gray-15); + @include all-audio-icons($color-gray-15); } } @@ -117,15 +122,13 @@ @mixin android { background: $color-white; - @include audio-icon(play, $color-gray-60); - @include audio-icon(pause, $color-gray-60); + @include all-audio-icons($color-gray-60); } @mixin ios { background: $color-white-alpha-20; - @include audio-icon(play, $color-white); - @include audio-icon(pause, $color-white); + @include all-audio-icons($color-white); } @include light-theme { diff --git a/ts/components/conversation/Message.stories.tsx b/ts/components/conversation/Message.stories.tsx index 87c591a461..bc88775df3 100644 --- a/ts/components/conversation/Message.stories.tsx +++ b/ts/components/conversation/Message.stories.tsx @@ -753,6 +753,39 @@ story.add('Audio with Caption', () => { return renderBothDirections(props); }); +story.add('Audio with Not Downloaded Attachment', () => { + const props = createProps({ + attachments: [ + { + contentType: AUDIO_MP3, + fileName: 'incompetech-com-Agnus-Dei-X.mp3', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + url: undefined as any, + }, + ], + status: 'sent', + }); + + return renderBothDirections(props); +}); + +story.add('Audio with Pending Attachment', () => { + const props = createProps({ + attachments: [ + { + contentType: AUDIO_MP3, + fileName: 'incompetech-com-Agnus-Dei-X.mp3', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + url: undefined as any, + pending: true, + }, + ], + status: 'sent', + }); + + return renderBothDirections(props); +}); + story.add('Other File Type', () => { const props = createProps({ attachments: [ diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 37f4f76f7f..8894788d5f 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -85,9 +85,11 @@ export type AudioAttachmentProps = { buttonRef: React.RefObject; direction: DirectionType; theme: ThemeType | undefined; - url: string; + attachment: AttachmentType; withContentAbove: boolean; withContentBelow: boolean; + + kickOffAttachmentDownload(): void; }; export type PropsData = { @@ -754,16 +756,23 @@ export class Message extends React.PureComponent {
); } - if (!firstAttachment.pending && isAudio(attachments)) { + if (isAudio(attachments)) { return renderAudioAttachment({ i18n, buttonRef: this.audioButtonRef, id, direction, theme, - url: firstAttachment.url, + attachment: firstAttachment, withContentAbove, withContentBelow, + + kickOffAttachmentDownload() { + kickOffAttachmentDownload({ + attachment: firstAttachment, + messageId: id, + }); + }, }); } const { pending, fileName, fileSize, contentType } = firstAttachment; diff --git a/ts/components/conversation/MessageAudio.tsx b/ts/components/conversation/MessageAudio.tsx index 1492d28c75..849520a36e 100644 --- a/ts/components/conversation/MessageAudio.tsx +++ b/ts/components/conversation/MessageAudio.tsx @@ -8,12 +8,13 @@ import { noop } from 'lodash'; import { assert } from '../../util/assert'; import { LocalizerType } from '../../types/Util'; import { WaveformCache } from '../../types/Audio'; +import { hasNotDownloaded, AttachmentType } from '../../types/Attachment'; export type Props = { direction?: 'incoming' | 'outgoing'; id: string; i18n: LocalizerType; - url: string; + attachment: AttachmentType; withContentAbove: boolean; withContentBelow: boolean; @@ -23,11 +24,21 @@ export type Props = { waveformCache: WaveformCache; buttonRef: React.RefObject; + kickOffAttachmentDownload(): void; activeAudioID: string | undefined; setActiveAudioID: (id: string | undefined) => void; }; +type ButtonProps = { + i18n: LocalizerType; + buttonRef: React.RefObject; + + mod: string; + label: string; + onClick: () => void; +}; + type LoadAudioOptions = { audioContext: AudioContext; waveformCache: WaveformCache; @@ -39,10 +50,17 @@ type LoadAudioResult = { peaks: ReadonlyArray; }; +enum State { + NotDownloaded = 'NotDownloaded', + Pending = 'Pending', + Normal = 'Normal', +} + // Constants const CSS_BASE = 'module-message__audio-attachment'; const PEAK_COUNT = 47; +const BAR_NOT_DOWNLOADED_HEIGHT = 2; const BAR_MIN_HEIGHT = 4; const BAR_MAX_HEIGHT = 20; @@ -130,6 +148,43 @@ async function loadAudio(options: LoadAudioOptions): Promise { return result; } +const Button: React.FC = props => { + const { i18n, buttonRef, mod, label, onClick } = props; + // Clicking button toggle playback + const onButtonClick = (event: React.MouseEvent) => { + event.stopPropagation(); + event.preventDefault(); + + onClick(); + }; + + // Keyboard playback toggle + const onButtonKeyDown = (event: React.KeyboardEvent) => { + if (event.key !== 'Enter' && event.key !== 'Space') { + return; + } + event.stopPropagation(); + event.preventDefault(); + + onClick(); + }; + + return ( +