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] 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 fdd9ac3ad..16b1373fe 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 000000000..9cf1803e4 --- /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 535a24190..7d23b4bb9 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 c19668e35..f5a3525a0 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 5fd76106c..2c6b22cca 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 000000000..41de5daed --- /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 000000000..147257ef8 --- /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 cf98aa045..000000000 --- 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 99368e7ec..000000000 --- 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 791a0b452..6c5a60996 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 f7b3d4d00..5c3a319f3 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 42bae1449..22f8d5d0d 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 e0c719855..f88550dd0 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 800dc2196..000000000 --- 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 cfb9b972a..000000000 --- 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 135e7a2c0..000000000 --- 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 b1810ece0..4adf76dbf 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 d6bc78fda..000000000 --- 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 34cc5947a..000000000 --- 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 779cded2e..fb3ee855a 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 000000000..7307eb8f2 --- /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 000000000..7632ad6f6 --- /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 000000000..86ac06dfd --- /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 ff689af1b..0700a6c2e 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 14a59c146..43729cbcf 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 c7a6923ca..cde32a194 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 000000000..4b603bdd4 --- /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 000000000..5e140a161 --- /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 000000000..634bbc9cb --- /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 000000000..5800c8101 --- /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 000000000..b51e6ade9 --- /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 000000000..b461aee0e --- /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 000000000..ca4cffa29 --- /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 000000000..1220aef80 --- /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 f2bcb75e8..1d23c1a24 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 129ec242c..c896e055e 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 ed2d3613c..f84965902 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 0c73476db..1777ea356 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 4976649f2..b6073483a 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 079f7a9e9..f00bdb996 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 2bca7627d..51085eb79 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 10f473bd5..1bed153be 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 1b19a3895..53905d1da 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 839e1d8d9..7f0ffb502 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 5e3ebfdf9..78e5f6004 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 6ba2a611b..4238107c6 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 b0bdb4aca..07a4bf1a7 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 000000000..6dc73fb94 --- /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 000000000..18ad7723f --- /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 000000000..c00a3f5bd --- /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 871c62a2f..56e163253 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 755414a43..000000000 --- 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 000000000..1d83e9d2c --- /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 000000000..9967ac845 --- /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 000000000..0e9568ac1 --- /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 000000000..5c9e558f9 --- /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 000000000..02455f3b8 --- /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 000000000..c89dc940a --- /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 000000000..a4a642250 --- /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 000000000..e50b0f119 --- /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 63727572f..0255e8207 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 +]