diff --git a/_locales/en/messages.json b/_locales/en/messages.json index bd7e2f439..100ea14de 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -114,6 +114,7 @@ "clear": "Clear", "clearAllData": "Clear All Data", "deleteAccountWarning": "This will permanently delete your messages and contacts.", + "deleteAccountFromLogin": "Are you sure you want to clear your device?", "deleteContactConfirmation": "Are you sure you want to delete this conversation?", "quoteThumbnailAlt": "Thumbnail of image from quoted message", "imageAttachmentAlt": "Image attached to message", @@ -266,16 +267,16 @@ "updateGroupDialogTitle": "Updating $name$...", "showRecoveryPhrase": "Recovery Phrase", "yourSessionID": "Your Session ID", - "setAccountPasswordTitle": "Set Account Password", + "setAccountPasswordTitle": "Password", "setAccountPasswordDescription": "Require password to unlock Session.", - "changeAccountPasswordTitle": "Change Account Password", + "changeAccountPasswordTitle": "Change Password", "changeAccountPasswordDescription": "Change the password required to unlock Session.", - "removeAccountPasswordTitle": "Remove Account Password", + "removeAccountPasswordTitle": "Remove Password", "removeAccountPasswordDescription": "Remove the password required to unlock Session.", "enterPassword": "Please enter your password", "confirmPassword": "Confirm password", "enterNewPassword": "Please enter your new password", - "confirmNewPassword": "Confirm new password", + "confirmNewPassword": "Confirm password", "showRecoveryPhrasePasswordRequest": "Please enter your password", "recoveryPhraseSavePromptMain": "Your recovery phrase is the master key to your Session ID — you can use it to restore your Session ID if you lose access to your device. Store your recovery phrase in a safe place, and don't give it to anyone.", "invalidOpenGroupUrl": "Invalid URL", @@ -295,12 +296,12 @@ "setPasswordInvalid": "Passwords do not match", "changePasswordInvalid": "The old password you entered is incorrect", "removePasswordInvalid": "Incorrect password", - "setPasswordTitle": "Set Password", - "changePasswordTitle": "Changed Password", - "removePasswordTitle": "Removed Password", + "setPasswordTitle": "Password Set", + "changePasswordTitle": "Password Changed", + "removePasswordTitle": "Password Removed", "setPasswordToastDescription": "Your password has been set. Please keep it safe.", "changePasswordToastDescription": "Your password has been changed. Please keep it safe.", - "removePasswordToastDescription": "You have removed your password.", + "removePasswordToastDescription": "Your password has been removed.", "publicChatExists": "You are already connected to this community", "connectToServerFail": "Couldn't join community", "connectingToServer": "Connecting...", @@ -414,6 +415,9 @@ "dialogClearAllDataDeletionFailedTitleQuestion": "Do you want to delete data from just this device?", "dialogClearAllDataDeletionFailedMultiple": "Data not deleted by those Service Nodes: $snodes$", "dialogClearAllDataDeletionQuestion": "Would you like to clear this device only, or delete your data from the network as well?", + "clearDevice": "Clear Device", + "tryAgain": "Try Again", + "areYouSureClearDevice": "Are you sure you want to clear your device?", "deviceOnly": "Clear Device Only", "entireAccount": "Clear Device and Network", "areYouSureDeleteDeviceOnly": "Are you sure you want to delete your device data only?", diff --git a/protos/SignalService.proto b/protos/SignalService.proto index 7527f6b54..d94cbdfc5 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -37,7 +37,9 @@ message Unsend { message MessageRequestResponse { // @required - required bool isApproved = 1; + required bool isApproved = 1; + optional bytes profileKey = 2; + optional DataMessage.LokiProfile profile = 3; } message Content { diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 808a6fade..0937c3118 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -172,13 +172,17 @@ .module-image { margin: -1px; } + + &__text { + padding-block: var(--margins-sm); + } } .module-message__link-preview__content { padding: 0 0 var(--margins-xs) 0; display: flex; flex-direction: row; - align-items: flex-start; + align-items: center; flex-grow: 1; margin-left: var(--margins-sm); } diff --git a/stylesheets/_quote.scss b/stylesheets/_quote.scss index 51a23f394..fee3d1048 100644 --- a/stylesheets/_quote.scss +++ b/stylesheets/_quote.scss @@ -221,7 +221,7 @@ } .module-quote-container { - margin-bottom: 5px; + margin-bottom: var(--margins-xs); margin-top: var(--margins-xs); min-width: 300px; // if the quoted content is small it doesn't look very good so we set a minimum padding-right: var(--margins-xs); diff --git a/stylesheets/_session_password.scss b/stylesheets/_session_password.scss index 854b2fbbb..9cf738cb7 100644 --- a/stylesheets/_session_password.scss +++ b/stylesheets/_session_password.scss @@ -1,8 +1,16 @@ .password { height: 100vh; + color: var(--white-color); //TODO theming update .clear-data-wrapper { - margin: auto; + display: flex; + height: 100%; + width: 100%; + background-color: var(--black-color); + + .clear-data-container { + margin: auto; + } .warning-info-area { display: flex; diff --git a/stylesheets/_session_theme.scss b/stylesheets/_session_theme.scss index 9e368a6d2..1bc8d3aaf 100644 --- a/stylesheets/_session_theme.scss +++ b/stylesheets/_session_theme.scss @@ -33,6 +33,9 @@ padding: var(--padding-message-content); border-radius: var(--border-radius-message-box); + padding: var(--padding-message-content); + border-radius: var(--border-radius-message-box); + a { text-decoration: underline; } diff --git a/ts/components/SessionPasswordPrompt.tsx b/ts/components/SessionPasswordPrompt.tsx index 74f18dd7b..cffa02d2f 100644 --- a/ts/components/SessionPasswordPrompt.tsx +++ b/ts/components/SessionPasswordPrompt.tsx @@ -8,15 +8,18 @@ import { SessionSpinner } from './basic/SessionSpinner'; import { SessionTheme } from '../themes/SessionTheme'; import { switchThemeTo } from '../session/utils/Theme'; import styled from 'styled-components'; +import { ToastUtils } from '../session/utils'; +import { isString } from 'lodash'; +import { SessionToastContainer } from './SessionToastContainer'; interface State { - error: string; errorCount: number; clearDataView: boolean; loading: boolean; } export const MAX_LOGIN_TRIES = 3; +// tslint:disable: use-simple-attributes const TextPleaseWait = (props: { isLoading: boolean }) => { if (!props.isLoading) { @@ -38,7 +41,6 @@ class SessionPasswordPromptInner extends React.PureComponent<{}, State> { super(props); this.state = { - error: '', errorCount: 0, clearDataView: false, loading: false, @@ -54,7 +56,6 @@ class SessionPasswordPromptInner extends React.PureComponent<{}, State> { } public render() { - const showResetElements = this.state.errorCount >= MAX_LOGIN_TRIES; const isLoading = this.state.loading; const wrapperClass = this.state.clearDataView @@ -65,13 +66,13 @@ class SessionPasswordPromptInner extends React.PureComponent<{}, State> { : 'password-prompt-container'; const infoAreaClass = this.state.clearDataView ? 'warning-info-area' : 'password-info-area'; const infoTitle = this.state.clearDataView - ? window.i18n('clearAllData') + ? window.i18n('clearDevice') : window.i18n('passwordViewTitle'); const buttonGroup = this.state.clearDataView ? this.renderClearDataViewButtons() : this.renderPasswordViewButtons(); const featureElement = this.state.clearDataView ? ( -

{window.i18n('deleteAccountWarning')}

+

{window.i18n('deleteAccountFromLogin')}

) : ( { const infoIcon = this.state.clearDataView ?? ( ); - const errorSection = !this.state.clearDataView && ( -
- {this.state.error && ( - <> - {showResetElements ? ( -
{window.i18n('maxPasswordAttempts')}
- ) : ( -
{this.state.error}
- )} - - )} -
- ); + const spinner = isLoading ? : null; return ( @@ -114,7 +103,6 @@ class SessionPasswordPromptInner extends React.PureComponent<{}, State> { {spinner || featureElement} - {errorSection} {buttonGroup} @@ -143,7 +131,12 @@ class SessionPasswordPromptInner extends React.PureComponent<{}, State> { errorCount: this.state.errorCount + 1, }); - this.setState({ error }); + if (error && isString(error)) { + ToastUtils.pushToastError('onLogin', error); + } else if (error?.message && isString(error.message)) { + ToastUtils.pushToastError('onLogin', error.message); + } + global.setTimeout(() => { document.getElementById('password-prompt-input')?.focus(); }, 50); @@ -166,7 +159,6 @@ class SessionPasswordPromptInner extends React.PureComponent<{}, State> { private initClearDataView() { this.setState({ - error: '', errorCount: 0, clearDataView: true, }); @@ -180,7 +172,7 @@ class SessionPasswordPromptInner extends React.PureComponent<{}, State> { {showResetElements && ( <> { )} {/* TODO Theming - Fix */} @@ -201,7 +193,7 @@ class SessionPasswordPromptInner extends React.PureComponent<{}, State> { return (
{ return ( + diff --git a/ts/components/conversation/SessionConversation.tsx b/ts/components/conversation/SessionConversation.tsx index 97ae138db..c202e0c79 100644 --- a/ts/components/conversation/SessionConversation.tsx +++ b/ts/components/conversation/SessionConversation.tsx @@ -54,6 +54,8 @@ import { ConversationMessageRequestButtons } from './ConversationRequestButtons' import { ConversationRequestinfo } from './ConversationRequestInfo'; import { getCurrentRecoveryPhrase } from '../../util/storage'; import loadImage from 'blueimp-load-image'; +import { markAllReadByConvoId } from '../../interactions/conversationInteractions'; + import { SessionSpinner } from '../basic/SessionSpinner'; import styled from 'styled-components'; // tslint:disable: jsx-curly-spacing @@ -307,17 +309,18 @@ export class SessionConversation extends React.Component { } private async scrollToNow() { - if (!this.props.selectedConversationKey) { + const conversationKey = this.props.selectedConversationKey; + if (!conversationKey) { return; } - const mostNowMessage = await Data.getLastMessageInConversation( - this.props.selectedConversationKey - ); - if (mostNowMessage) { + await markAllReadByConvoId(conversationKey); + const mostRecentMessage = await Data.getLastMessageInConversation(conversationKey); + + if (mostRecentMessage) { await openConversationToSpecificMessage({ - conversationKey: this.props.selectedConversationKey, - messageIdToNavigateTo: mostNowMessage.id, + conversationKey, + messageIdToNavigateTo: mostRecentMessage.id, shouldHighlightMessage: false, }); const messageContainer = this.messageContainerRef.current; diff --git a/ts/components/conversation/message/message-content/MessageAuthorText.tsx b/ts/components/conversation/message/message-content/MessageAuthorText.tsx index 4f3c6f3b1..bacf3f0ee 100644 --- a/ts/components/conversation/message/message-content/MessageAuthorText.tsx +++ b/ts/components/conversation/message/message-content/MessageAuthorText.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { useSelector } from 'react-redux'; +import styled from 'styled-components'; import { MessageRenderingProps } from '../../../../models/messageType'; import { PubKey } from '../../../../session/types'; import { @@ -19,6 +20,11 @@ type Props = { messageId: string; }; +const StyledAuthorContainer = styled(Flex)` + /* TODO Theming - Verify */ + color: var(--text-primary-color); +`; + export const MessageAuthorText = (props: Props) => { const selected = useSelector(state => getMessageAuthorProps(state as any, props.messageId)); @@ -38,7 +44,7 @@ export const MessageAuthorText = (props: Props) => { const displayedPubkey = authorProfileName ? PubKey.shorten(sender) : sender; return ( - + { boldProfileName={true} shouldShowPubkey={Boolean(isPublic)} /> - + ); }; diff --git a/ts/components/conversation/message/message-content/MessageContent.tsx b/ts/components/conversation/message/message-content/MessageContent.tsx index e4f1b2a22..28ad3d166 100644 --- a/ts/components/conversation/message/message-content/MessageContent.tsx +++ b/ts/components/conversation/message/message-content/MessageContent.tsx @@ -88,6 +88,7 @@ const StyledMessageOpaqueContent = styled.div<{ `; export const IsMessageVisibleContext = createContext(false); +// tslint:disable: use-simple-attributes export const MessageContent = (props: Props) => { const [highlight, setHighlight] = useState(false); diff --git a/ts/components/conversation/message/message-item/ReadableMessage.tsx b/ts/components/conversation/message/message-item/ReadableMessage.tsx index bcaaeb966..a57cead51 100644 --- a/ts/components/conversation/message/message-item/ReadableMessage.tsx +++ b/ts/components/conversation/message/message-item/ReadableMessage.tsx @@ -144,17 +144,17 @@ export const ReadableMessage = (props: ReadableMessageProps) => { const found = await Data.getMessageById(messageId); if (found && Boolean(found.get('unread'))) { - const foundReceivedAt = found.get('received_at'); + const foundSentAt = found.get('sent_at'); // mark the message as read. // this will trigger the expire timer. await found.markRead(Date.now()); // we should stack those and send them in a single message once every 5secs or something. // this would be part of an redesign of the sending pipeline - if (foundReceivedAt) { + if (foundSentAt && selectedConversationKey) { void getConversationController() - .get(found.id) - ?.sendReadReceiptsIfNeeded([foundReceivedAt]); + .get(selectedConversationKey) + ?.sendReadReceiptsIfNeeded([foundSentAt]); } } } diff --git a/ts/components/dialog/SessionPasswordDialog.tsx b/ts/components/dialog/SessionPasswordDialog.tsx index 39c0a33fe..082c655a2 100644 --- a/ts/components/dialog/SessionPasswordDialog.tsx +++ b/ts/components/dialog/SessionPasswordDialog.tsx @@ -114,7 +114,7 @@ export class SessionPasswordDialog extends React.Component {
Promise; }) => { return (
- {window.i18n('password')} + {window.i18n('passwordViewTitle')} - {pwdLockError &&
{pwdLockError}
} -
@@ -210,9 +203,6 @@ export class SessionSettingsView extends React.Component {shouldRenderPasswordLock ? ( - + ) : ( > { + const messagesIds = await channels.markAllAsReadByConversationNoExpiration(conversationId); + return messagesIds; +} + // might throw async function getUnreadCountByConversation(conversationId: string): Promise { return channels.getUnreadCountByConversation(conversationId); diff --git a/ts/data/dataInit.ts b/ts/data/dataInit.ts index 8c9b3903f..1bd0f40b7 100644 --- a/ts/data/dataInit.ts +++ b/ts/data/dataInit.ts @@ -40,6 +40,7 @@ const channelsToMake = new Set([ 'removeMessage', '_removeMessages', 'getUnreadByConversation', + 'markAllAsReadByConversationNoExpiration', 'getUnreadCountByConversation', 'getMessageCountByType', 'removeAllMessagesInConversation', diff --git a/ts/interactions/conversationInteractions.ts b/ts/interactions/conversationInteractions.ts index 859c42036..1455c01bb 100644 --- a/ts/interactions/conversationInteractions.ts +++ b/ts/interactions/conversationInteractions.ts @@ -284,7 +284,8 @@ export async function markAllReadByConvoId(conversationId: string) { const conversation = getConversationController().get(conversationId); perfStart(`markAllReadByConvoId-${conversationId}`); - await conversation.markReadBouncy(Date.now()); + await conversation?.markAllAsRead(); + perfEnd(`markAllReadByConvoId-${conversationId}`, 'markAllReadByConvoId'); } diff --git a/ts/models/conversation.ts b/ts/models/conversation.ts index 27658b0f0..fe3942186 100644 --- a/ts/models/conversation.ts +++ b/ts/models/conversation.ts @@ -25,6 +25,7 @@ import { SignalService } from '../protobuf'; import { MessageModel, sliceQuoteText } from './message'; import { MessageAttributesOptionals, MessageDirection } from './messageType'; import autoBind from 'auto-bind'; + import { Data } from '../../ts/data/data'; import { toHex } from '../session/utils/String'; import { @@ -925,7 +926,7 @@ export class ConversationModel extends Backbone.Model { const messageRequestResponseParams: MessageRequestResponseParams = { timestamp, - // lokiProfile: UserUtils.getOurProfile(), // we can't curently include our profile in that response + lokiProfile: UserUtils.getOurProfile(), }; const messageRequestResponse = new MessageRequestResponse(messageRequestResponseParams); @@ -1223,15 +1224,49 @@ export class ConversationModel extends Backbone.Model { } } + /** + * Mark everything as read efficiently if possible. + * + * For convos with a expiration timer enable, start the timer as of now. + * Send read receipt if needed. + */ + public async markAllAsRead() { + if (this.isOpenGroupV2()) { + // for opengroups, we batch everything as there is no expiration timer to take care (and potentially a lot of messages) + + await Data.markAllAsReadByConversationNoExpiration(this.id); + this.set({ mentionedUs: false, unreadCount: 0 }); + + await this.commit(); + return; + } + + // if the conversation has no expiration timer, we can also batch everything, but we also need to send read receipts potentially + // so we grab them from the db + if (!this.get('expireTimer')) { + const allReadMessages = await Data.markAllAsReadByConversationNoExpiration(this.id); + this.set({ mentionedUs: false, unreadCount: 0 }); + await this.commit(); + if (allReadMessages.length) { + await this.sendReadReceiptsIfNeeded(uniq(allReadMessages)); + } + return; + } + await this.markReadBouncy(Date.now()); + } + // tslint:disable-next-line: cyclomatic-complexity - public async markReadBouncy(newestUnreadDate: number, providedOptions: any = {}) { + public async markReadBouncy( + newestUnreadDate: number, + providedOptions: { sendReadReceipts?: boolean; readAt?: number } = {} + ) { const lastReadTimestamp = this.lastReadTimestamp; if (newestUnreadDate < lastReadTimestamp) { return; } - const options = providedOptions || {}; - defaults(options, { sendReadReceipts: true }); + const readAt = providedOptions?.readAt || Date.now(); + const sendReadReceipts = providedOptions?.sendReadReceipts || true; const conversationId = this.id; Notifications.clearByConversationID(conversationId); @@ -1245,7 +1280,7 @@ export class ConversationModel extends Backbone.Model { // Build the list of updated message models so we can mark them all as read on a single sqlite call for (const nowRead of oldUnreadNowRead) { - nowRead.markReadNoCommit(options.readAt); + nowRead.markReadNoCommit(readAt); const errors = nowRead.get('errors'); read.push({ @@ -1307,7 +1342,7 @@ export class ConversationModel extends Backbone.Model { // conversation is viewed, another error message shows up for the contact read = read.filter(item => !item.hasErrors); - if (read.length && options.sendReadReceipts) { + if (read.length && sendReadReceipts) { const timestamps = map(read, 'timestamp').filter(t => !!t) as Array; await this.sendReadReceiptsIfNeeded(timestamps); } diff --git a/ts/node/sql.ts b/ts/node/sql.ts index e71b1da05..f7270e7f7 100644 --- a/ts/node/sql.ts +++ b/ts/node/sql.ts @@ -6,6 +6,7 @@ import { app, clipboard, dialog, Notification } from 'electron'; import { chunk, + compact, difference, forEach, fromPairs, @@ -1112,6 +1113,38 @@ function getUnreadByConversation(conversationId: string) { return map(rows, row => jsonToObject(row.json)); } +/** + * Warning: This does not start expiration timer + */ +function markAllAsReadByConversationNoExpiration( + conversationId: string +): Array<{ id: string; timestamp: number }> { + const messagesUnreadBefore = assertGlobalInstance() + .prepare( + `SELECT json FROM ${MESSAGES_TABLE} WHERE + unread = $unread AND + conversationId = $conversationId;` + ) + .all({ + unread: 1, + conversationId, + }); + + assertGlobalInstance() + .prepare( + `UPDATE ${MESSAGES_TABLE} SET + unread = 0, json = json_set(json, '$.unread', 0) + WHERE unread = $unread AND + conversationId = $conversationId;` + ) + .run({ + unread: 1, + conversationId, + }); + + return compact(messagesUnreadBefore.map(row => jsonToObject(row.json).sent_at)); +} + function getUnreadCountByConversation(conversationId: string) { const row = assertGlobalInstance() .prepare( @@ -1346,7 +1379,7 @@ function getFirstUnreadMessageWithMention( function getMessagesBySentAt(sentAt: number) { const rows = assertGlobalInstance() .prepare( - `SELECT * FROM ${MESSAGES_TABLE} + `SELECT json FROM ${MESSAGES_TABLE} WHERE sent_at = $sent_at ORDER BY received_at DESC;` ) @@ -2403,6 +2436,7 @@ export const sqlNode = { saveMessages, removeMessage, getUnreadByConversation, + markAllAsReadByConversationNoExpiration, getUnreadCountByConversation, getMessageCountByType, diff --git a/ts/receiver/contentMessage.ts b/ts/receiver/contentMessage.ts index 3511e3c28..24724c350 100644 --- a/ts/receiver/contentMessage.ts +++ b/ts/receiver/contentMessage.ts @@ -27,6 +27,7 @@ import { } from '../interactions/conversations/unsendingInteractions'; import { ConversationTypeEnum } from '../models/conversationAttributes'; import { findCachedBlindedMatchOrLookupOnAllServers } from '../session/apis/open_group_api/sogsv3/knownBlindedkeys'; +import { appendFetchAvatarAndProfileJob } from './userProfileImageUpdates'; export async function handleSwarmContentMessage(envelope: EnvelopePlus, messageHash: string) { try { @@ -605,6 +606,11 @@ async function handleMessageRequestResponse( messageRequestResponse: SignalService.MessageRequestResponse ) { const { isApproved } = messageRequestResponse; + if (!isApproved) { + window?.log?.error('handleMessageRequestResponse: isApproved is false -- dropping message.'); + await removeFromCache(envelope); + return; + } if (!messageRequestResponse) { window?.log?.error('handleMessageRequestResponse: Invalid parameters -- dropping message.'); await removeFromCache(envelope); @@ -675,6 +681,14 @@ async function handleMessageRequestResponse( } } + if (messageRequestResponse.profile && !isEmpty(messageRequestResponse.profile)) { + void appendFetchAvatarAndProfileJob( + conversationToApprove, + messageRequestResponse.profile, + messageRequestResponse.profileKey + ); + } + if (!conversationToApprove || conversationToApprove.didApproveMe() === isApproved) { if (conversationToApprove) { await conversationToApprove.commit(); diff --git a/ts/session/messages/outgoing/controlMessage/MessageRequestResponse.ts b/ts/session/messages/outgoing/controlMessage/MessageRequestResponse.ts index 95bde6a94..dcedf5269 100644 --- a/ts/session/messages/outgoing/controlMessage/MessageRequestResponse.ts +++ b/ts/session/messages/outgoing/controlMessage/MessageRequestResponse.ts @@ -1,18 +1,28 @@ import { SignalService } from '../../../../protobuf'; +import { LokiProfile } from '../../../../types/Message'; import { ContentMessage } from '../ContentMessage'; import { MessageParams } from '../Message'; +import { buildProfileForOutgoingMessage } from '../visibleMessage/VisibleMessage'; // tslint:disable-next-line: no-empty-interface -export interface MessageRequestResponseParams extends MessageParams {} +export interface MessageRequestResponseParams extends MessageParams { + lokiProfile?: LokiProfile; +} export class MessageRequestResponse extends ContentMessage { // we actually send a response only if it is an accept // private readonly isApproved: boolean; + private readonly profileKey?: Uint8Array; + private readonly profile?: SignalService.DataMessage.ILokiProfile; constructor(params: MessageRequestResponseParams) { super({ timestamp: params.timestamp, } as MessageRequestResponseParams); + + const profile = buildProfileForOutgoingMessage(params); + this.profile = profile.lokiProfile; + this.profileKey = profile.profileKey; } public contentProto(): SignalService.Content { @@ -24,6 +34,8 @@ export class MessageRequestResponse extends ContentMessage { public messageRequestResponseProto(): SignalService.MessageRequestResponse { return new SignalService.MessageRequestResponse({ isApproved: true, + profileKey: this.profileKey?.length ? this.profileKey : undefined, + profile: this.profile, }); } } diff --git a/ts/session/messages/outgoing/visibleMessage/VisibleMessage.ts b/ts/session/messages/outgoing/visibleMessage/VisibleMessage.ts index c5fe43d0c..43e718dc5 100644 --- a/ts/session/messages/outgoing/visibleMessage/VisibleMessage.ts +++ b/ts/session/messages/outgoing/visibleMessage/VisibleMessage.ts @@ -1,4 +1,5 @@ import ByteBuffer from 'bytebuffer'; +import { isEmpty } from 'lodash'; import { DataMessage } from '..'; import { SignalService } from '../../../../protobuf'; import { LokiProfile } from '../../../../types/Message'; @@ -80,8 +81,7 @@ export class VisibleMessage extends DataMessage { private readonly body?: string; private readonly quote?: Quote; private readonly profileKey?: Uint8Array; - private readonly displayName?: string; - private readonly avatarPointer?: string; + private readonly profile?: SignalService.DataMessage.ILokiProfile; private readonly preview?: Array; /// In the case of a sync message, the public key of the person the message was targeted at. @@ -94,21 +94,12 @@ export class VisibleMessage extends DataMessage { this.body = params.body; this.quote = params.quote; this.expireTimer = params.expireTimer; - if (params.lokiProfile && params.lokiProfile.profileKey) { - if ( - params.lokiProfile.profileKey instanceof Uint8Array || - (params.lokiProfile.profileKey as any) instanceof ByteBuffer - ) { - this.profileKey = new Uint8Array(params.lokiProfile.profileKey); - } else { - this.profileKey = new Uint8Array( - ByteBuffer.wrap(params.lokiProfile.profileKey).toArrayBuffer() - ); - } - } - this.displayName = params.lokiProfile && params.lokiProfile.displayName; - this.avatarPointer = params.lokiProfile && params.lokiProfile.avatarPointer; + const profile = buildProfileForOutgoingMessage(params); + + this.profile = profile.lokiProfile; + this.profileKey = profile.profileKey; + this.preview = params.preview; this.reaction = params.reaction; this.syncTarget = params.syncTarget; @@ -137,18 +128,10 @@ export class VisibleMessage extends DataMessage { dataMessage.syncTarget = this.syncTarget; } - if (this.avatarPointer || this.displayName) { - const profile = new SignalService.DataMessage.LokiProfile(); - - if (this.avatarPointer) { - profile.profilePicture = this.avatarPointer; - } - - if (this.displayName) { - profile.displayName = this.displayName; - } - dataMessage.profile = profile; + if (this.profile) { + dataMessage.profile = this.profile; } + if (this.profileKey && this.profileKey.length) { dataMessage.profileKey = this.profileKey; } @@ -201,3 +184,47 @@ export class VisibleMessage extends DataMessage { return this.identifier === comparator.identifier && this.timestamp === comparator.timestamp; } } + +export function buildProfileForOutgoingMessage(params: { lokiProfile?: LokiProfile }) { + let profileKey: Uint8Array | undefined; + if (params.lokiProfile && params.lokiProfile.profileKey) { + if ( + params.lokiProfile.profileKey instanceof Uint8Array || + (params.lokiProfile.profileKey as any) instanceof ByteBuffer + ) { + profileKey = new Uint8Array(params.lokiProfile.profileKey); + } else { + profileKey = new Uint8Array(ByteBuffer.wrap(params.lokiProfile.profileKey).toArrayBuffer()); + } + } + + const displayName = params.lokiProfile?.displayName; + + // no need to iclude the avatarPointer if there is no profileKey associated with it. + const avatarPointer = + params.lokiProfile?.avatarPointer && + !isEmpty(profileKey) && + params.lokiProfile.avatarPointer && + !isEmpty(params.lokiProfile.avatarPointer) + ? params.lokiProfile.avatarPointer + : undefined; + + let lokiProfile: SignalService.DataMessage.ILokiProfile | undefined; + if (avatarPointer || displayName) { + lokiProfile = new SignalService.DataMessage.LokiProfile(); + + // we always need a profileKey tom decode an avatar pointer + if (avatarPointer && avatarPointer.length && profileKey) { + lokiProfile.profilePicture = avatarPointer; + } + + if (displayName) { + lokiProfile.displayName = displayName; + } + } + + return { + lokiProfile, + profileKey: lokiProfile?.profilePicture ? profileKey : undefined, + }; +} diff --git a/ts/test/session/unit/messages/MessageRequestResponse_test.ts b/ts/test/session/unit/messages/MessageRequestResponse_test.ts new file mode 100644 index 000000000..85da9a965 --- /dev/null +++ b/ts/test/session/unit/messages/MessageRequestResponse_test.ts @@ -0,0 +1,160 @@ +import { expect } from 'chai'; +import { v4 } from 'uuid'; + +import { SignalService } from '../../../../protobuf'; +import { Constants } from '../../../../session'; +import { MessageRequestResponse } from '../../../../session/messages/outgoing/controlMessage/MessageRequestResponse'; +// tslint:disable: no-unused-expression + +// tslint:disable-next-line: max-func-body-length +describe('MessageRequestResponse', () => { + let message: MessageRequestResponse | undefined; + it('correct ttl', () => { + message = new MessageRequestResponse({ + timestamp: Date.now(), + }); + + expect(message.ttl()).to.equal(Constants.TTL_DEFAULT.TTL_MAX); + }); + + it('has an identifier', () => { + message = new MessageRequestResponse({ + timestamp: Date.now(), + }); + + expect(message.identifier).to.not.equal(null, 'identifier cannot be null'); + expect(message.identifier).to.not.equal(undefined, 'identifier cannot be undefined'); + }); + + it('has an identifier matching if given', () => { + const identifier = v4(); + message = new MessageRequestResponse({ + timestamp: Date.now(), + identifier, + }); + + expect(message.identifier).to.not.equal(identifier, 'identifier should match'); + }); + + it('isApproved is always true', () => { + message = new MessageRequestResponse({ + timestamp: Date.now(), + }); + const plainText = message.plainTextBuffer(); + const decoded = SignalService.Content.decode(plainText); + expect(decoded.messageRequestResponse) + .to.have.property('isApproved') + .to.be.eq(true, 'isApproved is true'); + }); + + it('can create response without lokiProfile', () => { + message = new MessageRequestResponse({ + timestamp: Date.now(), + }); + const plainText = message.plainTextBuffer(); + const decoded = SignalService.Content.decode(plainText); + expect(decoded.messageRequestResponse) + .to.have.property('profile') + .to.be.eq(null, 'no profile field if no profile given'); + }); + + it('can create response with display name only', () => { + message = new MessageRequestResponse({ + timestamp: Date.now(), + lokiProfile: { displayName: 'Jane', profileKey: null }, + }); + const plainText = message.plainTextBuffer(); + const decoded = SignalService.Content.decode(plainText); + + expect(decoded.messageRequestResponse?.profile?.displayName).to.be.deep.eq('Jane'); + expect(decoded.messageRequestResponse?.profile?.profilePicture).to.be.empty; + expect(decoded.messageRequestResponse?.profileKey).to.be.empty; + }); + + it('empty profileKey does not get included', () => { + message = new MessageRequestResponse({ + timestamp: Date.now(), + lokiProfile: { displayName: 'Jane', profileKey: new Uint8Array(0) }, + }); + const plainText = message.plainTextBuffer(); + const decoded = SignalService.Content.decode(plainText); + + expect(decoded.messageRequestResponse?.profile?.displayName).to.be.eq('Jane'); + + expect(decoded.messageRequestResponse?.profile?.profilePicture).to.be.empty; + expect(decoded.messageRequestResponse?.profileKey).to.be.empty; + }); + + it('can create response with display name and profileKey and profileImage', () => { + message = new MessageRequestResponse({ + timestamp: Date.now(), + lokiProfile: { + displayName: 'Jane', + profileKey: new Uint8Array([1, 2, 3, 4, 5, 6]), + avatarPointer: 'https://somevalidurl.com', + }, + }); + const plainText = message.plainTextBuffer(); + const decoded = SignalService.Content.decode(plainText); + + expect(decoded.messageRequestResponse?.profile?.displayName).to.be.deep.eq('Jane'); + + expect(decoded.messageRequestResponse?.profileKey).to.be.not.empty; + + if (!decoded.messageRequestResponse?.profileKey?.buffer) { + throw new Error('decoded.messageRequestResponse?.profileKey?.buffer should be set'); + } + expect(decoded.messageRequestResponse?.profile?.profilePicture).to.be.eq( + 'https://somevalidurl.com' + ); + // don't ask me why deep.eq ([1,2,3, ...]) gives nothing interesting but a 8192 buffer not matching + expect(decoded.messageRequestResponse?.profileKey.length).to.be.eq(6); + expect(decoded.messageRequestResponse?.profileKey[0]).to.be.eq(1); + expect(decoded.messageRequestResponse?.profileKey[1]).to.be.eq(2); + expect(decoded.messageRequestResponse?.profileKey[2]).to.be.eq(3); + expect(decoded.messageRequestResponse?.profileKey[3]).to.be.eq(4); + expect(decoded.messageRequestResponse?.profileKey[4]).to.be.eq(5); + expect(decoded.messageRequestResponse?.profileKey[5]).to.be.eq(6); + }); + + it('profileKey not included if profileUrl not set', () => { + message = new MessageRequestResponse({ + timestamp: Date.now(), + lokiProfile: { displayName: 'Jane', profileKey: new Uint8Array([1, 2, 3, 4, 5, 6]) }, + }); + const plainText = message.plainTextBuffer(); + const decoded = SignalService.Content.decode(plainText); + + expect(decoded.messageRequestResponse?.profile?.displayName).to.be.deep.eq('Jane'); + + if (!decoded.messageRequestResponse?.profileKey?.buffer) { + throw new Error('decoded.messageRequestResponse?.profileKey?.buffer should be set'); + } + + expect(decoded.messageRequestResponse?.profile?.profilePicture).to.be.empty; + expect(decoded.messageRequestResponse?.profileKey).to.be.empty; + }); + + it('url not included if profileKey not set', () => { + message = new MessageRequestResponse({ + timestamp: Date.now(), + lokiProfile: { + displayName: 'Jane', + profileKey: null, + avatarPointer: 'https://somevalidurl.com', + }, + }); + const plainText = message.plainTextBuffer(); + const decoded = SignalService.Content.decode(plainText); + + expect(decoded.messageRequestResponse?.profile?.displayName).to.be.deep.eq('Jane'); + + if (!decoded.messageRequestResponse?.profileKey?.buffer) { + throw new Error('decoded.messageRequestResponse?.profileKey?.buffer should be set'); + } + + expect(decoded.messageRequestResponse?.profile?.displayName).to.be.eq('Jane'); + expect(decoded.messageRequestResponse?.profile?.profilePicture).to.be.empty; + expect(decoded.messageRequestResponse?.profileKey).to.be.empty; + }); +}); diff --git a/ts/types/LocalizerKeys.ts b/ts/types/LocalizerKeys.ts index 8394717c3..95a12580f 100644 --- a/ts/types/LocalizerKeys.ts +++ b/ts/types/LocalizerKeys.ts @@ -162,6 +162,7 @@ export type LocalizerKeys = | 'spellCheckDirty' | 'debugLogExplanation' | 'closedGroupInviteFailTitle' + | 'areYouSureClearDevice' | 'setAccountPasswordDescription' | 'removeAccountPasswordDescription' | 'establishingConnection' @@ -348,6 +349,8 @@ export type LocalizerKeys = | 'openGroupInvitation' | 'callMissedCausePermission' | 'mediaPermissionsDescription' + | 'tryAgain' + | 'clearDevice' | 'media' | 'noMembersInThisGroup' | 'saveLogToDesktop' @@ -478,6 +481,7 @@ export type LocalizerKeys = | 'titleIsNow' | 'removePasswordToastDescription' | 'recoveryPhrase' + | 'deleteAccountFromLogin' | 'newMessages' | 'you' | 'pruneSettingTitle'