Improve experience for contacts without signal accounts

This commit is contained in:
Fedor Indutny 2021-05-13 13:57:27 -07:00 committed by Scott Nonnenberg
parent fe505a7f2f
commit 7fa730531a
11 changed files with 266 additions and 3 deletions

View File

@ -4615,6 +4615,18 @@
"message": "Attach file", "message": "Attach file",
"description": "Aria label for file attachment button in composition area" "description": "Aria label for file attachment button in composition area"
}, },
"CompositionArea--sms-only__title": {
"message": "This person isnt 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": { "countMutedConversationsDescription": {
"message": "Count muted conversations in badge count", "message": "Count muted conversations in badge count",
"description": "Description for counting muted conversations in badge setting" "description": "Description for counting muted conversations in badge setting"

View File

@ -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
.module-last-seen-indicator { .module-last-seen-indicator {

View File

@ -74,6 +74,9 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
onStartGroupMigration: action('onStartGroupMigration'), onStartGroupMigration: action('onStartGroupMigration'),
// GroupV2 Pending Approval Actions // GroupV2 Pending Approval Actions
onCancelJoinRequest: action('onCancelJoinRequest'), onCancelJoinRequest: action('onCancelJoinRequest'),
// SMS-only
isSMSOnly: overrideProps.isSMSOnly || false,
isFetchingUUID: overrideProps.isFetchingUUID || false,
}); });
story.add('Default', () => { story.add('Default', () => {
@ -106,3 +109,20 @@ story.add('Message Request', () => {
return <CompositionArea {...props} />; return <CompositionArea {...props} />;
}); });
story.add('SMS-only fetching UUID', () => {
const props = createProps({
isSMSOnly: true,
isFetchingUUID: true,
});
return <CompositionArea {...props} />;
});
story.add('SMS-only', () => {
const props = createProps({
isSMSOnly: true,
});
return <CompositionArea {...props} />;
});

View File

@ -4,6 +4,7 @@
import * as React from 'react'; import * as React from 'react';
import { get, noop } from 'lodash'; import { get, noop } from 'lodash';
import classNames from 'classnames'; import classNames from 'classnames';
import { Spinner } from './Spinner';
import { EmojiButton, Props as EmojiButtonProps } from './emoji/EmojiButton'; import { EmojiButton, Props as EmojiButtonProps } from './emoji/EmojiButton';
import { import {
Props as StickerButtonProps, Props as StickerButtonProps,
@ -38,6 +39,8 @@ export type OwnProps = {
readonly groupVersion?: 1 | 2; readonly groupVersion?: 1 | 2;
readonly isGroupV1AndDisabled?: boolean; readonly isGroupV1AndDisabled?: boolean;
readonly isMissingMandatoryProfileSharing?: boolean; readonly isMissingMandatoryProfileSharing?: boolean;
readonly isSMSOnly?: boolean;
readonly isFetchingUUID?: boolean;
readonly left?: boolean; readonly left?: boolean;
readonly messageRequestsEnabled?: boolean; readonly messageRequestsEnabled?: boolean;
readonly acceptedMessageRequest?: boolean; readonly acceptedMessageRequest?: boolean;
@ -157,6 +160,9 @@ export const CompositionArea = ({
onStartGroupMigration, onStartGroupMigration,
// GroupV2 Pending Approval Actions // GroupV2 Pending Approval Actions
onCancelJoinRequest, onCancelJoinRequest,
// SMS-only contacts
isSMSOnly,
isFetchingUUID,
}: Props): JSX.Element => { }: Props): JSX.Element => {
const [disabled, setDisabled] = React.useState(false); const [disabled, setDisabled] = React.useState(false);
const [showMic, setShowMic] = React.useState(!draftText); const [showMic, setShowMic] = React.useState(!draftText);
@ -382,6 +388,36 @@ export const CompositionArea = ({
); );
} }
if (conversationType === 'direct' && isSMSOnly) {
return (
<div
className={classNames([
'module-composition-area',
'module-composition-area--sms-only',
isFetchingUUID ? 'module-composition-area--pending' : null,
])}
>
{isFetchingUUID ? (
<Spinner
ariaLabel={i18n('CompositionArea--sms-only__spinner-label')}
role="presentation"
moduleClassName="module-image-spinner"
svgSize="small"
/>
) : (
<>
<h2 className="module-composition-area--sms-only__title">
{i18n('CompositionArea--sms-only__title')}
</h2>
<p className="module-composition-area--sms-only__body">
{i18n('CompositionArea--sms-only__body')}
</p>
</>
)}
</div>
);
}
// If no message request, but we haven't shared profile yet, we show profile-sharing UI // If no message request, but we haven't shared profile yet, we show profile-sharing UI
if ( if (
!left && !left &&

View File

@ -20,17 +20,21 @@ export const SpinnerDirections = [
export type SpinnerDirection = typeof SpinnerDirections[number]; export type SpinnerDirection = typeof SpinnerDirections[number];
export type Props = { export type Props = {
moduleClassName?: string; ariaLabel?: string;
direction?: SpinnerDirection; direction?: SpinnerDirection;
moduleClassName?: string;
role?: string;
size?: string; size?: string;
svgSize: SpinnerSvgSize; svgSize: SpinnerSvgSize;
}; };
export const Spinner = ({ export const Spinner = ({
ariaLabel,
direction,
moduleClassName, moduleClassName,
role,
size, size,
svgSize, svgSize,
direction,
}: Props): JSX.Element => { }: Props): JSX.Element => {
const getClassName = getClassNamesFor('module-spinner', moduleClassName); const getClassName = getClassNamesFor('module-spinner', moduleClassName);
@ -42,6 +46,8 @@ export const Spinner = ({
getClassName(direction && `__container--${direction}`), getClassName(direction && `__container--${direction}`),
getClassName(direction && `__container--${svgSize}-${direction}`) getClassName(direction && `__container--${svgSize}-${direction}`)
)} )}
role={role}
aria-label={ariaLabel}
style={{ style={{
height: size, height: size,
width: size, width: size,

View File

@ -23,6 +23,7 @@ import { ConversationType } from '../state/ducks/conversations';
import { ColorType } from '../types/Colors'; import { ColorType } from '../types/Colors';
import { MessageModel } from './messages'; import { MessageModel } from './messages';
import { isMuted } from '../util/isMuted'; import { isMuted } from '../util/isMuted';
import { isConversationSMSOnly } from '../util/isConversationSMSOnly';
import { isConversationUnregistered } from '../util/isConversationUnregistered'; import { isConversationUnregistered } from '../util/isConversationUnregistered';
import { missingCaseError } from '../util/missingCaseError'; import { missingCaseError } from '../util/missingCaseError';
import { sniffImageMimeType } from '../util/sniffImageMimeType'; import { sniffImageMimeType } from '../util/sniffImageMimeType';
@ -48,6 +49,7 @@ import { markConversationRead } from '../util/markConversationRead';
import { handleMessageSend } from '../util/handleMessageSend'; import { handleMessageSend } from '../util/handleMessageSend';
import { getConversationMembers } from '../util/getConversationMembers'; import { getConversationMembers } from '../util/getConversationMembers';
import { sendReadReceiptsFor } from '../util/sendReadReceiptsFor'; import { sendReadReceiptsFor } from '../util/sendReadReceiptsFor';
import { updateConversationsWithUuidLookup } from '../updateConversationsWithUuidLookup';
/* eslint-disable more/no-then */ /* eslint-disable more/no-then */
window.Whisper = window.Whisper || {}; window.Whisper = window.Whisper || {};
@ -88,6 +90,7 @@ const COLORS = [
]; ];
const THREE_HOURS = 3 * 60 * 60 * 1000; const THREE_HOURS = 3 * 60 * 60 * 1000;
const FIVE_MINUTES = 1000 * 60 * 5;
type CustomError = Error & { type CustomError = Error & {
identifier?: string; identifier?: string;
@ -140,6 +143,8 @@ export class ConversationModel extends window.Backbone
throttledBumpTyping: unknown; throttledBumpTyping: unknown;
throttledFetchSMSOnlyUUID?: () => Promise<void> | void;
typingRefreshTimer?: NodeJS.Timer | null; typingRefreshTimer?: NodeJS.Timer | null;
typingPauseTimer?: NodeJS.Timer | null; typingPauseTimer?: NodeJS.Timer | null;
@ -154,6 +159,8 @@ export class ConversationModel extends window.Backbone
private cachedIdenticon?: CachedIdenticon; private cachedIdenticon?: CachedIdenticon;
private isFetchingUUID?: boolean;
// eslint-disable-next-line class-methods-use-this // eslint-disable-next-line class-methods-use-this
defaults(): Partial<ConversationAttributesType> { defaults(): Partial<ConversationAttributesType> {
return { return {
@ -281,6 +288,15 @@ export class ConversationModel extends window.Backbone
} }
this.cachedProps = null; 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 { isMe(): boolean {
@ -763,6 +779,13 @@ export class ConversationModel extends window.Backbone
return isConversationUnregistered(this.attributes); return isConversationUnregistered(this.attributes);
} }
isSMSOnly(): boolean {
return isConversationSMSOnly({
...this.attributes,
type: this.isPrivate() ? 'direct' : 'unknown',
});
}
setUnregistered(): void { setUnregistered(): void {
window.log.info(`Conversation ${this.idForLogging()} is now unregistered`); window.log.info(`Conversation ${this.idForLogging()} is now unregistered`);
this.set({ this.set({
@ -987,6 +1010,45 @@ export class ConversationModel extends window.Backbone
}); });
} }
async fetchSMSOnlyUUID(): Promise<void> {
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 { isValid(): boolean {
return this.isPrivate() || this.isGroupV1() || this.isGroupV2(); return this.isPrivate() || this.isGroupV1() || this.isGroupV2();
} }
@ -1358,6 +1420,7 @@ export class ConversationModel extends window.Backbone
isPinned: this.get('isPinned'), isPinned: this.get('isPinned'),
isUntrusted: this.isUntrusted(), isUntrusted: this.isUntrusted(),
isVerified: this.isVerified(), isVerified: this.isVerified(),
isFetchingUUID: this.isFetchingUUID,
lastMessage: { lastMessage: {
status: this.get('lastMessageStatus')!, status: this.get('lastMessageStatus')!,
text: this.get('lastMessage')!, text: this.get('lastMessage')!,

View File

@ -114,6 +114,7 @@ export type ConversationType = {
searchableTitle?: string; searchableTitle?: string;
unreadCount?: number; unreadCount?: number;
isSelected?: boolean; isSelected?: boolean;
isFetchingUUID?: boolean;
typingContact?: { typingContact?: {
avatarPath?: string; avatarPath?: string;
color?: ColorType; color?: ColorType;
@ -568,7 +569,6 @@ export type ToggleConversationInChooseMembersActionType = {
maxGroupSize: number; maxGroupSize: number;
}; };
}; };
export type ConversationActionType = export type ConversationActionType =
| CantAddContactToGroupActionType | CantAddContactToGroupActionType
| ClearChangedMessagesActionType | ClearChangedMessagesActionType

View File

@ -6,6 +6,7 @@ import { get } from 'lodash';
import { mapDispatchToProps } from '../actions'; import { mapDispatchToProps } from '../actions';
import { CompositionArea } from '../../components/CompositionArea'; import { CompositionArea } from '../../components/CompositionArea';
import { StateType } from '../reducer'; import { StateType } from '../reducer';
import { isConversationSMSOnly } from '../../util/isConversationSMSOnly';
import { selectRecentEmojis } from '../selectors/emojis'; import { selectRecentEmojis } from '../selectors/emojis';
import { getIntl } from '../selectors/user'; import { getIntl } from '../selectors/user';
@ -72,6 +73,8 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
// Message Requests // Message Requests
...conversation, ...conversation,
conversationType: conversation.type, conversationType: conversation.type,
isSMSOnly: Boolean(isConversationSMSOnly(conversation)),
isFetchingUUID: conversation.isFetchingUUID,
isMissingMandatoryProfileSharing: Boolean( isMissingMandatoryProfileSharing: Boolean(
!conversation.profileSharing && !conversation.profileSharing &&
window.Signal.RemoteConfig.isEnabled( window.Signal.RemoteConfig.isEnabled(

View File

@ -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' }));
});
});

View File

@ -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);
}

View File

@ -2178,6 +2178,11 @@ Whisper.ConversationView = Whisper.View.extend({
this.model.fetchLatestGroupV2Data(); this.model.fetchLatestGroupV2Data();
this.model.throttledMaybeMigrateV1Group(); this.model.throttledMaybeMigrateV1Group();
assert(
this.model.throttledFetchSMSOnlyUUID !== undefined,
'Conversation model should be initialized'
);
this.model.throttledFetchSMSOnlyUUID();
const statusPromise = this.model.throttledGetProfiles(); const statusPromise = this.model.throttledGetProfiles();
// eslint-disable-next-line more/no-then // eslint-disable-next-line more/no-then