Merge branch 'clearnet' into theming

This commit is contained in:
William Grant 2022-10-06 00:25:54 +11:00
commit 0f3a23779a
23 changed files with 415 additions and 118 deletions

View file

@ -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?",

View file

@ -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 {

View file

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

View file

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

View file

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

View file

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

View file

@ -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 ? (
<p className="text-center">{window.i18n('deleteAccountWarning')}</p>
<p className="text-center">{window.i18n('deleteAccountFromLogin')}</p>
) : (
<input
id="password-prompt-input"
@ -87,19 +88,7 @@ class SessionPasswordPromptInner extends React.PureComponent<{}, State> {
const infoIcon = this.state.clearDataView ?? (
<SessionIcon iconType="warning" iconSize={35} iconColor="var(--danger-color)" />
);
const errorSection = !this.state.clearDataView && (
<div className="password-prompt-error-section">
{this.state.error && (
<>
{showResetElements ? (
<div className="session-label warning">{window.i18n('maxPasswordAttempts')}</div>
) : (
<div className="session-label primary">{this.state.error}</div>
)}
</>
)}
</div>
);
const spinner = isLoading ? <SessionSpinner loading={true} /> : null;
return (
@ -114,7 +103,6 @@ class SessionPasswordPromptInner extends React.PureComponent<{}, State> {
{spinner || featureElement}
<TextPleaseWait isLoading={isLoading} />
{errorSection}
{buttonGroup}
</div>
</div>
@ -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 && (
<>
<SessionButton
text="Reset Database"
text={window.i18n('clearDevice')}
buttonColor={SessionButtonColor.Danger}
buttonType={SessionButtonType.Simple}
onClick={this.initClearDataView}
@ -189,7 +181,7 @@ class SessionPasswordPromptInner extends React.PureComponent<{}, State> {
)}
{/* TODO Theming - Fix */}
<SessionButton
text={window.i18n('done')}
text={showResetElements ? window.i18n('tryAgain') : window.i18n('done')}
buttonType={SessionButtonType.Simple}
onClick={this.initLogin}
/>
@ -201,7 +193,7 @@ class SessionPasswordPromptInner extends React.PureComponent<{}, State> {
return (
<div className="button-group">
<SessionButton
text={window.i18n('clearAllData')}
text={window.i18n('clearDevice')}
buttonColor={SessionButtonColor.Danger}
buttonType={SessionButtonType.Simple}
onClick={window.clearLocalData}
@ -227,6 +219,7 @@ export const SessionPasswordPrompt = () => {
return (
<SessionTheme>
<SessionToastContainer />
<StyledContent>
<SessionPasswordPromptInner />
</StyledContent>

View file

@ -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<Props, State> {
}
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;

View file

@ -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 (
<Flex container={true}>
<StyledAuthorContainer container={true}>
<ContactName
pubkey={displayedPubkey}
name={authorName}
@ -47,6 +53,6 @@ export const MessageAuthorText = (props: Props) => {
boldProfileName={true}
shouldShowPubkey={Boolean(isPublic)}
/>
</Flex>
</StyledAuthorContainer>
);
};

View file

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

View file

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

View file

@ -114,7 +114,7 @@ export class SessionPasswordDialog extends React.Component<Props, State> {
<div className="session-modal__button-group">
<SessionButton
text={window.i18n('ok')}
text={window.i18n('done')}
buttonColor={passwordAction === 'remove' ? SessionButtonColor.Danger : undefined}
buttonType={SessionButtonType.Simple}
onClick={this.setPassword}

View file

@ -15,6 +15,7 @@ import { matchesHash } from '../../util/passwordUtils';
import { SettingsCategoryPermissions } from './section/CategoryPermissions';
import { SettingsCategoryHelp } from './section/CategoryHelp';
import styled from 'styled-components';
import { ToastUtils } from '../../session/utils';
export function getMediaPermissionsSettings() {
return window.getSettingValue('media-permissions');
@ -42,9 +43,6 @@ export interface SettingsViewProps {
interface State {
hasPassword: boolean | null;
pwdLockError: string | null;
mediaSetting: boolean | null;
callMediaSetting: boolean | null;
shouldLockSettings: boolean | null;
}
@ -105,8 +103,7 @@ const StyledPasswordInput = styled.input`
border: 1px solid var(--border-color);
border-radius: 7px;
text-align: center;
font-size: 24px;
letter-spacing: 5px;
font-size: 16px;
font-family: var(--font-default);
::placeholder {
@ -120,30 +117,26 @@ const StyledH3 = styled.h3`
`;
const PasswordLock = ({
pwdLockError,
validatePasswordLock,
}: {
pwdLockError: string | null;
validatePasswordLock: () => Promise<boolean>;
}) => {
return (
<div className="session-settings__password-lock">
<div className="session-settings__password-lock-box">
<StyledH3>{window.i18n('password')}</StyledH3>
<StyledH3>{window.i18n('passwordViewTitle')}</StyledH3>
<StyledPasswordInput
type="password"
id="password-lock-input"
defaultValue=""
placeholder="Password"
placeholder={window.i18n('enterPassword')}
data-testid="password-lock-input"
autoFocus={true}
/>
{pwdLockError && <div className="session-label danger">{pwdLockError}</div>}
<SessionButton
buttonType={SessionButtonType.Simple}
text={window.i18n('ok')}
text={window.i18n('done')}
onClick={validatePasswordLock}
/>
</div>
@ -210,9 +203,6 @@ export class SessionSettingsView extends React.Component<SettingsViewProps, Stat
this.state = {
hasPassword: null,
pwdLockError: null,
mediaSetting: null,
callMediaSetting: null,
shouldLockSettings: true,
};
@ -228,10 +218,6 @@ export class SessionSettingsView extends React.Component<SettingsViewProps, Stat
public componentDidMount() {
window.addEventListener('keyup', this.onKeyUp);
const mediaSetting = getMediaPermissionsSettings();
const callMediaSetting = getCallMediaPermissionsSettings();
this.setState({ mediaSetting, callMediaSetting });
}
public componentWillUnmount() {
@ -244,9 +230,7 @@ export class SessionSettingsView extends React.Component<SettingsViewProps, Stat
);
if (!enteredPassword) {
this.setState({
pwdLockError: window.i18n('noGivenPassword'),
});
ToastUtils.pushToastError('validatePassword', window.i18n('noGivenPassword'));
return false;
}
@ -254,17 +238,13 @@ export class SessionSettingsView extends React.Component<SettingsViewProps, Stat
// Check if the password matches the hash we have stored
const hash = await Data.getPasswordHash();
if (hash && !matchesHash(enteredPassword, hash)) {
this.setState({
pwdLockError: window.i18n('invalidPassword'),
});
ToastUtils.pushToastError('validatePassword', window.i18n('invalidPassword'));
return false;
}
// Unlocked settings
this.setState({
shouldLockSettings: false,
pwdLockError: null,
});
return true;
@ -280,10 +260,7 @@ export class SessionSettingsView extends React.Component<SettingsViewProps, Stat
<StyledSettingsView>
{shouldRenderPasswordLock ? (
<PasswordLock
pwdLockError={this.state.pwdLockError}
validatePasswordLock={this.validatePasswordLock}
/>
<PasswordLock validatePasswordLock={this.validatePasswordLock} />
) : (
<StyledSettingsList ref={this.settingsViewRef}>
<SettingInCategory
@ -304,7 +281,6 @@ export class SessionSettingsView extends React.Component<SettingsViewProps, Stat
this.setState({
hasPassword: true,
shouldLockSettings: true,
pwdLockError: null,
});
}

View file

@ -148,6 +148,7 @@ export const Data = {
getMessageBySenderAndTimestamp,
getUnreadByConversation,
getUnreadCountByConversation,
markAllAsReadByConversationNoExpiration,
getMessageCountByType,
getMessagesByConversation,
getLastMessagesByConversation,
@ -472,6 +473,7 @@ async function getMessageBySenderAndTimestamp({
source,
timestamp,
});
if (!messages || !messages.length) {
return null;
}
@ -484,6 +486,13 @@ async function getUnreadByConversation(conversationId: string): Promise<MessageC
return new MessageCollection(messages);
}
async function markAllAsReadByConversationNoExpiration(
conversationId: string
): Promise<Array<number>> {
const messagesIds = await channels.markAllAsReadByConversationNoExpiration(conversationId);
return messagesIds;
}
// might throw
async function getUnreadCountByConversation(conversationId: string): Promise<number> {
return channels.getUnreadCountByConversation(conversationId);

View file

@ -40,6 +40,7 @@ const channelsToMake = new Set([
'removeMessage',
'_removeMessages',
'getUnreadByConversation',
'markAllAsReadByConversationNoExpiration',
'getUnreadCountByConversation',
'getMessageCountByType',
'removeAllMessagesInConversation',

View file

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

View file

@ -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<ConversationAttributes> {
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<ConversationAttributes> {
}
}
/**
* 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<ConversationAttributes> {
// 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<ConversationAttributes> {
// 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<number>;
await this.sendReadReceiptsIfNeeded(timestamps);
}

View file

@ -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,

View file

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

View file

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

View file

@ -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<PreviewWithAttachmentUrl>;
/// 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,
};
}

View file

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

View file

@ -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'