diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 0883861d1..1f578985c 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1168,6 +1168,16 @@ } } }, + "ChangeNumber--notification": { + "message": "$sender$ changed their number to a new number", + "description": "Shown in timeline when a member of a conversation changes their phone number", + "placeholders": { + "sender": { + "content": "$1", + "example": "Sam" + } + } + }, "quoteThumbnailAlt": { "message": "Thumbnail of image from quoted message", "description": "Used in alt tag of thumbnail images inside of an embedded message quote" diff --git a/protos/SignalStorage.proto b/protos/SignalStorage.proto index 131d3f885..e3fec7ed4 100644 --- a/protos/SignalStorage.proto +++ b/protos/SignalStorage.proto @@ -135,4 +135,5 @@ message AccountRecord { repeated PinnedConversation pinnedConversations = 14; optional uint32 universalExpireTimer = 17; optional bool primarySendsSms = 18; + optional string e164 = 19; } diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 6b39f7bd1..52ce42c91 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -2425,6 +2425,45 @@ $timer-icons: '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05', } } +// Module: Change Number Notification + +.module-change-number-notification { + @include font-body-2; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + + @include light-theme { + color: $color-gray-60; + } + @include dark-theme { + color: $color-gray-05; + } + + &__icon { + height: 16px; + width: 16px; + display: inline-block; + margin-right: 8px; + + @include light-theme { + @include color-svg( + '../images/icons/v2/phone-right-outline-24.svg', + $color-gray-75 + ); + } + @include dark-theme { + @include color-svg( + '../images/icons/v2/phone-right-solid-24.svg', + $color-gray-15 + ); + } + } +} + +// Module: Error Boundary + .module-error-boundary-notification { text-align: center; cursor: pointer; diff --git a/ts/background.ts b/ts/background.ts index f288f43c8..5fe257245 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -60,6 +60,7 @@ import { ReadSyncEvent, ContactEvent, GroupEvent, + EnvelopeEvent, } from './textsecure/messageReceiverEvents'; import type { WebAPIType } from './textsecure/WebAPI'; import * as universalExpireTimer from './util/universalExpireTimer'; @@ -174,6 +175,10 @@ export async function startApp(): Promise { }; } + messageReceiver.addEventListener( + 'envelope', + queuedEventListener(onEnvelopeReceived, false) + ); messageReceiver.addEventListener( 'message', queuedEventListener(onMessageReceived, false) @@ -503,17 +508,7 @@ export async function startApp(): Promise { accountManager = new window.textsecure.AccountManager(server); accountManager.addEventListener('registration', () => { - const ourDeviceId = window.textsecure.storage.user.getDeviceId(); - const ourNumber = window.textsecure.storage.user.getNumber(); - const ourUuid = window.textsecure.storage.user.getUuid(); - const user = { - ourConversationId: window.ConversationController.getOurConversationId(), - ourDeviceId, - ourNumber, - ourUuid, - regionCode: window.storage.get('regionCode'), - }; - window.Whisper.events.trigger('userChanged', user); + window.Whisper.events.trigger('userChanged'); window.Signal.Util.Registration.markDone(); window.log.info('dispatching registration event'); @@ -1210,7 +1205,6 @@ export async function startApp(): Promise { conversationRemoved, removeAllConversations, } = window.reduxActions.conversations; - const { userChanged } = window.reduxActions.user; convoCollection.on('remove', conversation => { const { id } = conversation || {}; @@ -1254,7 +1248,19 @@ export async function startApp(): Promise { }); convoCollection.on('reset', removeAllConversations); - window.Whisper.events.on('userChanged', userChanged); + window.Whisper.events.on('userChanged', () => { + const newDeviceId = window.textsecure.storage.user.getDeviceId(); + const newNumber = window.textsecure.storage.user.getNumber(); + const newUuid = window.textsecure.storage.user.getUuid(); + + window.reduxActions.user.userChanged({ + ourConversationId: window.ConversationController.getOurConversationId(), + ourDeviceId: newDeviceId, + ourNumber: newNumber, + ourUuid: newUuid, + regionCode: window.storage.get('regionCode'), + }); + }); let shortcutGuideView: WhatIsThis | null = null; @@ -3000,6 +3006,17 @@ export async function startApp(): Promise { maxSize: Infinity, }); + function onEnvelopeReceived({ envelope }: EnvelopeEvent) { + const ourUuid = window.textsecure.storage.user.getUuid(); + if (envelope.sourceUuid && envelope.sourceUuid !== ourUuid) { + window.ConversationController.ensureContactIds({ + e164: envelope.source, + uuid: envelope.sourceUuid, + highTrust: true, + }); + } + } + // Note: We do very little in this function, since everything in handleDataMessage is // inside a conversation-specific queue(). Any code here might run before an earlier // message is processed in handleDataMessage(). diff --git a/ts/components/conversation/ChangeNumberNotification.tsx b/ts/components/conversation/ChangeNumberNotification.tsx new file mode 100644 index 000000000..f63ce58c6 --- /dev/null +++ b/ts/components/conversation/ChangeNumberNotification.tsx @@ -0,0 +1,43 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; + +import { ConversationType } from '../../state/ducks/conversations'; +import { LocalizerType } from '../../types/Util'; +import { Intl } from '../Intl'; + +import { Timestamp } from './Timestamp'; +import { Emojify } from './Emojify'; + +export type PropsData = { + sender: ConversationType; + timestamp: number; +}; + +export type PropsHousekeeping = { + i18n: LocalizerType; +}; + +export type Props = PropsData & PropsHousekeeping; + +const CSS_MODULE = 'module-change-number-notification'; + +export const ChangeNumberNotification: React.FC = props => { + const { i18n, sender, timestamp } = props; + + return ( +
+ + , + }} + i18n={i18n} + /> +  ยท  + +
+ ); +}; diff --git a/ts/components/conversation/TimelineItem.stories.tsx b/ts/components/conversation/TimelineItem.stories.tsx index 2b6acb29f..9dd5050f7 100644 --- a/ts/components/conversation/TimelineItem.stories.tsx +++ b/ts/components/conversation/TimelineItem.stories.tsx @@ -129,6 +129,13 @@ storiesOf('Components/Conversation/TimelineItem', module) type: 'universalTimerNotification', data: null, }, + { + type: 'changeNumberNotification', + data: { + sender: getDefaultConversation(), + timestamp: Date.now(), + }, + }, { type: 'callHistory', data: { diff --git a/ts/components/conversation/TimelineItem.tsx b/ts/components/conversation/TimelineItem.tsx index 9ebb8f5e1..f150d62de 100644 --- a/ts/components/conversation/TimelineItem.tsx +++ b/ts/components/conversation/TimelineItem.tsx @@ -24,6 +24,10 @@ import { PropsActionsType as DeliveryIssueActionProps, PropsDataType as DeliveryIssueProps, } from './DeliveryIssueNotification'; +import { + ChangeNumberNotification, + PropsData as ChangeNumberNotificationProps, +} from './ChangeNumberNotification'; import { CallingNotificationType } from '../../util/callingNotification'; import { InlineNotificationWrapper } from './InlineNotificationWrapper'; import { @@ -95,6 +99,10 @@ type UniversalTimerNotificationType = { type: 'universalTimerNotification'; data: null; }; +type ChangeNumberNotificationType = { + type: 'changeNumberNotification'; + data: ChangeNumberNotificationProps; +}; type SafetyNumberNotificationType = { type: 'safetyNumberNotification'; data: SafetyNumberNotificationProps; @@ -138,6 +146,7 @@ export type TimelineItemType = | SafetyNumberNotificationType | TimerNotificationType | UniversalTimerNotificationType + | ChangeNumberNotificationType | UnsupportedMessageType | VerificationNotificationType; @@ -244,6 +253,10 @@ export class TimelineItem extends React.PureComponent { ); } else if (item.type === 'universalTimerNotification') { notification = renderUniversalTimerNotification(); + } else if (item.type === 'changeNumberNotification') { + notification = ( + + ); } else if (item.type === 'safetyNumberNotification') { notification = ( diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 67331006a..a2b68947b 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -141,6 +141,7 @@ export type MessageAttributesType = { | 'profile-change' | 'timer-notification' | 'universal-timer-notification' + | 'change-number-notification' | 'verified-change'; body?: string; attachments?: Array; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 3794543fe..54cb8aac2 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -152,8 +152,6 @@ export class ConversationModel extends window.Backbone jobQueue?: typeof window.PQueueType; - ourNumber?: string; - ourUuid?: string; storeName?: string | null; @@ -234,7 +232,6 @@ export class ConversationModel extends window.Backbone this.storeName = 'conversations'; - this.ourNumber = window.textsecure.storage.user.getNumber(); this.ourUuid = window.textsecure.storage.user.getUuid(); this.verifiedEnum = window.textsecure.storage.protocol.VerifiedStatus; @@ -1497,6 +1494,11 @@ export class ConversationModel extends window.Backbone const oldValue = this.get('e164'); if (e164 && e164 !== oldValue) { this.set('e164', e164); + + if (oldValue) { + this.addChangeNumberNotification(); + } + window.Signal.Data.updateConversation(this.attributes); this.trigger('idUpdated', this, 'e164', oldValue); } @@ -2688,7 +2690,7 @@ export class ConversationModel extends window.Backbone sent_at: now, received_at: window.Signal.Util.incrementMessageCounter(), received_at_ms: now, - unread: 0, + unread: false, changedId: conversationId || this.id, profileChange, // TODO: DESKTOP-722 @@ -2716,23 +2718,30 @@ export class ConversationModel extends window.Backbone } } - async addUniversalTimerNotification(): Promise { + async addNotification( + type: MessageAttributesType['type'], + extra: Partial = {} + ): Promise { const now = Date.now(); - const message = ({ + const message: Partial = { + ...extra, + conversationId: this.id, - type: 'universal-timer-notification', + type, sent_at: now, received_at: window.Signal.Util.incrementMessageCounter(), received_at_ms: now, - unread: 0, - // TODO: DESKTOP-722 - } as unknown) as typeof window.Whisper.MessageAttributesType; + unread: false, + }; - const id = await window.Signal.Data.saveMessage(message); + const id = await window.Signal.Data.saveMessage( + // TODO: DESKTOP-722 + message as MessageAttributesType + ); const model = window.MessageController.register( id, new window.Whisper.Message({ - ...message, + ...(message as MessageAttributesType), id, }) ); @@ -2764,7 +2773,9 @@ export class ConversationModel extends window.Backbone return; } - const notificationId = await this.addUniversalTimerNotification(); + const notificationId = await this.addNotification( + 'universal-timer-notification' + ); this.set('pendingUniversalTimer', notificationId); } @@ -2797,6 +2808,27 @@ export class ConversationModel extends window.Backbone this.set('pendingUniversalTimer', undefined); } + async addChangeNumberNotification(): Promise { + window.log.info( + `Conversation ${this.idForLogging()}: adding change number notification` + ); + + const convos = [ + this, + ...(await window.ConversationController.getAllGroupsInvolvingId(this.id)), + ]; + + const sourceUuid = this.get('uuid'); + + await Promise.all( + convos.map(convo => { + return convo.addNotification('change-number-notification', { + sourceUuid, + }); + }) + ); + } + async onReadMessage( message: MessageModel, readAt?: number diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 905aa6ce6..ea707aa79 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -178,8 +178,6 @@ export class MessageModel extends window.Backbone.Model { INITIAL_PROTOCOL_VERSION?: number; - OUR_NUMBER?: string; - OUR_UUID?: string; isSelected?: boolean; @@ -209,7 +207,6 @@ export class MessageModel extends window.Backbone.Model { this.CURRENT_PROTOCOL_VERSION = Proto.DataMessage.ProtocolVersion.CURRENT; this.INITIAL_PROTOCOL_VERSION = Proto.DataMessage.ProtocolVersion.INITIAL; - this.OUR_NUMBER = window.textsecure.storage.user.getNumber(); this.OUR_UUID = window.textsecure.storage.user.getUuid(); this.on('change', this.notifyRedux); @@ -364,7 +361,7 @@ export class MessageModel extends window.Backbone.Model { this.attributes, findAndFormatContact, ourConversationId, - this.OUR_NUMBER, + window.textsecure.storage.user.getNumber(), this.OUR_UUID, undefined, undefined, @@ -1066,7 +1063,7 @@ export class MessageModel extends window.Backbone.Model { ); } - return this.OUR_NUMBER; + return window.textsecure.storage.user.getNumber(); } getSourceDevice(): string | number | undefined { @@ -1280,11 +1277,12 @@ export class MessageModel extends window.Backbone.Model { const quoteWithData = await loadQuoteData(this.get('quote')); const previewWithData = await loadPreviewData(this.get('preview')); const stickerWithData = await loadStickerData(this.get('sticker')); + const ourNumber = window.textsecure.storage.user.getNumber(); // Special-case the self-send case - we send only a sync message if ( recipients.length === 1 && - (recipients[0] === this.OUR_NUMBER || recipients[0] === this.OUR_UUID) + (recipients[0] === ourNumber || recipients[0] === this.OUR_UUID) ) { const dataMessage = await window.textsecure.messaging.getDataMessage({ attachments, @@ -1434,9 +1432,10 @@ export class MessageModel extends window.Backbone.Model { const quoteWithData = await loadQuoteData(this.get('quote')); const previewWithData = await loadPreviewData(this.get('preview')); const stickerWithData = await loadStickerData(this.get('sticker')); + const ourNumber = window.textsecure.storage.user.getNumber(); // Special-case the self-send case - we send only a sync message - if (identifier === this.OUR_NUMBER || identifier === this.OUR_UUID) { + if (identifier === ourNumber || identifier === this.OUR_UUID) { const dataMessage = await window.textsecure.messaging.getDataMessage({ attachments, body, diff --git a/ts/services/storageRecordOps.ts b/ts/services/storageRecordOps.ts index c777e7b8d..f6bcf8edd 100644 --- a/ts/services/storageRecordOps.ts +++ b/ts/services/storageRecordOps.ts @@ -186,6 +186,11 @@ export async function toAccountRecord( accountRecord.primarySendsSms = Boolean(primarySendsSms); } + const accountE164 = window.storage.get('accountE164'); + if (accountE164 !== undefined) { + accountRecord.e164 = accountE164; + } + const universalExpireTimer = getUniversalExpireTimer(); if (universalExpireTimer) { accountRecord.universalExpireTimer = Number(universalExpireTimer); @@ -828,6 +833,7 @@ export async function mergeAccountRecord( typingIndicators, primarySendsSms, universalExpireTimer, + e164: accountE164, } = accountRecord; window.storage.put('read-receipt-setting', Boolean(readReceipts)); @@ -848,6 +854,11 @@ export async function mergeAccountRecord( window.storage.put('primarySendsSms', primarySendsSms); } + if (typeof accountE164 === 'string') { + window.storage.put('accountE164', accountE164); + window.storage.user.setNumber(accountE164); + } + setUniversalExpireTimer(universalExpireTimer || 0); const PHONE_NUMBER_SHARING_MODE_ENUM = diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index d1b395ba4..cb9bdf7a0 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -3522,7 +3522,8 @@ async function hasUserInitiatedMessages( 'message-history-unsynced', 'keychange', 'group-v1-migration', - 'universal-timer-notification' + 'universal-timer-notification', + 'change-number-notification' ) ) AND json_extract(json, '$.expirationTimerUpdate') IS NULL @@ -4226,7 +4227,8 @@ async function getLastConversationActivity({ 'message-history-unsynced', 'keychange', 'group-v1-migration', - 'universal-timer-notification' + 'universal-timer-notification', + 'change-number-notification' ) ) AND ( @@ -4277,7 +4279,8 @@ async function getLastConversationPreview({ 'verified-change', 'message-history-unsynced', 'group-v1-migration', - 'universal-timer-notification' + 'universal-timer-notification', + 'change-number-notification' ) ) AND NOT ( diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index 2150ea389..01101c692 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -13,6 +13,7 @@ import { import { TimelineItemType } from '../../components/conversation/TimelineItem'; import { PropsData } from '../../components/conversation/Message'; import { PropsData as TimerNotificationProps } from '../../components/conversation/TimerNotification'; +import { PropsData as ChangeNumberNotificationProps } from '../../components/conversation/ChangeNumberNotification'; import { PropsData as SafetyNumberNotificationProps } from '../../components/conversation/SafetyNumberNotification'; import { PropsData as VerificationNotificationProps } from '../../components/conversation/VerificationNotification'; import { PropsDataType as GroupsV2Props } from '../../components/conversation/GroupV2Change'; @@ -188,6 +189,12 @@ export function getPropsForBubble( data: null, }; } + if (isChangeNumberNotification(message)) { + return { + type: 'changeNumberNotification', + data: getPropsForChangeNumberNotification(message, conversationSelector), + }; + } if (isChatSessionRefreshed(message)) { return { type: 'chatSessionRefreshed', @@ -852,6 +859,24 @@ export function isUniversalTimerNotification( return message.type === 'universal-timer-notification'; } +// Change Number Notification + +export function isChangeNumberNotification( + message: MessageAttributesType +): boolean { + return message.type === 'change-number-notification'; +} + +function getPropsForChangeNumberNotification( + message: MessageAttributesType, + conversationSelector: GetConversationByIdType +): ChangeNumberNotificationProps { + return { + sender: conversationSelector(message.sourceUuid), + timestamp: message.sent_at, + }; +} + // Chat Session Refreshed export function isChatSessionRefreshed( diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index d1ceb9e52..cb413d3bb 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -95,6 +95,7 @@ import { ContactSyncEvent, GroupEvent, GroupSyncEvent, + EnvelopeEvent, } from './messageReceiverEvents'; // TODO: remove once we move away from ArrayBuffers @@ -459,6 +460,11 @@ export default class MessageReceiver handler: (ev: GroupSyncEvent) => void ): void; + public addEventListener( + name: 'envelope', + handler: (ev: EnvelopeEvent) => void + ): void; + public addEventListener(name: string, handler: EventHandler): void { return super.addEventListener(name, handler); } @@ -981,8 +987,6 @@ export default class MessageReceiver if (this.stoppingProcessing) { return; } - // No decryption is required for delivery receipts, so the decrypted field of - // the Unprocessed model will never be set if (envelope.content) { await this.innerHandleContentMessage(envelope, plaintext); diff --git a/ts/textsecure/messageReceiverEvents.ts b/ts/textsecure/messageReceiverEvents.ts index 0524a8a2f..ef4db11f1 100644 --- a/ts/textsecure/messageReceiverEvents.ts +++ b/ts/textsecure/messageReceiverEvents.ts @@ -5,7 +5,11 @@ import { PublicKey } from '@signalapp/signal-client'; import { SignalService as Proto } from '../protobuf'; -import { ProcessedDataMessage, ProcessedSent } from './Types.d'; +import { + ProcessedEnvelope, + ProcessedDataMessage, + ProcessedSent, +} from './Types.d'; import type { ModifiedContactDetails, ModifiedGroupDetails, @@ -136,6 +140,12 @@ export class GroupSyncEvent extends Event { } } +export class EnvelopeEvent extends Event { + constructor(public readonly envelope: ProcessedEnvelope) { + super('envelope'); + } +} + // // Confirmable events below // diff --git a/ts/textsecure/storage/User.ts b/ts/textsecure/storage/User.ts index 311518971..19115708d 100644 --- a/ts/textsecure/storage/User.ts +++ b/ts/textsecure/storage/User.ts @@ -3,6 +3,7 @@ import { WebAPICredentials } from '../Types.d'; +import { strictAssert } from '../../util/assert'; import { StorageInterface } from '../../types/Storage.d'; import Helpers from '../Helpers'; @@ -27,6 +28,25 @@ export class User { window.log.info('storage.user: uuid and device id changed'); } + public async setNumber(number: string): Promise { + if (this.getNumber() === number) { + return; + } + + const deviceId = this.getDeviceId(); + strictAssert( + deviceId !== undefined, + 'Cannot update device number without knowing device id' + ); + + window.log.info('storage.user: number changed'); + + await this.storage.put('number_id', `${number}.${deviceId}`); + + // Notify redux about phone number change + window.Whisper.events.trigger('userChanged'); + } + public getNumber(): string | undefined { const numberId = this.storage.get('number_id'); if (numberId === undefined) return undefined; diff --git a/ts/types/Storage.d.ts b/ts/types/Storage.d.ts index 4273176ea..cabc9faa0 100644 --- a/ts/types/Storage.d.ts +++ b/ts/types/Storage.d.ts @@ -77,6 +77,9 @@ export type StorageAccessType = { phoneNumberDiscoverability: PhoneNumberDiscoverability; pinnedConversationIds: Array; primarySendsSms: boolean; + // Unlike `number_id` (which also includes device id) this field is only + // updated whenever we receive a new storage manifest + accountE164: string; typingIndicators: boolean; sealedSenderIndicators: boolean; storageFetchComplete: boolean;