diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 565cbbe9d..22ca24c4f 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -4615,6 +4615,18 @@ "message": "Attach file", "description": "Aria label for file attachment button in composition area" }, + "CompositionArea--sms-only__title": { + "message": "This person isn’t using Signal", + "description": "Title for the composition area for the SMS-only contact" + }, + "CompositionArea--sms-only__body": { + "message": "Signal Desktop does not support messaging non-Signal contacts. Ask this person to install Signal for a more secure messaging experience.", + "description": "Body for the composition area for the SMS-only contact" + }, + "CompositionArea--sms-only__spinner-label": { + "message": "Checking contact's registration status", + "description": "Displayed while checking if the contact is SMS-only" + }, "countMutedConversationsDescription": { "message": "Count muted conversations in badge count", "description": "Description for counting muted conversations in badge setting" diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index f35ed2e81..4bfd06bbe 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -9250,6 +9250,50 @@ button.module-image__border-overlay:focus { } } +.module-composition-area--sms-only { + display: flex; + flex-direction: column; + align-items: center; + + // Note the margine in .composition-area-placeholder above + padding: 14px 16px 18px 16px; + + &:not(.module-composition-area--pending) { + @include light-theme { + border-top: 1px solid $color-gray-05; + } + @include dark-theme { + border-top: 1px solid $color-gray-75; + } + } + + &__title { + @include font-body-2-bold; + margin: 0 0 2px 0; + + @include light-theme { + color: $color-gray-60; + } + @include dark-theme { + color: $color-gray-05; + } + } + + &__body { + @include font-body-2; + text-align: center; + + margin: 0; + + @include light-theme { + color: $color-gray-60; + } + @include dark-theme { + color: $color-gray-05; + } + } +} + // Module: Last Seen Indicator .module-last-seen-indicator { diff --git a/ts/components/CompositionArea.stories.tsx b/ts/components/CompositionArea.stories.tsx index b70abcdc8..345c7d6e5 100644 --- a/ts/components/CompositionArea.stories.tsx +++ b/ts/components/CompositionArea.stories.tsx @@ -74,6 +74,9 @@ const createProps = (overrideProps: Partial = {}): Props => ({ onStartGroupMigration: action('onStartGroupMigration'), // GroupV2 Pending Approval Actions onCancelJoinRequest: action('onCancelJoinRequest'), + // SMS-only + isSMSOnly: overrideProps.isSMSOnly || false, + isFetchingUUID: overrideProps.isFetchingUUID || false, }); story.add('Default', () => { @@ -106,3 +109,20 @@ story.add('Message Request', () => { return ; }); + +story.add('SMS-only fetching UUID', () => { + const props = createProps({ + isSMSOnly: true, + isFetchingUUID: true, + }); + + return ; +}); + +story.add('SMS-only', () => { + const props = createProps({ + isSMSOnly: true, + }); + + return ; +}); diff --git a/ts/components/CompositionArea.tsx b/ts/components/CompositionArea.tsx index dcb3cda86..1c4c24655 100644 --- a/ts/components/CompositionArea.tsx +++ b/ts/components/CompositionArea.tsx @@ -4,6 +4,7 @@ import * as React from 'react'; import { get, noop } from 'lodash'; import classNames from 'classnames'; +import { Spinner } from './Spinner'; import { EmojiButton, Props as EmojiButtonProps } from './emoji/EmojiButton'; import { Props as StickerButtonProps, @@ -38,6 +39,8 @@ export type OwnProps = { readonly groupVersion?: 1 | 2; readonly isGroupV1AndDisabled?: boolean; readonly isMissingMandatoryProfileSharing?: boolean; + readonly isSMSOnly?: boolean; + readonly isFetchingUUID?: boolean; readonly left?: boolean; readonly messageRequestsEnabled?: boolean; readonly acceptedMessageRequest?: boolean; @@ -157,6 +160,9 @@ export const CompositionArea = ({ onStartGroupMigration, // GroupV2 Pending Approval Actions onCancelJoinRequest, + // SMS-only contacts + isSMSOnly, + isFetchingUUID, }: Props): JSX.Element => { const [disabled, setDisabled] = React.useState(false); const [showMic, setShowMic] = React.useState(!draftText); @@ -382,6 +388,36 @@ export const CompositionArea = ({ ); } + if (conversationType === 'direct' && isSMSOnly) { + return ( +
+ {isFetchingUUID ? ( + + ) : ( + <> +

+ {i18n('CompositionArea--sms-only__title')} +

+

+ {i18n('CompositionArea--sms-only__body')} +

+ + )} +
+ ); + } + // If no message request, but we haven't shared profile yet, we show profile-sharing UI if ( !left && diff --git a/ts/components/Spinner.tsx b/ts/components/Spinner.tsx index 9da55878c..3419a12d6 100644 --- a/ts/components/Spinner.tsx +++ b/ts/components/Spinner.tsx @@ -20,17 +20,21 @@ export const SpinnerDirections = [ export type SpinnerDirection = typeof SpinnerDirections[number]; export type Props = { - moduleClassName?: string; + ariaLabel?: string; direction?: SpinnerDirection; + moduleClassName?: string; + role?: string; size?: string; svgSize: SpinnerSvgSize; }; export const Spinner = ({ + ariaLabel, + direction, moduleClassName, + role, size, svgSize, - direction, }: Props): JSX.Element => { const getClassName = getClassNamesFor('module-spinner', moduleClassName); @@ -42,6 +46,8 @@ export const Spinner = ({ getClassName(direction && `__container--${direction}`), getClassName(direction && `__container--${svgSize}-${direction}`) )} + role={role} + aria-label={ariaLabel} style={{ height: size, width: size, diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 121807e9f..5c8c6abb8 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -23,6 +23,7 @@ import { ConversationType } from '../state/ducks/conversations'; import { ColorType } from '../types/Colors'; import { MessageModel } from './messages'; import { isMuted } from '../util/isMuted'; +import { isConversationSMSOnly } from '../util/isConversationSMSOnly'; import { isConversationUnregistered } from '../util/isConversationUnregistered'; import { missingCaseError } from '../util/missingCaseError'; import { sniffImageMimeType } from '../util/sniffImageMimeType'; @@ -48,6 +49,7 @@ import { markConversationRead } from '../util/markConversationRead'; import { handleMessageSend } from '../util/handleMessageSend'; import { getConversationMembers } from '../util/getConversationMembers'; import { sendReadReceiptsFor } from '../util/sendReadReceiptsFor'; +import { updateConversationsWithUuidLookup } from '../updateConversationsWithUuidLookup'; /* eslint-disable more/no-then */ window.Whisper = window.Whisper || {}; @@ -88,6 +90,7 @@ const COLORS = [ ]; const THREE_HOURS = 3 * 60 * 60 * 1000; +const FIVE_MINUTES = 1000 * 60 * 5; type CustomError = Error & { identifier?: string; @@ -140,6 +143,8 @@ export class ConversationModel extends window.Backbone throttledBumpTyping: unknown; + throttledFetchSMSOnlyUUID?: () => Promise | void; + typingRefreshTimer?: NodeJS.Timer | null; typingPauseTimer?: NodeJS.Timer | null; @@ -154,6 +159,8 @@ export class ConversationModel extends window.Backbone private cachedIdenticon?: CachedIdenticon; + private isFetchingUUID?: boolean; + // eslint-disable-next-line class-methods-use-this defaults(): Partial { return { @@ -281,6 +288,15 @@ export class ConversationModel extends window.Backbone } this.cachedProps = null; }); + + // Set `isFetchingUUID` eagerly to avoid UI flicker when opening the + // conversation for the first time. + this.isFetchingUUID = this.isSMSOnly(); + + this.throttledFetchSMSOnlyUUID = window._.throttle( + this.fetchSMSOnlyUUID, + FIVE_MINUTES + ); } isMe(): boolean { @@ -763,6 +779,13 @@ export class ConversationModel extends window.Backbone return isConversationUnregistered(this.attributes); } + isSMSOnly(): boolean { + return isConversationSMSOnly({ + ...this.attributes, + type: this.isPrivate() ? 'direct' : 'unknown', + }); + } + setUnregistered(): void { window.log.info(`Conversation ${this.idForLogging()} is now unregistered`); this.set({ @@ -987,6 +1010,45 @@ export class ConversationModel extends window.Backbone }); } + async fetchSMSOnlyUUID(): Promise { + const { messaging } = window.textsecure; + if (!messaging) { + return; + } + if (!this.isSMSOnly()) { + return; + } + + window.log.info( + `Fetching uuid for a sms-only conversation ${this.idForLogging()}` + ); + + this.isFetchingUUID = true; + this.trigger('change', this); + + try { + // Attempt to fetch UUID + await updateConversationsWithUuidLookup({ + conversationController: window.ConversationController, + conversations: [this], + messaging, + }); + } finally { + // No redux update here + this.isFetchingUUID = false; + this.trigger('change', this); + + window.log.info( + `Done fetching uuid for a sms-only conversation ${this.idForLogging()}` + ); + } + + // On successful fetch - mark contact as registered. + if (this.get('uuid')) { + this.setRegistered(); + } + } + isValid(): boolean { return this.isPrivate() || this.isGroupV1() || this.isGroupV2(); } @@ -1358,6 +1420,7 @@ export class ConversationModel extends window.Backbone isPinned: this.get('isPinned'), isUntrusted: this.isUntrusted(), isVerified: this.isVerified(), + isFetchingUUID: this.isFetchingUUID, lastMessage: { status: this.get('lastMessageStatus')!, text: this.get('lastMessage')!, diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 1a44df441..b843011eb 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -114,6 +114,7 @@ export type ConversationType = { searchableTitle?: string; unreadCount?: number; isSelected?: boolean; + isFetchingUUID?: boolean; typingContact?: { avatarPath?: string; color?: ColorType; @@ -568,7 +569,6 @@ export type ToggleConversationInChooseMembersActionType = { maxGroupSize: number; }; }; - export type ConversationActionType = | CantAddContactToGroupActionType | ClearChangedMessagesActionType diff --git a/ts/state/smart/CompositionArea.tsx b/ts/state/smart/CompositionArea.tsx index 8003e045a..5c8f6c06e 100644 --- a/ts/state/smart/CompositionArea.tsx +++ b/ts/state/smart/CompositionArea.tsx @@ -6,6 +6,7 @@ import { get } from 'lodash'; import { mapDispatchToProps } from '../actions'; import { CompositionArea } from '../../components/CompositionArea'; import { StateType } from '../reducer'; +import { isConversationSMSOnly } from '../../util/isConversationSMSOnly'; import { selectRecentEmojis } from '../selectors/emojis'; import { getIntl } from '../selectors/user'; @@ -72,6 +73,8 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => { // Message Requests ...conversation, conversationType: conversation.type, + isSMSOnly: Boolean(isConversationSMSOnly(conversation)), + isFetchingUUID: conversation.isFetchingUUID, isMissingMandatoryProfileSharing: Boolean( !conversation.profileSharing && window.Signal.RemoteConfig.isEnabled( diff --git a/ts/test-both/util/isConversationSMSOnly_test.ts b/ts/test-both/util/isConversationSMSOnly_test.ts new file mode 100644 index 000000000..b3f96e65a --- /dev/null +++ b/ts/test-both/util/isConversationSMSOnly_test.ts @@ -0,0 +1,47 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import { isConversationSMSOnly } from '../../util/isConversationSMSOnly'; + +describe('isConversationSMSOnly', () => { + it('returns false if passed an undefined discoveredUnregisteredAt', () => { + assert.isFalse(isConversationSMSOnly({})); + assert.isFalse( + isConversationSMSOnly({ discoveredUnregisteredAt: undefined }) + ); + }); + + ['direct', 'private'].forEach(type => { + it(`returns true if passed a time fewer than 6 hours ago and is ${type}`, () => { + assert.isTrue( + isConversationSMSOnly({ + type, + e164: 'e164', + uuid: 'uuid', + discoveredUnregisteredAt: Date.now(), + }) + ); + + const fiveHours = 1000 * 60 * 60 * 5; + assert.isTrue( + isConversationSMSOnly({ + type, + e164: 'e164', + uuid: 'uuid', + discoveredUnregisteredAt: Date.now() - fiveHours, + }) + ); + }); + + it(`returns true conversation is ${type} and has no uuid`, () => { + assert.isTrue(isConversationSMSOnly({ type, e164: 'e164' })); + assert.isFalse(isConversationSMSOnly({ type })); + }); + }); + + it('returns false for groups', () => { + assert.isFalse(isConversationSMSOnly({ type: 'group' })); + }); +}); diff --git a/ts/util/isConversationSMSOnly.ts b/ts/util/isConversationSMSOnly.ts new file mode 100644 index 000000000..8e22cfb1d --- /dev/null +++ b/ts/util/isConversationSMSOnly.ts @@ -0,0 +1,27 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { isConversationUnregistered } from './isConversationUnregistered'; + +export type MinimalConversationType = Readonly<{ + type?: string; + e164?: string; + uuid?: string; + discoveredUnregisteredAt?: number; +}>; + +export function isConversationSMSOnly( + conversation: MinimalConversationType +): boolean { + const { e164, uuid, type } = conversation; + // `direct` for redux, `private` for models and the database + if (type !== 'direct' && type !== 'private') { + return false; + } + + if (e164 && !uuid) { + return true; + } + + return isConversationUnregistered(conversation); +} diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index 5f1ee99f1..7b4f5d6ee 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -2178,6 +2178,11 @@ Whisper.ConversationView = Whisper.View.extend({ this.model.fetchLatestGroupV2Data(); this.model.throttledMaybeMigrateV1Group(); + assert( + this.model.throttledFetchSMSOnlyUUID !== undefined, + 'Conversation model should be initialized' + ); + this.model.throttledFetchSMSOnlyUUID(); const statusPromise = this.model.throttledGetProfiles(); // eslint-disable-next-line more/no-then