Merge remote-tracking branch 'upstream/clearnet' into fix-i18n-with-dollars-sign

pull/2190/head
Audric Ackermann 4 years ago
commit 71aa6c813c
No known key found for this signature in database
GPG Key ID: 999F434D76324AD4

@ -35,8 +35,8 @@ module.exports = grunt => {
'node_modules/bytebuffer/dist/bytebuffer.js',
'js/curve/curve25519_compiled.js',
'js/curve/curve25519_wrapper.js',
'node_modules/libsodium/dist/modules/libsodium.js',
'node_modules/libsodium-wrappers/dist/modules/libsodium-wrappers.js',
'node_modules/libsodium-sumo/dist/modules-sumo/libsodium-sumo.js',
'node_modules/libsodium-wrappers-sumo/dist/modules-sumo/libsodium-wrappers.js',
'libtextsecure/libsignal-protocol.js',
'js/util_worker_tasks.js',
];

@ -397,11 +397,11 @@
"notificationSubtitle": "Notifications - $setting$",
"surveyTitle": "Take our Session Survey",
"goToOurSurvey": "Go to our survey",
"blockAll": "Block All",
"clearAll": "Clear All",
"messageRequests": "Message Requests",
"requestsSubtitle": "Pending Requests",
"requestsPlaceholder": "No requests",
"messageRequestsDescription": "Enable Message Request Inbox",
"hideRequestBannerDescription": "Hide the Message Request banner until you receive a new message request.",
"incomingCallFrom": "Incoming call from '$name$'",
"ringing": "Ringing...",
"establishingConnection": "Establishing connection...",
@ -419,12 +419,28 @@
"noAudioOutputFound": "No audio output found",
"callMediaPermissionsTitle": "Voice and video calls",
"callMissedCausePermission": "Call missed from '$name$' because you need to enable the 'Voice and video calls' permission in the Privacy Settings.",
"callMissedNotApproved": "Call missed from '$name$' as you haven't approved this conversation yet. Send a message to him first.",
"callMissedNotApproved": "Call missed from '$name$' as you haven't approved this conversation yet. Send a message to them first.",
"callMediaPermissionsDescription": "Allows access to accept voice and video calls from other users",
"callMediaPermissionsDialogContent": "The current implementation of voice/video calls will expose your IP address to the Oxen Foundation servers and the calling/called user.",
"startedACall": "You called $name$",
"answeredACall": "Call with $name$",
"trimDatabase": "Trim Database",
"trimDatabaseDescription": "Reduces your message database size to your last 10,000 messages.",
"trimDatabaseConfirmationBody": "Are you sure you want to delete your $deleteAmount$ oldest received messages?"
"trimDatabaseConfirmationBody": "Are you sure you want to delete your $deleteAmount$ oldest received messages?",
"messageRequestPending": "Your message request is currently pending",
"messageRequestAccepted": "Your message request has been accepted",
"messageRequestAcceptedOurs": "You have accepted $name$'s message request",
"messageRequestAcceptedOursNoName": "You have accepted the message request",
"declineRequestMessage": "Are you sure you want to decline this message request?",
"respondingToRequestWarning": "Sending a message to this user will automatically accept their message request and reveal your Session ID.",
"hideRequestBanner": "Hide Message Request Banner",
"openMessageRequestInbox": "View Message Requests",
"noMessageRequestsPending": "No pending message requests",
"noMediaUntilApproved": "You cannot send attachments until the conversation is approved",
"mustBeApproved": "This conversation must be accepted to use this feature",
"youHaveANewFriendRequest": "You have a new friend request",
"clearAllConfirmationTitle": "Clear All Message Requests",
"clearAllConfirmationBody": "Are you sure you want to clear all message requests?",
"hideBanner": "Hide",
"openMessageRequestInboxDescription": "View your Message Request inbox"
}

@ -59,6 +59,7 @@ module.exports = {
removeMessage,
getUnreadByConversation,
getUnreadCountByConversation,
getMessageCountByType,
getMessageBySenderAndSentAt,
getMessageBySenderAndServerTimestamp,
getMessageBySenderAndTimestamp,
@ -844,6 +845,7 @@ const LOKI_SCHEMA_VERSIONS = [
updateToLokiSchemaVersion18,
updateToLokiSchemaVersion19,
updateToLokiSchemaVersion20,
updateToLokiSchemaVersion21,
];
function updateToLokiSchemaVersion1(currentVersion, db) {
@ -1344,8 +1346,8 @@ function updateToLokiSchemaVersion20(currentVersion, db) {
if (currentVersion >= targetVersion) {
return;
}
console.log(`updateToLokiSchemaVersion${targetVersion}: starting...`);
db.transaction(() => {
// looking for all private conversations, with a nickname set
const rowsToUpdate = db
@ -1369,6 +1371,41 @@ function updateToLokiSchemaVersion20(currentVersion, db) {
obj.name = obj.profile.displayName;
updateConversation(obj, db);
}
writeLokiSchemaVersion(targetVersion, db);
})();
});
console.log(`updateToLokiSchemaVersion${targetVersion}: success!`);
}
function updateToLokiSchemaVersion21(currentVersion, db) {
const targetVersion = 21;
if (currentVersion >= targetVersion) {
return;
}
console.log(`updateToLokiSchemaVersion${targetVersion}: starting...`);
db.transaction(() => {
db.exec(`
UPDATE ${CONVERSATIONS_TABLE} SET
json = json_set(json, '$.didApproveMe', 1, '$.isApproved', 1)
WHERE type = 'private';
`);
// all closed group admins
const closedGroupRows = getAllClosedGroupConversations(db) || [];
const adminIds = closedGroupRows.map(json => jsonToObject(json).groupAdmins);
forEach(adminIds, id => {
db.exec(
`
UPDATE ${CONVERSATIONS_TABLE} SET
json = json_set(json, '$.didApproveMe', 1, '$.isApproved', 1)
WHERE type = id
values ($id);
`
).run({
id,
});
});
writeLokiSchemaVersion(targetVersion, db);
@ -2274,6 +2311,27 @@ function getUnreadCountByConversation(conversationId) {
return row['count(*)'];
}
function getMessageCountByType(conversationId, type = '%') {
const row = globalInstance
.prepare(
`SELECT count(*) from ${MESSAGES_TABLE}
WHERE conversationId = $conversationId
AND type = $type;`
)
.get({
conversationId,
type,
});
if (!row) {
throw new Error(
`getIncomingMessagesCountByConversation: Unable to get incoming messages count of ${conversationId}`
);
}
return row['count(*)'];
}
// Note: Sorting here is necessary for getting the last message (with limit 1)
// be sure to update the sorting order to sort messages on redux too (sortMessages)
const orderByClause = 'ORDER BY COALESCE(serverTimestamp, sent_at, received_at) DESC';
@ -3072,13 +3130,13 @@ function getMessagesCountByConversation(instance, conversationId) {
return row ? row['count(*)'] : 0;
}
function getAllClosedGroupConversationsV1(instance) {
function getAllClosedGroupConversations(instance) {
const rows = (globalInstance || instance)
.prepare(
`SELECT json FROM ${CONVERSATIONS_TABLE} WHERE
type = 'group' AND
id NOT LIKE 'publicChat:%'
ORDER BY id ASC;`
ORDER BY id ASC;`
)
.all();
@ -3094,7 +3152,7 @@ function remove05PrefixFromStringIfNeeded(str) {
function updateExistingClosedGroupV1ToClosedGroupV2(db) {
// the migration is called only once, so all current groups not being open groups are v1 closed group.
const allClosedGroupV1 = getAllClosedGroupConversationsV1(db) || [];
const allClosedGroupV1 = getAllClosedGroupConversations(db) || [];
allClosedGroupV1.forEach(groupV1 => {
const groupId = groupV1.id;

@ -5,6 +5,6 @@ If you update the app icon, you also need to update all those file generated fro
The current source file is build/session_icon_source_1024px.png
-> macOS: use https://cloudconvert.com/png-to-icns to get .icns from the 1024px.png source file => save as icon.icns
-> macOS: use https://cloudconvert.com/png-to-icns to get .icns from the 1024px.png source file => save as icon-mac.icns
-> windows: use https://cloudconvert.com/png-to-ico to get .ico from the 1024px.png source file => save as icon.ico
-> linux: build binaries on github actions, get the zip with the deb+appImage, extract it, all the icons are in a .icons-set folder, and you can copy paste them into build/icons

Binary file not shown.

@ -1007,19 +1007,19 @@ function getThemeFromMainWindow() {
});
}
function askForMediaAccess() {
async function askForMediaAccess() {
// Microphone part
let status = systemPreferences.getMediaAccessStatus('microphone');
if (status !== 'granted') {
systemPreferences.askForMediaAccess('microphone');
await systemPreferences.askForMediaAccess('microphone');
}
// Camera part
status = systemPreferences.getMediaAccessStatus('camera');
if (status !== 'granted') {
systemPreferences.askForMediaAccess('camera');
await systemPreferences.askForMediaAccess('camera');
}
}
ipc.on('media-access', () => {
ipc.on('media-access', async () => {
askForMediaAccess();
});

@ -80,7 +80,7 @@
"ip2country": "1.0.1",
"jquery": "3.3.1",
"jsbn": "1.1.0",
"libsodium-wrappers": "^0.7.8",
"libsodium-wrappers-sumo": "^0.7.9",
"linkify-it": "3.0.2",
"lodash": "4.17.11",
"long": "^4.0.0",
@ -138,7 +138,7 @@
"@types/filesize": "3.6.0",
"@types/fs-extra": "5.0.5",
"@types/jquery": "3.3.29",
"@types/libsodium-wrappers": "^0.7.8",
"@types/libsodium-wrappers-sumo": "^0.7.5",
"@types/linkify-it": "2.0.3",
"@types/lodash": "4.14.106",
"@types/mocha": "5.0.0",
@ -208,7 +208,7 @@
},
"mac": {
"category": "public.app-category.social-networking",
"icon": "build/icon.icns",
"icon": "build/icon-mac.icns",
"target": [
"dmg",
"zip"
@ -247,7 +247,7 @@
},
"asarUnpack": "node_modules/spellchecker/vendor/hunspell_dictionaries",
"target": ["deb", "rpm", "freebsd"],
"icon": "build/icon.icns"
"icon": "build/icon-linux.icns"
},
"asarUnpack": [
"node_modules/better-sqlite3/build/Release/better_sqlite3.node"

@ -5,8 +5,9 @@ const path = require('path');
const { webFrame, remote, clipboard, ipcRenderer } = require('electron');
const { app } = remote;
const url = require('url');
const config = require('url').parse(window.location.toString(), true).query;
const config = url.parse(window.location.toString(), true).query;
let title = config.name;
if (config.environment !== 'production') {
@ -30,7 +31,6 @@ window.getNodeVersion = () => config.node_version;
window.sessionFeatureFlags = {
useOnionRequests: true,
useMessageRequests: false,
useCallMessage: true,
};

@ -35,6 +35,10 @@ message Unsend {
required string author = 2;
}
message MessageRequestResponse {
// @required
required bool isApproved = 1;
}
message Content {
optional DataMessage dataMessage = 1;
@ -44,6 +48,7 @@ message Content {
optional ConfigurationMessage configurationMessage = 7;
optional DataExtractionNotification dataExtractionNotification = 8;
optional Unsend unsendMessage = 9;
optional MessageRequestResponse messageRequestResponse = 10;
}
message KeyPair {
@ -195,6 +200,7 @@ message ConfigurationMessage {
optional bytes profileKey = 4;
optional bool isApproved = 5;
optional bool isBlocked = 6;
optional bool didApproveMe = 7;
}
repeated ClosedGroup closedGroups = 1;

@ -628,6 +628,7 @@
display: flex;
flex-direction: row;
padding-inline-end: 16px;
padding-top: 8px;
padding-bottom: 8px;
padding-inline-start: 16px;
align-items: center;

@ -227,7 +227,6 @@ textarea {
width: 100%;
border-radius: 2px;
text-align: center;
&.primary {
background-color: $session-color-primary;
}
@ -605,7 +604,7 @@ label {
.react-contexify__item:not(.react-contexify__item--disabled):hover
> .react-contexify__item__content {
background: var(--color-accent);
color: var(--color-text-opposite);
color: var(--color-text-menu-highlighted);
}
.react-contexify__item__content {
transition: $session-transition-duration;

@ -97,8 +97,12 @@ export const AvatarPlaceHolder = (props: Props) => {
);
}
const initial = getInitials(name)?.toLocaleUpperCase() || '0';
const fontSize = diameter * 0.5;
let initials = getInitials(name)?.toLocaleUpperCase() || '0';
if (name.indexOf(' ') === -1) {
initials = name.substring(0, 2);
}
const fontSize = Math.floor(initials.length > 1 ? diameter * 0.4 : diameter * 0.5);
const bgColorIndex = hash % avatarPlaceholderColors.length;
@ -125,8 +129,9 @@ export const AvatarPlaceHolder = (props: Props) => {
stroke="white"
strokeWidth={1}
alignmentBaseline="central"
height={fontSize}
>
{initial}
{initials}
</text>
</g>
</svg>

@ -40,6 +40,7 @@ import {
import { SessionButton, SessionButtonColor, SessionButtonType } from '../basic/SessionButton';
import { SessionIconButton } from '../icon';
import { ConversationHeaderMenu } from '../menu/ConversationHeaderMenu';
import { Flex } from '../basic/Flex';
export interface TimerOption {
name: string;
@ -364,10 +365,12 @@ export const ConversationHeaderWithDetails = () => {
<ConversationHeaderTitle />
</div>
</div>
{!isKickedFromGroup && <ExpirationLength expirationSettingName={expirationSettingName} />}
{!isSelectionMode && (
<>
<Flex container={true} flexDirection="row" alignItems="center">
{!isKickedFromGroup && (
<ExpirationLength expirationSettingName={expirationSettingName} />
)}
<CallButton />
<AvatarHeader
onAvatarClick={() => {
@ -376,7 +379,7 @@ export const ConversationHeaderWithDetails = () => {
pubkey={selectedConvoKey}
showBackButton={isMessageDetailOpened}
/>
</>
</Flex>
)}
<ConversationHeaderMenu triggerId={triggerId} />

@ -0,0 +1,95 @@
import React, { useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import styled from 'styled-components';
import { getMessageCountByType } from '../../data/data';
import {
approveConvoAndSendResponse,
declineConversationWithConfirm,
} from '../../interactions/conversationInteractions';
import { MessageDirection } from '../../models/messageType';
import { getConversationController } from '../../session/conversations';
import { getSelectedConversation } from '../../state/selectors/conversations';
import { SessionButton, SessionButtonColor, SessionButtonType } from '../basic/SessionButton';
export const ConversationMessageRequestButtons = () => {
const selectedConversation = useSelector(getSelectedConversation);
const [hasIncoming, setHasIncomingMsgs] = useState(false);
const [incomingChecked, setIncomingChecked] = useState(false);
useEffect(() => {
async function getIncomingMessages() {
const id = selectedConversation?.id;
if (id) {
const msgCount = await getMessageCountByType(
selectedConversation?.id,
MessageDirection.incoming
);
if (msgCount > 0) {
setHasIncomingMsgs(true);
} else {
setHasIncomingMsgs(false);
}
setIncomingChecked(true);
}
}
// tslint:disable-next-line: no-floating-promises
getIncomingMessages();
}, []);
if (!selectedConversation || !hasIncoming || !incomingChecked) {
return null;
}
const convoModel = getConversationController().get(selectedConversation.id);
const showMsgRequestUI = convoModel && convoModel.isIncomingRequest();
const handleDeclineConversationRequest = () => {
declineConversationWithConfirm(selectedConversation.id, true);
};
const handleAcceptConversationRequest = async () => {
const { id } = selectedConversation;
const convo = getConversationController().get(selectedConversation.id);
await convo.setDidApproveMe(true);
await convo.addOutgoingApprovalMessage(Date.now());
await approveConvoAndSendResponse(id, true);
};
if (!showMsgRequestUI) {
return null;
}
return (
<ConversationRequestBanner>
<ConversationBannerRow>
<SessionButton
buttonColor={SessionButtonColor.Green}
buttonType={SessionButtonType.BrandOutline}
onClick={handleAcceptConversationRequest}
text={window.i18n('accept')}
/>
<SessionButton
buttonColor={SessionButtonColor.Danger}
buttonType={SessionButtonType.BrandOutline}
text={window.i18n('decline')}
onClick={handleDeclineConversationRequest}
/>
</ConversationBannerRow>
</ConversationRequestBanner>
);
};
const ConversationBannerRow = styled.div`
display: flex;
flex-direction: row;
gap: var(--margins-lg);
justify-content: center;
`;
const ConversationRequestBanner = styled.div`
display: flex;
flex-direction: column;
justify-content: center;
padding: var(--margins-lg);
gap: var(--margins-lg);
`;

@ -0,0 +1,61 @@
import React, { useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import styled from 'styled-components';
import { getMessageCountByType } from '../../data/data';
import { MessageDirection } from '../../models/messageType';
import { getConversationController } from '../../session/conversations';
import { getSelectedConversation } from '../../state/selectors/conversations';
export const ConversationRequestinfo = () => {
const selectedConversation = useSelector(getSelectedConversation);
const convoModel =
selectedConversation && getConversationController().get(selectedConversation.id);
const showMsgRequestUI = selectedConversation && convoModel && convoModel.isIncomingRequest();
const [hasIncomingMessages, setHasIncomingMessages] = useState(false);
const [incomingChecked, setIncomingChecked] = useState(false);
useEffect(() => {
async function getIncomingMessages() {
const id = selectedConversation?.id;
if (id) {
const msgCount = await getMessageCountByType(
selectedConversation?.id,
MessageDirection.incoming
);
if (msgCount > 0) {
setHasIncomingMessages(true);
} else {
setHasIncomingMessages(false);
}
setIncomingChecked(true);
}
}
// tslint:disable-next-line: no-floating-promises
getIncomingMessages();
}, []);
if (!showMsgRequestUI || !hasIncomingMessages || !incomingChecked) {
return null;
}
return (
<ConversationRequestTextBottom>
<ConversationRequestTextInner>
{window.i18n('respondingToRequestWarning')}
</ConversationRequestTextInner>
</ConversationRequestTextBottom>
);
};
const ConversationRequestTextBottom = styled.div`
display: flex;
flex-direction: row;
justify-content: center;
padding: var(--margins-lg);
`;
const ConversationRequestTextInner = styled.div`
color: var(--color-text-subtle);
text-align: center;
max-width: 390px;
`;

@ -48,6 +48,8 @@ import {
} from '../../types/attachments/VisualAttachment';
import { blobToArrayBuffer } from 'blob-util';
import { MAX_ATTACHMENT_FILESIZE_BYTES } from '../../session/constants';
import { ConversationMessageRequestButtons } from './ConversationRequestButtons';
import { ConversationRequestinfo } from './ConversationRequestInfo';
// tslint:disable: jsx-curly-spacing
interface State {
@ -236,10 +238,10 @@ export class SessionConversation extends React.Component<Props, State> {
<div className={classNames('conversation-info-panel', showMessageDetails && 'show')}>
<MessageDetail />
</div>
{lightBoxOptions?.media && this.renderLightBox(lightBoxOptions)}
<div className="conversation-messages">
<ConversationMessageRequestButtons />
<SplitViewContainer
top={<InConversationCallContainer />}
bottom={
@ -254,6 +256,7 @@ export class SessionConversation extends React.Component<Props, State> {
{isDraggingFile && <SessionFileDropzone />}
</div>
<ConversationRequestinfo />
<CompositionBox
sendMessage={this.sendMessageFn}
stagedAttachments={this.props.stagedAttachments}

@ -2,7 +2,10 @@ import React, { useLayoutEffect } from 'react';
import { useSelector } from 'react-redux';
// tslint:disable-next-line: no-submodule-imports
import useKey from 'react-use/lib/useKey';
import { PropsForDataExtractionNotification } from '../../models/messageType';
import {
PropsForDataExtractionNotification,
PropsForMessageRequestResponse,
} from '../../models/messageType';
import {
PropsForCallNotification,
PropsForExpirationTimer,
@ -15,7 +18,7 @@ import {
getSortedMessagesTypesOfSelectedConversation,
} from '../../state/selectors/conversations';
import { GroupUpdateMessage } from './message/message-item/GroupUpdateMessage';
import { DataExtractionNotification } from './message/message-item/DataExtractionNotification';
import { MessageRequestResponse } from './message/message-item/MessageRequestResponse';
import { MessageDateBreak } from './message/message-item/DateBreak';
import { GroupInvitation } from './message/message-item/GroupInvitation';
import { Message } from './message/message-item/Message';
@ -23,6 +26,7 @@ import { CallNotification } from './message/message-item/notification-bubble/Cal
import { SessionLastSeenIndicator } from './SessionLastSeenIndicator';
import { TimerNotification } from './TimerNotification';
import { DataExtractionNotification } from './message/message-item/DataExtractionNotification';
function isNotTextboxEvent(e: KeyboardEvent) {
return (e?.target as any)?.type === undefined;
@ -106,6 +110,16 @@ export const SessionMessagesList = (props: {
return [<GroupInvitation key={messageId} {...msgProps} />, dateBreak, unreadIndicator];
}
if (messageProps.message?.messageType === 'message-request-response') {
const msgProps = messageProps.message.props as PropsForMessageRequestResponse;
return [
<MessageRequestResponse key={messageId} {...msgProps} />,
dateBreak,
unreadIndicator,
];
}
if (messageProps.message?.messageType === 'data-extraction') {
const msgProps = messageProps.message.props as PropsForDataExtractionNotification;

@ -404,16 +404,24 @@ class CompositionBoxInner extends React.Component<Props, State> {
return null;
}
const makeMessagePlaceHolderText = () => {
if (isKickedFromGroup) {
return i18n('youGotKickedFromGroup');
}
if (left) {
return i18n('youLeftTheGroup');
}
if (isBlocked && isPrivate) {
return i18n('unblockToSend');
}
if (isBlocked && !isPrivate) {
return i18n('unblockGroupToSend');
}
return i18n('sendMessage');
};
const { isKickedFromGroup, left, isPrivate, isBlocked } = this.props.selectedConversation;
const messagePlaceHolder = isKickedFromGroup
? i18n('youGotKickedFromGroup')
: left
? i18n('youLeftTheGroup')
: isBlocked && isPrivate
? i18n('unblockToSend')
: isBlocked && !isPrivate
? i18n('unblockGroupToSend')
: i18n('sendMessage');
const messagePlaceHolder = makeMessagePlaceHolderText();
const { typingEnabled } = this.props;
const neverMatchingRegex = /($a)/;
@ -711,6 +719,13 @@ class CompositionBoxInner extends React.Component<Props, State> {
}
private onChooseAttachment() {
if (
!this.props.selectedConversation?.didApproveMe &&
this.props.selectedConversation?.isPrivate
) {
ToastUtils.pushNoMediaUntilApproved();
return;
}
this.fileInput.current?.click();
}

@ -8,7 +8,7 @@ import { SessionButtonColor } from '../../../basic/SessionButton';
import { SessionIcon } from '../../../icon';
const StyledTrustSenderUI = styled.div`
padding-inline: var(--margins-xs);
padding-inline: var(--margins-sm);
display: flex;
align-items: center;
`;

@ -0,0 +1,44 @@
import React from 'react';
import { useConversationUsername } from '../../../../hooks/useParamSelector';
import { PropsForMessageRequestResponse } from '../../../../models/messageType';
import { UserUtils } from '../../../../session/utils';
import { Flex } from '../../../basic/Flex';
import { SpacerSM, Text } from '../../../basic/Text';
import { ReadableMessage } from './ReadableMessage';
export const MessageRequestResponse = (props: PropsForMessageRequestResponse) => {
const { messageId, isUnread, receivedAt, conversationId } = props;
const profileName = useConversationUsername(conversationId);
const isFromSync = props.source === UserUtils.getOurPubKeyStrFromCache();
let msgText = '';
if (isFromSync) {
msgText = profileName
? window.i18n('messageRequestAcceptedOurs', [profileName])
: window.i18n('messageRequestAcceptedOursNoName');
} else {
msgText = window.i18n('messageRequestAccepted');
}
return (
<ReadableMessage
messageId={messageId}
receivedAt={receivedAt}
isUnread={isUnread}
key={`readable-message-${messageId}`}
>
<Flex
container={true}
flexDirection="row"
alignItems="center"
justifyContent="center"
margin={'var(--margins-sm)'}
id={`msg-${messageId}`}
>
<SpacerSM />
<Text text={msgText} subtle={true} ellipsisOverflow={true} />
</Flex>
</ReadableMessage>
);
};

@ -25,7 +25,7 @@ import {
import { applyTheme } from '../../state/ducks/theme';
import { getFocusedSection } from '../../state/selectors/section';
import { clearSearch } from '../../state/ducks/search';
import { SectionType, showLeftPaneSection } from '../../state/ducks/section';
import { SectionType, setOverlayMode, showLeftPaneSection } from '../../state/ducks/section';
import { cleanUpOldDecryptedMedias } from '../../session/crypto/DecryptedAttachmentsManager';
@ -81,8 +81,10 @@ const Section = (props: { type: SectionType }) => {
// Show Path Indicator Modal
dispatch(onionPathModal({}));
} else {
// message section
dispatch(clearSearch());
dispatch(showLeftPaneSection(type));
dispatch(setOverlayMode(undefined));
}
};

@ -6,7 +6,7 @@ import { SessionTheme } from '../../state/ducks/SessionTheme';
import { getLeftPaneLists } from '../../state/selectors/conversations';
import { getSearchResults, isSearching } from '../../state/selectors/search';
import { getFocusedSection, getOverlayMode } from '../../state/selectors/section';
import { getIsMessageRequestsEnabled } from '../../state/selectors/userConfig';
import { getHideMessageRequestBanner } from '../../state/selectors/userConfig';
import { ActionsPanel } from './ActionsPanel';
import { LeftPaneContactSection } from './LeftPaneContactSection';
import { LeftPaneMessageSection } from './LeftPaneMessageSection';
@ -28,7 +28,7 @@ const InnerLeftPaneMessageSection = () => {
const searchResults = showSearch ? useSelector(getSearchResults) : undefined;
const lists = showSearch ? undefined : useSelector(getLeftPaneLists);
const messageRequestsEnabled = useSelector(getIsMessageRequestsEnabled);
const messageRequestsEnabled = useSelector(getHideMessageRequestBanner);
const overlayMode = useSelector(getOverlayMode);
return (

@ -110,13 +110,11 @@ export class LeftPaneMessageSection extends React.Component<Props> {
return (
<div className="module-conversations-list-content">
<SessionSearchInput />
{window.sessionFeatureFlags.useMessageRequests ? (
<MessageRequestsBanner
handleOnClick={() => {
window.inboxStore?.dispatch(setOverlayMode('message-requests'));
}}
/>
) : null}
<MessageRequestsBanner
handleOnClick={() => {
window.inboxStore?.dispatch(setOverlayMode('message-requests'));
}}
/>
{this.renderList()}
{this.renderBottomButtons()}
</div>

@ -5,11 +5,11 @@ import { disableRecoveryPhrasePrompt } from '../../state/ducks/userConfig';
import { getShowRecoveryPhrasePrompt } from '../../state/selectors/userConfig';
import { recoveryPhraseModal } from '../../state/ducks/modalDialog';
import { Flex } from '../basic/Flex';
import { getFocusedSection } from '../../state/selectors/section';
import { SectionType } from '../../state/ducks/section';
import { getFocusedSection, getOverlayMode } from '../../state/selectors/section';
import { SectionType, setOverlayMode } from '../../state/ducks/section';
import { UserUtils } from '../../session/utils';
import { SessionButton, SessionButtonType } from '../basic/SessionButton';
import { SessionIcon } from '../icon';
import { SessionIcon, SessionIconButton } from '../icon';
const SectionTitle = styled.h1`
padding: 0 var(--margins-sm);
@ -20,10 +20,15 @@ const SectionTitle = styled.h1`
export const LeftPaneSectionHeader = (props: { buttonClicked?: any }) => {
const showRecoveryPhrasePrompt = useSelector(getShowRecoveryPhrasePrompt);
const focusedSection = useSelector(getFocusedSection);
const overlayMode = useSelector(getOverlayMode);
const dispatch = useDispatch();
let label: string | undefined;
const isMessageSection = focusedSection === SectionType.Message;
const isMessageRequestOverlay = overlayMode === 'message-requests';
const showBackButton = isMessageRequestOverlay && isMessageSection;
switch (focusedSection) {
case SectionType.Contact:
@ -33,7 +38,9 @@ export const LeftPaneSectionHeader = (props: { buttonClicked?: any }) => {
label = window.i18n('settingsHeader');
break;
case SectionType.Message:
label = window.i18n('messagesHeader');
label = isMessageRequestOverlay
? window.i18n('messageRequests')
: window.i18n('messagesHeader');
break;
default:
}
@ -41,8 +48,19 @@ export const LeftPaneSectionHeader = (props: { buttonClicked?: any }) => {
return (
<Flex flexDirection="column">
<div className="module-left-pane__header">
{showBackButton && (
<SessionIconButton
onClick={() => {
dispatch(setOverlayMode(undefined));
}}
iconType="chevron"
iconRotation={90}
iconSize="medium"
margin="0 0 var(--margins-xs) var(--margins-xs)"
/>
)}
<SectionTitle>{label}</SectionTitle>
{isMessageSection && (
{isMessageSection && !isMessageRequestOverlay && (
<SessionButton onClick={props.buttonClicked} dataTestId="new-conversation-button">
<SessionIcon iconType="plus" iconSize="small" iconColor="white" />
</SessionButton>

@ -1,11 +1,14 @@
import React from 'react';
import { contextMenu } from 'react-contexify';
import { createPortal } from 'react-dom';
import { useSelector } from 'react-redux';
import styled from 'styled-components';
import { getConversationRequests } from '../../state/selectors/conversations';
import { getUnreadConversationRequests } from '../../state/selectors/conversations';
import { getHideMessageRequestBanner } from '../../state/selectors/userConfig';
import { SessionIcon, SessionIconSize, SessionIconType } from '../icon';
import { MemoMessageRequestBannerContextMenu } from '../menu/MessageRequestBannerContextMenu';
const StyledMessageRequestBanner = styled.div`
border-left: var(--border-unread);
height: 64px;
width: 100%;
max-width: 300px;
@ -15,6 +18,8 @@ const StyledMessageRequestBanner = styled.div`
align-items: center;
cursor: pointer;
background: var(--color-request-banner-background);
transition: var(--session-transition-duration);
&:hover {
@ -42,7 +47,7 @@ const StyledCircleIcon = styled.div`
const StyledUnreadCounter = styled.div`
font-weight: bold;
border-radius: var(--margins-sm);
background-color: var(--color-clickable-hovered);
background-color: var(--color-request-banner-unread-background);
margin-left: 10px;
min-width: 20px;
height: 20px;
@ -55,14 +60,13 @@ const StyledUnreadCounter = styled.div`
`;
const StyledGridContainer = styled.div`
border: solid 1px black;
display: flex;
width: 36px;
height: 36px;
align-items: center;
border-radius: 50%;
justify-content: center;
background-color: var(--color-conversation-item-has-unread);
background-color: var(--color-request-banner-icon-background);
`;
export const CirclularIcon = (props: { iconType: SessionIconType; iconSize: SessionIconSize }) => {
@ -74,7 +78,7 @@ export const CirclularIcon = (props: { iconType: SessionIconType; iconSize: Sess
<SessionIcon
iconType={iconType}
iconSize={iconSize}
iconColor={'var(--color-text-subtle)'}
iconColor="var(--color-request-banner-icon)"
/>
</StyledGridContainer>
</StyledCircleIcon>
@ -83,21 +87,53 @@ export const CirclularIcon = (props: { iconType: SessionIconType; iconSize: Sess
export const MessageRequestsBanner = (props: { handleOnClick: () => any }) => {
const { handleOnClick } = props;
const conversationRequests = useSelector(getConversationRequests);
const conversationRequestsUnread = useSelector(getUnreadConversationRequests).length;
const hideRequestBanner = useSelector(getHideMessageRequestBanner);
if (!conversationRequests.length) {
if (!conversationRequestsUnread || hideRequestBanner) {
return null;
}
const triggerId = 'msg-req-banner';
const handleOnContextMenu = (e: any) => {
contextMenu.show({
id: triggerId,
event: e,
});
};
const openRequests = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.button === 0) {
handleOnClick();
}
};
return (
<StyledMessageRequestBanner onClick={handleOnClick}>
<CirclularIcon iconType="messageRequest" iconSize="medium" />
<StyledMessageRequestBannerHeader>
{window.i18n('messageRequests')}
</StyledMessageRequestBannerHeader>
<StyledUnreadCounter>
<div>{conversationRequests.length || 0}</div>
</StyledUnreadCounter>
</StyledMessageRequestBanner>
<>
<StyledMessageRequestBanner
onContextMenu={handleOnContextMenu}
onClick={openRequests}
onMouseUp={e => {
e.stopPropagation();
e.preventDefault();
}}
>
<CirclularIcon iconType="messageRequest" iconSize="medium" />
<StyledMessageRequestBannerHeader>
{window.i18n('messageRequests')}
</StyledMessageRequestBannerHeader>
<StyledUnreadCounter>
<div>{conversationRequestsUnread || 0}</div>
</StyledUnreadCounter>
</StyledMessageRequestBanner>
<Portal>
<MemoMessageRequestBannerContextMenu triggerId={triggerId} />
</Portal>
</>
);
};
const Portal = ({ children }: { children: any }) => {
return createPortal(children, document.querySelector('.inbox.index') as Element);
};

@ -6,7 +6,6 @@ import { MessageBody } from '../../conversation/message/message-content/MessageB
import { OutgoingMessageStatus } from '../../conversation/message/message-content/OutgoingMessageStatus';
import { TypingAnimation } from '../../conversation/TypingAnimation';
import { ContextConversationId } from './ConversationListItem';
import { MessageRequestButtons } from './MessageRequest';
import { useSelector } from 'react-redux';
import { isSearching } from '../../../state/selectors/search';
@ -57,7 +56,6 @@ export const MessageItem = (props: { isMessageRequest: boolean }) => {
<MessageBody text={text} disableJumbomoji={true} disableLinks={true} isGroup={isGroup} />
)}
</div>
<MessageRequestButtons isMessageRequest={props.isMessageRequest} />
{!isSearchingMode && lastMessage && lastMessage.status && !props.isMessageRequest ? (
<OutgoingMessageStatus status={lastMessage.status} />
) : null}

@ -1,64 +0,0 @@
import React, { useContext } from 'react';
import {
approveConversation,
blockConvoById,
} from '../../../interactions/conversationInteractions';
import { forceSyncConfigurationNowIfNeeded } from '../../../session/utils/syncUtils';
import { SessionIconButton } from '../../icon';
import { ContextConversationId } from './ConversationListItem';
const RejectMessageRequestButton = () => {
const conversationId = useContext(ContextConversationId);
/**
* Removes conversation from requests list,
* adds ID to block list, syncs the block with linked devices.
*/
const handleConversationBlock = async () => {
await blockConvoById(conversationId);
await forceSyncConfigurationNowIfNeeded();
};
return (
<SessionIconButton
iconType="exit"
iconSize="large"
onClick={handleConversationBlock}
backgroundColor="var(--color-destructive)"
iconColor="var(--color-foreground-primary)"
iconPadding="var(--margins-xs)"
borderRadius="2px"
margin="0 5px 0 0"
/>
);
};
const ApproveMessageRequestButton = () => {
const conversationId = useContext(ContextConversationId);
return (
<SessionIconButton
iconType="check"
iconSize="large"
onClick={async () => {
await approveConversation(conversationId);
}}
backgroundColor="var(--color-accent)"
iconColor="var(--color-foreground-primary)"
iconPadding="var(--margins-xs)"
borderRadius="2px"
/>
);
};
export const MessageRequestButtons = ({ isMessageRequest }: { isMessageRequest: boolean }) => {
if (!isMessageRequest) {
return null;
}
return (
<>
<RejectMessageRequestButton />
<ApproveMessageRequestButton />
</>
);
};

@ -12,7 +12,8 @@ export const OverlayHeader = ({ subtitle, title }: { title: string; subtitle: st
<div className="exit">
<SessionIconButton
iconSize="small"
iconType="exit"
iconType="chevron"
iconRotation={90}
onClick={() => {
dispatch(setOverlayMode(undefined));
}}

@ -3,61 +3,20 @@ import React from 'react';
import { SpacerLG } from '../../basic/Text';
import { useDispatch, useSelector } from 'react-redux';
import { getConversationRequests } from '../../../state/selectors/conversations';
import {
getConversationRequests,
getSelectedConversation,
} from '../../../state/selectors/conversations';
import { MemoConversationListItemWithDetails } from '../conversation-list-item/ConversationListItem';
import styled from 'styled-components';
import { SessionButton, SessionButtonColor, SessionButtonType } from '../../basic/SessionButton';
import { OverlayHeader } from './OverlayHeader';
import { setOverlayMode } from '../../../state/ducks/section';
import { SectionType, setOverlayMode, showLeftPaneSection } from '../../../state/ducks/section';
import { getConversationController } from '../../../session/conversations';
import { forceSyncConfigurationNowIfNeeded } from '../../../session/utils/syncUtils';
import { BlockedNumberController } from '../../../util';
import { getIsMessageRequestsEnabled } from '../../../state/selectors/userConfig';
import useKey from 'react-use/lib/useKey';
/**
* Blocks all message request conversations and synchronizes across linked devices
* @returns void
*/
async function handleBlockAllRequestsClick(messageRequestSetting: boolean) {
const messageRequestsEnabled =
messageRequestSetting && window?.sessionFeatureFlags?.useMessageRequests;
if (!messageRequestsEnabled) {
return;
}
// block all convo requests. Force sync if there were changes.
window?.log?.info('Blocking all conversations');
const conversations = getConversationController().getConversations();
if (!conversations) {
window?.log?.info('No message requests to block.');
return;
}
const conversationRequests = conversations.filter(
c => c.isPrivate() && c.get('active_at') && c.get('isApproved')
);
let syncRequired = false;
if (!conversationRequests) {
window?.log?.info('No conversation requests to block.');
return;
}
await Promise.all(
conversationRequests.map(async convo => {
await BlockedNumberController.block(convo.id);
syncRequired = true;
})
);
if (syncRequired) {
await forceSyncConfigurationNowIfNeeded();
}
}
import { clearConversationFocus, ReduxConversationType } from '../../../state/ducks/conversations';
import { updateConfirmModal } from '../../../state/ducks/modalDialog';
export const OverlayMessageRequest = () => {
useKey('Escape', closeOverlay);
@ -65,37 +24,106 @@ export const OverlayMessageRequest = () => {
function closeOverlay() {
dispatch(setOverlayMode(undefined));
}
const messageRequestSetting = useSelector(getIsMessageRequestsEnabled);
const title = window.i18n('messageRequests');
const subtitle = window.i18n('requestsSubtitle');
const buttonText = window.i18n('blockAll');
const convoRequestCount = useSelector(getConversationRequests).length;
const messageRequests = useSelector(getConversationRequests);
const selectedConversation = useSelector(getSelectedConversation);
const buttonText = window.i18n('clearAll');
/**
* Blocks all message request conversations and synchronizes across linked devices
* @returns void
*/
function handleClearAllRequestsClick(convoRequests: Array<ReduxConversationType>) {
const { i18n } = window;
const title = i18n('clearAllConfirmationTitle');
const message = i18n('clearAllConfirmationBody');
const onClose = dispatch(updateConfirmModal(null));
dispatch(
updateConfirmModal({
title,
message,
onClose,
onClickOk: async () => {
window?.log?.info('Blocking all conversations');
if (!convoRequests) {
window?.log?.info('No conversation requests to block.');
return;
}
let newConvosBlocked = [];
const convoController = getConversationController();
await Promise.all(
(newConvosBlocked = convoRequests.filter(async convo => {
const { id } = convo;
const convoModel = convoController.get(id);
if (!convoModel.isBlocked()) {
await BlockedNumberController.block(id);
await convoModel.commit();
}
await convoModel.setIsApproved(false);
// if we're looking at the convo to decline, close the convo
if (selectedConversation?.id === id) {
clearConversationFocus();
}
return true;
}))
);
if (newConvosBlocked) {
await forceSyncConfigurationNowIfNeeded();
}
// if no more requests, return to placeholder screen
if (convoRequestCount === newConvosBlocked.length) {
dispatch(setOverlayMode(undefined));
dispatch(showLeftPaneSection(SectionType.Message));
clearConversationFocus();
}
},
})
);
}
return (
<div className="module-left-pane-overlay">
<OverlayHeader title={title} subtitle={subtitle} />
<SpacerLG />
<MessageRequestList />
<SpacerLG />
<SessionButton
buttonColor={SessionButtonColor.Green}
buttonType={SessionButtonType.BrandOutline}
text={buttonText}
onClick={() => {
void handleBlockAllRequestsClick(messageRequestSetting);
}}
/>
{convoRequestCount ? (
<>
<MessageRequestList />
<SpacerLG />
<SessionButton
buttonColor={SessionButtonColor.Danger}
buttonType={SessionButtonType.BrandOutline}
text={buttonText}
onClick={() => {
handleClearAllRequestsClick(messageRequests);
}}
/>
</>
) : (
<>
<SpacerLG />
<MessageRequestListPlaceholder>
{window.i18n('noMessageRequestsPending')}
</MessageRequestListPlaceholder>
</>
)}
</div>
);
};
const MessageRequestListPlaceholder = styled.div`
color: var(--color-text);
margin-bottom: auto;
`;
const MessageRequestListContainer = styled.div`
width: 100%;
overflow-y: auto;
border: var(--border-session);
margin-bottom: auto;
`;
/**

@ -1,12 +1,14 @@
import React from 'react';
import { animation, Menu } from 'react-contexify';
import {
AcceptMenuItem,
AddModeratorsMenuItem,
BanMenuItem,
BlockMenuItem,
ChangeNicknameMenuItem,
ClearNicknameMenuItem,
CopyMenuItem,
DeclineMenuItem,
DeleteContactMenuItem,
DeleteMessagesMenuItem,
DisappearingMessageMenuItem,
@ -40,6 +42,8 @@ export const ConversationHeaderMenu = (props: PropsConversationHeaderMenu) => {
return (
<ContextConversationId.Provider value={selectedConversation}>
<Menu id={triggerId} animation={animation.fade}>
<AcceptMenuItem />
<DeclineMenuItem />
<DisappearingMessageMenuItem />
<NotificationForConvoMenuItem />
<PinConversationMenuItem />

@ -3,11 +3,13 @@ import { animation, Menu } from 'react-contexify';
import _ from 'lodash';
import {
AcceptMenuItem,
BanMenuItem,
BlockMenuItem,
ChangeNicknameMenuItem,
ClearNicknameMenuItem,
CopyMenuItem,
DeclineMenuItem,
DeleteContactMenuItem,
DeleteMessagesMenuItem,
InviteContactMenuItem,
@ -28,6 +30,8 @@ const ConversationListItemContextMenu = (props: PropsContextConversationItem) =>
return (
<Menu id={triggerId} animation={animation.fade}>
<AcceptMenuItem />
<DeclineMenuItem />
<NotificationForConvoMenuItem />
<PinConversationMenuItem />
<BlockMenuItem />

@ -12,13 +12,16 @@ import {
useIsMe,
useIsPrivate,
useIsPublic,
useIsRequest,
useNotificationSetting,
useWeAreAdmin,
} from '../../hooks/useParamSelector';
import {
approveConvoAndSendResponse,
blockConvoById,
clearNickNameByConvoId,
copyPublicKeyByConvoId,
declineConversationWithConfirm,
deleteAllMessagesByConvoIdWithConfirmation,
markAllReadByConvoId,
setDisappearingMessagesByConvoId,
@ -44,6 +47,7 @@ import {
updateUserDetailsModal,
} from '../../state/ducks/modalDialog';
import { SectionType } from '../../state/ducks/section';
import { hideMessageRequestBanner } from '../../state/ducks/userConfig';
import { getNumberOfPinnedConversations } from '../../state/selectors/conversations';
import { getFocusedSection } from '../../state/selectors/section';
import { getTimerOptions } from '../../state/selectors/timerOptions';
@ -57,29 +61,36 @@ function showTimerOptions(
isPublic: boolean,
isKickedFromGroup: boolean,
left: boolean,
isBlocked: boolean
isBlocked: boolean,
isRequest: boolean
): boolean {
return !isPublic && !left && !isKickedFromGroup && !isBlocked;
return !isPublic && !left && !isKickedFromGroup && !isBlocked && !isRequest;
}
function showNotificationConvo(
isKickedFromGroup: boolean,
left: boolean,
isBlocked: boolean
isBlocked: boolean,
isRequest: boolean
): boolean {
return !left && !isKickedFromGroup && !isBlocked;
return !left && !isKickedFromGroup && !isBlocked && !isRequest;
}
function showBlock(isMe: boolean, isPrivate: boolean): boolean {
return !isMe && isPrivate;
function showBlock(isMe: boolean, isPrivate: boolean, isRequest: boolean): boolean {
return !isMe && isPrivate && !isRequest;
}
function showClearNickname(isMe: boolean, hasNickname: boolean, isPrivate: boolean): boolean {
return !isMe && hasNickname && isPrivate;
function showClearNickname(
isMe: boolean,
hasNickname: boolean,
isPrivate: boolean,
isRequest: boolean
): boolean {
return !isMe && hasNickname && isPrivate && isRequest;
}
function showChangeNickname(isMe: boolean, isPrivate: boolean) {
return !isMe && isPrivate;
function showChangeNickname(isMe: boolean, isPrivate: boolean, isRequest: boolean) {
return !isMe && isPrivate && !isRequest;
}
// we want to show the copyId for open groups and private chats only
@ -91,10 +102,11 @@ function showDeleteContact(
isGroup: boolean,
isPublic: boolean,
isGroupLeft: boolean,
isKickedFromGroup: boolean
isKickedFromGroup: boolean,
isRequest: boolean
): boolean {
// you need to have left a closed group first to be able to delete it completely.
return !isGroup || (isGroup && (isGroupLeft || isKickedFromGroup || isPublic));
return (!isGroup && !isRequest) || (isGroup && (isGroupLeft || isKickedFromGroup || isPublic));
}
const showUnbanUser = (weAreAdmin: boolean, isPublic: boolean, isKickedFromGroup: boolean) => {
@ -166,8 +178,9 @@ export const PinConversationMenuItem = (): JSX.Element | null => {
const conversationId = useContext(ContextConversationId);
const isMessagesSection = useSelector(getFocusedSection) === SectionType.Message;
const nbOfAlreadyPinnedConvos = useSelector(getNumberOfPinnedConversations);
const isRequest = useIsRequest(conversationId);
if (isMessagesSection) {
if (isMessagesSection && !isRequest) {
const conversation = getConversationController().get(conversationId);
const isPinned = conversation?.isPinned() || false;
@ -196,8 +209,9 @@ export const DeleteContactMenuItem = () => {
const isLeft = useIsLeft(convoId);
const isKickedFromGroup = useIsKickedFromGroup(convoId);
const isPrivate = useIsPrivate(convoId);
const isRequest = useIsRequest(convoId);
if (showDeleteContact(!isPrivate, isPublic, isLeft, isKickedFromGroup)) {
if (showDeleteContact(!isPrivate, isPublic, isLeft, isKickedFromGroup, isRequest)) {
let menuItemText: string;
if (isPublic) {
menuItemText = window.i18n('leaveGroup');
@ -396,7 +410,14 @@ export const CopyMenuItem = (): JSX.Element | null => {
export const MarkAllReadMenuItem = (): JSX.Element | null => {
const convoId = useContext(ContextConversationId);
return <Item onClick={() => markAllReadByConvoId(convoId)}>{window.i18n('markAllAsRead')}</Item>;
const isRequest = useIsRequest(convoId);
if (!isRequest) {
return (
<Item onClick={() => markAllReadByConvoId(convoId)}>{window.i18n('markAllAsRead')}</Item>
);
} else {
return null;
}
};
export const DisappearingMessageMenuItem = (): JSX.Element | null => {
@ -406,13 +427,15 @@ export const DisappearingMessageMenuItem = (): JSX.Element | null => {
const isLeft = useIsLeft(convoId);
const isKickedFromGroup = useIsKickedFromGroup(convoId);
const timerOptions = useSelector(getTimerOptions).timerOptions;
const isRequest = useIsRequest(convoId);
if (
showTimerOptions(
Boolean(isPublic),
Boolean(isKickedFromGroup),
Boolean(isLeft),
Boolean(isBlocked)
Boolean(isBlocked),
isRequest
)
) {
// const isRtlMode = isRtlBody();
@ -445,9 +468,12 @@ export const NotificationForConvoMenuItem = (): JSX.Element | null => {
const left = useIsLeft(convoId);
const isBlocked = useIsBlocked(convoId);
const isPrivate = useIsPrivate(convoId);
const isRequest = useIsRequest(convoId);
const currentNotificationSetting = useNotificationSetting(convoId);
if (showNotificationConvo(Boolean(isKickedFromGroup), Boolean(left), Boolean(isBlocked))) {
if (
showNotificationConvo(Boolean(isKickedFromGroup), Boolean(left), Boolean(isBlocked), isRequest)
) {
// const isRtlMode = isRtlBody();'
// exclude mentions_only settings for private chats as this does not make much sense
@ -500,8 +526,9 @@ export const BlockMenuItem = (): JSX.Element | null => {
const isMe = useIsMe(convoId);
const isBlocked = useIsBlocked(convoId);
const isPrivate = useIsPrivate(convoId);
const isRequest = useIsRequest(convoId);
if (showBlock(Boolean(isMe), Boolean(isPrivate))) {
if (showBlock(Boolean(isMe), Boolean(isPrivate), Boolean(isRequest))) {
const blockTitle = isBlocked ? window.i18n('unblockUser') : window.i18n('blockUser');
const blockHandler = isBlocked
? () => unblockConvoById(convoId)
@ -516,8 +543,9 @@ export const ClearNicknameMenuItem = (): JSX.Element | null => {
const isMe = useIsMe(convoId);
const hasNickname = useHasNickname(convoId);
const isPrivate = useIsPrivate(convoId);
const isRequest = Boolean(useIsRequest(convoId)); // easier to copy paste
if (showClearNickname(Boolean(isMe), Boolean(hasNickname), Boolean(isPrivate))) {
if (showClearNickname(Boolean(isMe), Boolean(hasNickname), Boolean(isPrivate), isRequest)) {
return (
<Item onClick={() => clearNickNameByConvoId(convoId)}>{window.i18n('clearNickname')}</Item>
);
@ -529,9 +557,10 @@ export const ChangeNicknameMenuItem = () => {
const convoId = useContext(ContextConversationId);
const isMe = useIsMe(convoId);
const isPrivate = useIsPrivate(convoId);
const isRequest = useIsRequest(convoId);
const dispatch = useDispatch();
if (showChangeNickname(isMe, isPrivate)) {
if (showChangeNickname(isMe, isPrivate, isRequest)) {
return (
<Item
onClick={() => {
@ -547,6 +576,11 @@ export const ChangeNicknameMenuItem = () => {
export const DeleteMessagesMenuItem = () => {
const convoId = useContext(ContextConversationId);
const isRequest = useIsRequest(convoId);
if (isRequest) {
return null;
}
return (
<Item
@ -558,3 +592,55 @@ export const DeleteMessagesMenuItem = () => {
</Item>
);
};
export const HideBannerMenuItem = (): JSX.Element => {
const dispatch = useDispatch();
return (
<Item
onClick={() => {
dispatch(hideMessageRequestBanner());
}}
>
{window.i18n('hideBanner')}
</Item>
);
};
export const AcceptMenuItem = () => {
const convoId = useContext(ContextConversationId);
const isRequest = useIsRequest(convoId);
const convo = getConversationController().get(convoId);
if (isRequest) {
return (
<Item
onClick={async () => {
await convo.setDidApproveMe(true);
await convo.addOutgoingApprovalMessage(Date.now());
await approveConvoAndSendResponse(convoId, true);
}}
>
{window.i18n('accept')}
</Item>
);
}
return null;
};
export const DeclineMenuItem = () => {
const convoId = useContext(ContextConversationId);
const isRequest = useIsRequest(convoId);
if (isRequest) {
return (
<Item
onClick={() => {
declineConversationWithConfirm(convoId, true);
}}
>
{window.i18n('decline')}
</Item>
);
}
return null;
};

@ -0,0 +1,27 @@
import React from 'react';
import { animation, Menu } from 'react-contexify';
import _ from 'lodash';
import { HideBannerMenuItem } from './Menu';
export type PropsContextConversationItem = {
triggerId: string;
};
const MessageRequestBannerContextMenu = (props: PropsContextConversationItem) => {
const { triggerId } = props;
return (
<Menu id={triggerId} animation={animation.fade}>
<HideBannerMenuItem />
</Menu>
);
};
function propsAreEqual(prev: PropsContextConversationItem, next: PropsContextConversationItem) {
return _.isEqual(prev, next);
}
export const MemoMessageRequestBannerContextMenu = React.memo(
MessageRequestBannerContextMenu,
propsAreEqual
);

@ -27,7 +27,7 @@ export const ZoomingSessionSlider = (props: { onSliderChange?: (value: number) =
/>
<div className="slider-info">
<p>{currentValueFromSettings}%`</p>
<p>{currentValueFromSettings}%</p>
</div>
</div>
</SessionSettingsItemWrapper>

@ -5,8 +5,9 @@ import useUpdate from 'react-use/lib/useUpdate';
import { SettingsKey } from '../../../data/settings-key';
import { CallManager } from '../../../session/utils';
import { sessionPassword, updateConfirmModal } from '../../../state/ducks/modalDialog';
import { SectionType, setOverlayMode, showLeftPaneSection } from '../../../state/ducks/section';
import { toggleMessageRequests } from '../../../state/ducks/userConfig';
import { getIsMessageRequestsEnabled } from '../../../state/selectors/userConfig';
import { getHideMessageRequestBanner } from '../../../state/selectors/userConfig';
import { SessionButtonColor } from '../../basic/SessionButton';
import { PasswordAction } from '../../dialog/SessionPasswordDialog';
@ -57,8 +58,6 @@ export const SettingsCategoryPrivacy = (props: {
const forceUpdate = useUpdate();
const dispatch = useDispatch();
const hasMessageRequestFlag = window.sessionFeatureFlags.useMessageRequests;
if (props.hasPassword !== null) {
return (
<>
@ -112,16 +111,24 @@ export const SettingsCategoryPrivacy = (props: {
description={window.i18n('autoUpdateSettingDescription')}
active={Boolean(window.getSettingValue(SettingsKey.settingsAutoUpdate))}
/>
{hasMessageRequestFlag && (
<SessionToggleWithDescription
onClickToggle={() => {
dispatch(toggleMessageRequests());
}}
title={window.i18n('messageRequests')}
description={window.i18n('messageRequestsDescription')}
active={useSelector(getIsMessageRequestsEnabled)}
/>
)}
<SessionToggleWithDescription
onClickToggle={() => {
dispatch(toggleMessageRequests());
}}
title={window.i18n('hideRequestBanner')}
description={window.i18n('hideRequestBannerDescription')}
active={useSelector(getHideMessageRequestBanner)}
/>
<SessionSettingButtonItem
title={window.i18n('openMessageRequestInbox')}
description={window.i18n('openMessageRequestInboxDescription')}
onClick={() => {
dispatch(showLeftPaneSection(SectionType.Message));
dispatch(setOverlayMode('message-requests'));
}}
buttonColor={SessionButtonColor.Primary}
buttonText={window.i18n('openMessageRequestInbox')}
/>
{!props.hasPassword && (
<SessionSettingButtonItem
title={window.i18n('setAccountPasswordTitle')}

@ -11,7 +11,7 @@ import {
ConversationTypeEnum,
} from '../models/conversation';
import { MessageCollection, MessageModel } from '../models/message';
import { MessageAttributes } from '../models/messageType';
import { MessageAttributes, MessageDirection } from '../models/messageType';
import { HexKeyPair } from '../receiver/keypairs';
import { getConversationController } from '../session/conversations';
import { getSodium } from '../session/crypto';
@ -38,6 +38,7 @@ let _shutdownPromise: any = null;
export type StorageItem = {
id: string;
value: any;
timestamp?: number;
};
export type IdentityKey = {
@ -113,6 +114,7 @@ const channelsToMake = {
_removeMessages,
getUnreadByConversation,
getUnreadCountByConversation,
getMessageCountByType,
removeAllMessagesInConversation,
@ -761,6 +763,18 @@ export async function getUnreadCountByConversation(conversationId: string): Prom
return channels.getUnreadCountByConversation(conversationId);
}
/**
* Gets the count of messages for a direction
* @param conversationId Conversation for messages to retrieve from
* @param type outgoing/incoming
*/
export async function getMessageCountByType(
conversationId: string,
type?: MessageDirection
): Promise<number> {
return channels.getMessageCountByType(conversationId, type);
}
export async function getMessagesByConversation(
conversationId: string,
{ skipTimerInit = false, messageId = null }: { skipTimerInit?: false; messageId: string | null }

@ -1,4 +1,6 @@
import _ from 'lodash';
import { useSelector } from 'react-redux';
import { ConversationModel } from '../models/conversation';
import { PubKey } from '../session/types';
import { UserUtils } from '../session/utils';
import { StateType } from '../state/reducer';
@ -118,6 +120,21 @@ export function useIsPinned(convoId?: string) {
return Boolean(convoProps && convoProps.isPinned);
}
export function useIsApproved(convoId?: string) {
const convoProps = useConversationPropsById(convoId);
return Boolean(convoProps && convoProps.isApproved);
}
export function useIsRequest(convoId: string) {
const convoProps = useConversationPropsById(convoId);
return Boolean(
convoProps &&
ConversationModel.hasValidIncomingRequestValues(
_.pick(convoProps, ['isMe', 'isApproved', 'isPrivate', 'isBlocked'])
)
);
}
export function useConversationPropsById(convoId?: string) {
return useSelector((state: StateType) => {
if (!convoId) {

@ -29,7 +29,11 @@ import {
lastAvatarUploadTimestamp,
removeAllMessagesInConversation,
} from '../data/data';
import { conversationReset, quoteMessage } from '../state/ducks/conversations';
import {
clearConversationFocus,
conversationReset,
quoteMessage,
} from '../state/ducks/conversations';
import { getDecryptedMediaUrl } from '../session/crypto/DecryptedAttachmentsManager';
import { IMAGE_JPEG } from '../types/MIME';
import { FSv2 } from '../session/apis/file_server_api';
@ -121,20 +125,72 @@ export async function unblockConvoById(conversationId: string) {
}
/**
* marks the conversation as approved.
* marks the conversation's approval fields, sends messageRequestResponse, syncs to linked devices
*/
export const approveConversation = async (conversationId: string) => {
const conversationToApprove = getConversationController().get(conversationId);
export const approveConvoAndSendResponse = async (
conversationId: string,
syncToDevices: boolean = true
) => {
const convoToApprove = getConversationController().get(conversationId);
if (!conversationToApprove || conversationToApprove.isApproved()) {
if (!convoToApprove || convoToApprove.isApproved()) {
window?.log?.info('Conversation is already approved.');
return;
}
await conversationToApprove.setIsApproved(true);
await convoToApprove.setIsApproved(true, false);
await convoToApprove.commit();
await convoToApprove.sendMessageRequestResponse(true);
// Conversation was not approved before so a sync is needed
if (syncToDevices) {
await forceSyncConfigurationNowIfNeeded();
}
};
export const declineConversationWithConfirm = (convoId: string, syncToDevices: boolean = true) => {
window?.inboxStore?.dispatch(
updateConfirmModal({
okText: window.i18n('decline'),
cancelText: window.i18n('cancel'),
message: window.i18n('declineRequestMessage'),
onClickOk: async () => {
await declineConversationWithoutConfirm(convoId, syncToDevices);
await blockConvoById(convoId);
await forceSyncConfigurationNowIfNeeded();
clearConversationFocus();
},
onClickCancel: () => {
window?.inboxStore?.dispatch(updateConfirmModal(null));
},
onClickClose: () => {
window?.inboxStore?.dispatch(updateConfirmModal(null));
},
})
);
};
/**
* Sets the approval fields to false for conversation. Sends decline message.
*/
export const declineConversationWithoutConfirm = async (
conversationId: string,
syncToDevices: boolean = true
) => {
const conversationToDecline = getConversationController().get(conversationId);
if (!conversationToDecline || conversationToDecline.isApproved()) {
window?.log?.info('Conversation is already declined.');
return;
}
await conversationToDecline.setIsApproved(false);
// Conversation was not approved before so a sync is needed
await forceSyncConfigurationNowIfNeeded();
if (syncToDevices) {
await forceSyncConfigurationNowIfNeeded();
}
};
export async function showUpdateGroupNameByConvoId(conversationId: string) {
@ -269,6 +325,7 @@ export async function deleteAllMessagesByConvoIdNoConfirmation(conversationId: s
lastMessage: null,
unreadCount: 0,
mentionedUs: false,
isApproved: false,
});
await conversation.commit();
@ -301,6 +358,13 @@ export async function setDisappearingMessagesByConvoId(
) {
const conversation = getConversationController().get(conversationId);
const canSetDisappearing = !conversation.isOutgoingRequest() && !conversation.isIncomingRequest();
if (!canSetDisappearing) {
ToastUtils.pushMustBeApproved();
return;
}
if (!seconds || seconds <= 0) {
await conversation.updateExpireTimer(null);
} else {

@ -9,10 +9,12 @@ import { BlockedNumberController } from '../util';
import { leaveClosedGroup } from '../session/group/closed-group';
import { SignalService } from '../protobuf';
import { MessageModel } from './message';
import { MessageAttributesOptionals } from './messageType';
import { MessageAttributesOptionals, MessageDirection } from './messageType';
import autoBind from 'auto-bind';
import {
getLastMessagesByConversation,
getMessageCountByType,
getMessagesByConversation,
getUnreadByConversation,
getUnreadCountByConversation,
removeMessage as dataRemoveMessage,
@ -59,6 +61,8 @@ import {
getAbsoluteAttachmentPath,
loadAttachmentData,
} from '../types/MessageAttachment';
import { getOurPubKeyStrFromCache } from '../session/utils/User';
import { MessageRequestResponse } from '../session/messages/outgoing/controlMessage/MessageRequestResponse';
export enum ConversationTypeEnum {
GROUP = 'group',
@ -112,6 +116,7 @@ export interface ConversationAttributes {
isTrustedForAttachmentDownload: boolean;
isPinned: boolean;
isApproved: boolean;
didApproveMe: boolean;
}
export interface ConversationAttributesOptionals {
@ -151,6 +156,7 @@ export interface ConversationAttributesOptionals {
isTrustedForAttachmentDownload?: boolean;
isPinned: boolean;
isApproved?: boolean;
didApproveMe?: boolean;
}
/**
@ -180,6 +186,7 @@ export const fillConvoAttributesWithDefaults = (
isTrustedForAttachmentDownload: false, // we don't trust a contact until we say so
isPinned: false,
isApproved: false,
didApproveMe: false,
});
};
@ -235,6 +242,40 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
);
}
/**
* Method to evalute if a convo contains the right values
* @param values Required properties to evaluate if this is a message request
*/
public static hasValidIncomingRequestValues({
isMe,
isApproved,
isBlocked,
isPrivate,
}: {
isMe?: boolean;
isApproved?: boolean;
isBlocked?: boolean;
isPrivate?: boolean;
}): boolean {
return Boolean(!isMe && !isApproved && isPrivate && !isBlocked);
}
public static hasValidOutgoingRequestValues({
isMe,
didApproveMe,
isApproved,
isBlocked,
isPrivate,
}: {
isMe?: boolean;
isApproved?: boolean;
didApproveMe?: boolean;
isBlocked?: boolean;
isPrivate?: boolean;
}): boolean {
return Boolean(!isMe && isApproved && isPrivate && !isBlocked && !didApproveMe);
}
public idForLogging() {
if (this.isPrivate()) {
return this.id;
@ -328,6 +369,7 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
const subscriberCount = this.get('subscriberCount');
const isPinned = this.isPinned();
const isApproved = this.isApproved();
const didApproveMe = this.didApproveMe();
const hasNickname = !!this.getNickname();
const isKickedFromGroup = !!this.get('isKickedFromGroup');
const left = !!this.get('left');
@ -404,6 +446,9 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
if (isPinned) {
toRet.isPinned = isPinned;
}
if (didApproveMe) {
toRet.didApproveMe = didApproveMe;
}
if (isApproved) {
toRet.isApproved = isApproved;
}
@ -615,11 +660,21 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
lokiProfile: UserUtils.getOurProfile(),
};
const updateApprovalNeeded =
!this.isApproved() && (this.isPrivate() || this.isMediumGroup() || this.isClosedGroup());
if (updateApprovalNeeded) {
const shouldApprove = !this.isApproved() && this.isPrivate();
const incomingMessageCount = await getMessageCountByType(this.id, MessageDirection.incoming);
const hasIncomingMessages = incomingMessageCount > 0;
if (shouldApprove) {
await this.setIsApproved(true);
void forceSyncConfigurationNowIfNeeded();
if (hasIncomingMessages) {
// have to manually add approval for local client here as DB conditional approval check in config msg handling will prevent this from running
await this.addOutgoingApprovalMessage(Date.now());
if (!this.didApproveMe()) {
await this.setDidApproveMe(true);
}
// should only send once
await this.sendMessageRequestResponse(true);
void forceSyncConfigurationNowIfNeeded();
}
}
if (this.isOpenGroupV2()) {
@ -686,6 +741,87 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
}
}
/**
* Does this conversation contain the properties to be considered a message request
*/
public isIncomingRequest(): boolean {
return ConversationModel.hasValidIncomingRequestValues({
isMe: this.isMe(),
isApproved: this.isApproved(),
isBlocked: this.isBlocked(),
isPrivate: this.isPrivate(),
});
}
/**
* Is this conversation an outgoing message request
*/
public isOutgoingRequest(): boolean {
return ConversationModel.hasValidOutgoingRequestValues({
isMe: this.isMe(),
isApproved: this.isApproved(),
didApproveMe: this.didApproveMe(),
isBlocked: this.isBlocked(),
isPrivate: this.isPrivate(),
});
}
/**
* When you have accepted another users message request
* @param timestamp for determining the order for this message to appear like a regular message
*/
public async addOutgoingApprovalMessage(timestamp: number) {
await this.addSingleOutgoingMessage({
sent_at: timestamp,
messageRequestResponse: {
isApproved: 1,
},
unread: 1, // 1 means unread
expireTimer: 0,
});
this.updateLastMessage();
}
/**
* When the other user has accepted your message request
* @param timestamp For determining message order in conversation
* @param source For determining the conversation name used in the message.
*/
public async addIncomingApprovalMessage(timestamp: number, source: string) {
await this.addSingleIncomingMessage({
sent_at: timestamp, // TODO: maybe add timestamp to messageRequestResponse? confirm it doesn't exist first
source,
messageRequestResponse: {
isApproved: 1,
},
unread: 1, // 1 means unread
expireTimer: 0,
});
this.updateLastMessage();
}
public async sendMessageRequestResponse(isApproved: boolean) {
if (!this.isPrivate()) {
return;
}
const publicKey = getOurPubKeyStrFromCache();
const timestamp = Date.now();
const messageRequestResponseParams = {
timestamp,
publicKey,
isApproved,
};
const messageRequestResponse = new MessageRequestResponse(messageRequestResponseParams);
const pubkeyForSending = new PubKey(this.id);
await getMessageQueue()
.sendToPubKey(pubkeyForSending, messageRequestResponse)
.catch(window?.log?.error);
}
public async sendMessage(msg: SendMessageType) {
const { attachments, body, groupInvitation, preview, quote } = msg;
this.clearTypingTimers();
@ -897,6 +1033,11 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
public async addSingleIncomingMessage(
messageAttributes: Omit<MessageAttributesOptionals, 'conversationId' | 'type' | 'direction'>
) {
// if there's a message by the other user, they've replied to us which we consider an accepted convo
if (!this.didApproveMe() && this.isPrivate()) {
await this.setDidApproveMe(true);
}
return this.addSingleMessage({
...messageAttributes,
conversationId: this.id,
@ -1011,7 +1152,8 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
`Sending ${read.length} read receipts?`,
window.storage.get(SettingsKey.settingsReadReceipt) || false
);
if (window.storage.get(SettingsKey.settingsReadReceipt)) {
const dontSendReceipt = this.isBlocked() || this.isIncomingRequest();
if (window.storage.get(SettingsKey.settingsReadReceipt) && !dontSendReceipt) {
const timestamps = _.map(read, 'timestamp').filter(t => !!t) as Array<number>;
const receiptMessage = new ReadReceiptMessage({
timestamp: Date.now(),
@ -1108,14 +1250,29 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
}
}
public async setIsApproved(value: boolean) {
public async setIsApproved(value: boolean, shouldCommit: boolean = true) {
if (value !== this.isApproved()) {
window?.log?.info(`Setting ${this.attributes.profileName} isApproved to:: ${value}`);
this.set({
isApproved: value,
});
await this.commit();
if (shouldCommit) {
await this.commit();
}
}
}
public async setDidApproveMe(value: boolean, shouldCommit: boolean = true) {
if (value !== this.didApproveMe()) {
window?.log?.info(`Setting ${this.attributes.profileName} didApproveMe to:: ${value}`);
this.set({
didApproveMe: value,
});
if (shouldCommit) {
await this.commit();
}
}
}
@ -1199,6 +1356,10 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
return Boolean(this.get('isPinned'));
}
public didApproveMe() {
return Boolean(this.get('didApproveMe'));
}
public isApproved() {
return Boolean(this.get('isApproved'));
}
@ -1307,6 +1468,33 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
}
const conversationId = this.id;
let friendRequestText;
if (!this.isApproved()) {
window?.log?.info('notification cancelled for unapproved convo', this.idForLogging());
const hadNoRequestsPrior =
getConversationController()
.getConversations()
.filter(conversation => {
return (
!conversation.isApproved() &&
!conversation.isBlocked() &&
conversation.isPrivate() &&
!conversation.isMe()
);
}).length === 1;
const isFirstMessageOfConvo =
(await getMessagesByConversation(this.id, { messageId: null })).length === 1;
if (hadNoRequestsPrior && isFirstMessageOfConvo) {
friendRequestText = window.i18n('youHaveANewFriendRequest');
} else {
window?.log?.info(
'notification cancelled for as pending requests already exist',
this.idForLogging()
);
return;
}
}
// make sure the notifications are not muted for this convo (and not the source convo)
const convNotif = this.get('triggerNotificationsFor');
if (convNotif === 'disabled') {
@ -1355,10 +1543,10 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
conversationId,
iconUrl,
isExpiringMessage,
message: message.getNotificationText(),
message: friendRequestText ? friendRequestText : message.getNotificationText(),
messageId,
messageSentAt,
title: convo.getTitle(),
title: friendRequestText ? '' : convo.getTitle(),
});
}
@ -1434,16 +1622,6 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
private async addSingleMessage(messageAttributes: MessageAttributesOptionals) {
const model = new MessageModel(messageAttributes);
const isMe = messageAttributes.source === UserUtils.getOurPubKeyStrFromCache();
if (
isMe &&
window.sessionFeatureFlags.useMessageRequests &&
window.inboxStore?.getState().userConfig.messageRequests
) {
await this.setIsApproved(true);
}
// no need to trigger a UI update now, we trigger a messagesAdded just below
const messageId = await model.commit(false);
model.set({ id: messageId });
@ -1490,15 +1668,6 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
) {
return false;
}
const msgRequestsEnabled =
window.sessionFeatureFlags.useMessageRequests &&
window.inboxStore?.getState().userConfig.messageRequests;
// if msg requests are unused, we have to send typing (this is already a private active unblocked convo)
if (!msgRequestsEnabled) {
return true;
}
// with message requests in use, we just need to check for isApproved
return Boolean(this.get('isApproved'));
}
@ -1567,6 +1736,10 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
throw new Error('Need to provide either recipientId');
}
if (!this.isApproved()) {
return;
}
if (this.isMe()) {
// note to self
return;

@ -16,6 +16,7 @@ import {
MessageGroupUpdate,
MessageModelType,
PropsForDataExtractionNotification,
PropsForMessageRequestResponse,
} from './messageType';
import autoBind from 'auto-bind';
@ -106,6 +107,7 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
const propsForGroupInvitation = this.getPropsForGroupInvitation();
const propsForGroupUpdateMessage = this.getPropsForGroupUpdateMessage();
const propsForTimerNotification = this.getPropsForTimerNotification();
const propsForMessageRequestResponse = this.getPropsForMessageRequestResponse();
const callNotificationType = this.get('callNotificationType');
const messageProps: MessageModelPropsWithoutConvoProps = {
propsForMessage: this.getPropsForMessage(),
@ -113,6 +115,9 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
if (propsForDataExtractionNotification) {
messageProps.propsForDataExtractionNotification = propsForDataExtractionNotification;
}
if (propsForMessageRequestResponse) {
messageProps.propsForMessageRequestResponse = propsForMessageRequestResponse;
}
if (propsForGroupInvitation) {
messageProps.propsForGroupInvitation = propsForGroupInvitation;
}
@ -176,6 +181,10 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
return !!this.get('groupInvitation');
}
public isMessageRequestResponse() {
return !!this.get('messageRequestResponse');
}
public isDataExtractionNotification() {
return !!this.get('dataExtractionNotification');
}
@ -300,6 +309,30 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
};
}
public getPropsForMessageRequestResponse(): PropsForMessageRequestResponse | null {
if (!this.isMessageRequestResponse()) {
return null;
}
const messageRequestResponse = this.get('messageRequestResponse');
if (!messageRequestResponse) {
window.log.warn('messageRequestResponse should not happen');
return null;
}
const contact = this.findAndFormatContact(messageRequestResponse.source);
return {
...messageRequestResponse,
name: contact.profileName || contact.name || messageRequestResponse.source,
messageId: this.id,
receivedAt: this.get('received_at'),
isUnread: this.isUnread(),
conversationId: this.get('conversationId'),
source: this.get('source'),
};
}
public findContact(pubkey: string) {
return getConversationController().get(pubkey);
}

@ -90,6 +90,11 @@ export interface MessageAttributes {
*/
dataExtractionNotification?: DataExtractionNotificationMsg;
/**
* For displaying a message to notifying when a request has been accepted.
*/
messageRequestResponse?: MessageRequestResponseMsg;
/**
* This field is used for unsending messages and used in sending unsend message requests.
*/
@ -109,6 +114,17 @@ export interface DataExtractionNotificationMsg {
referencedAttachmentTimestamp: number; // the attachment timestamp he screenshot
}
export interface MessageRequestResponseMsg {
source: string;
isApproved: boolean;
}
export enum MessageDirection {
outgoing = 'outgoing',
incoming = 'incoming',
any = '%',
}
export type PropsForDataExtractionNotification = DataExtractionNotificationMsg & {
name: string;
messageId: string;
@ -116,6 +132,16 @@ export type PropsForDataExtractionNotification = DataExtractionNotificationMsg &
isUnread: boolean;
};
export type PropsForMessageRequestResponse = MessageRequestResponseMsg & {
conversationId?: string;
name?: string;
messageId: string;
receivedAt?: number;
isUnread: boolean;
isApproved?: boolean;
source?: string;
};
export type MessageGroupUpdate = {
left?: Array<string>;
joined?: Array<string>;
@ -156,6 +182,10 @@ export interface MessageAttributesOptionals {
source: string;
referencedAttachmentTimestamp: number;
};
messageRequestResponse?: {
/** 1 means approved, 0 means unapproved. */
isApproved?: number;
};
unread?: number;
group?: any;
timestamp?: number;

@ -111,6 +111,16 @@ export async function handleClosedGroupControlMessage(
return;
}
if (type === Type.NEW) {
if (
!getConversationController()
.get(envelope.senderIdentity)
.isApproved()
) {
window?.log?.info(
'Received new closed group message from an unapproved sender -- dropping message.'
);
return;
}
await handleNewClosedGroup(envelope, groupUpdate);
return;
}

@ -16,6 +16,7 @@ import { removeFromCache } from './cache';
import { handleNewClosedGroup } from './closedGroups';
import { updateProfileOneAtATime } from './dataMessage';
import { EnvelopePlus } from './types';
import { ConversationInteraction } from '../interactions';
async function handleOurProfileUpdate(
sentAt: number | Long,
@ -54,21 +55,21 @@ async function handleGroupsAndContactsFromConfigMessage(
envelope: EnvelopePlus,
configMessage: SignalService.ConfigurationMessage
) {
const didWeHandleAConfigurationMessageAlready =
(await getItemById(hasSyncedInitialConfigurationItem))?.value || false;
if (didWeHandleAConfigurationMessageAlready) {
window?.log?.info(
'Dropping configuration groups change as we already handled one... Only handling contacts '
);
if (configMessage.contacts?.length) {
await Promise.all(configMessage.contacts.map(async c => handleContactReceived(c, envelope)));
}
const lastConfigUpdate = await getItemById(hasSyncedInitialConfigurationItem);
const lastConfigTimestamp = lastConfigUpdate?.timestamp;
const isNewerConfig =
!lastConfigTimestamp ||
(lastConfigTimestamp && lastConfigTimestamp < _.toNumber(envelope.timestamp));
if (!isNewerConfig) {
window?.log?.info('Received outdated configuration message... Dropping message.');
return;
}
await createOrUpdateItem({
id: 'hasSyncedInitialConfigurationItem',
value: true,
timestamp: _.toNumber(envelope.timestamp),
});
const numberClosedGroup = configMessage.closedGroups?.length || 0;
@ -95,12 +96,22 @@ async function handleGroupsAndContactsFromConfigMessage(
})
);
const numberOpenGroup = configMessage.openGroups?.length || 0;
handleOpenGroupsFromConfig(configMessage.openGroups);
if (configMessage.contacts?.length) {
await Promise.all(configMessage.contacts.map(async c => handleContactFromConfig(c, envelope)));
}
}
// Trigger a join for all open groups we are not already in.
// Currently, if you left an open group but kept the conversation, you won't rejoin it here.
/**
* Trigger a join for all open groups we are not already in.
* Currently, if you left an open group but kept the conversation, you won't rejoin it here.
* @param openGroups string array of open group urls
*/
const handleOpenGroupsFromConfig = (openGroups: Array<string>) => {
const numberOpenGroup = openGroups?.length || 0;
for (let i = 0; i < numberOpenGroup; i++) {
const currentOpenGroupUrl = configMessage.openGroups[i];
const currentOpenGroupUrl = openGroups[i];
const parsedRoom = parseOpenGroupV2(currentOpenGroupUrl);
if (!parsedRoom) {
continue;
@ -113,12 +124,13 @@ async function handleGroupsAndContactsFromConfigMessage(
void joinOpenGroupV2WithUIEvents(currentOpenGroupUrl, false, true);
}
}
if (configMessage.contacts?.length) {
await Promise.all(configMessage.contacts.map(async c => handleContactReceived(c, envelope)));
}
}
};
const handleContactReceived = async (
/**
* Handles adding of a contact and setting approval/block status
* @param contactReceived Contact to sync
*/
const handleContactFromConfig = async (
contactReceived: SignalService.ConfigurationMessage.IContact,
envelope: EnvelopePlus
) => {
@ -140,19 +152,28 @@ const handleContactReceived = async (
contactConvo.set('active_at', _.toNumber(envelope.timestamp));
}
if (
window.sessionFeatureFlags.useMessageRequests &&
window.inboxStore?.getState().userConfig.messageRequests
) {
if (contactReceived.isApproved) {
// checking for existence of field on protobuf
if (contactReceived.isApproved === true) {
if (!contactConvo.isApproved()) {
await contactConvo.setIsApproved(Boolean(contactReceived.isApproved));
await contactConvo.addOutgoingApprovalMessage(_.toNumber(envelope.timestamp));
}
if (contactReceived.isBlocked) {
await BlockedNumberController.block(contactConvo.id);
} else {
await BlockedNumberController.unblock(contactConvo.id);
if (contactReceived.didApproveMe === true) {
// checking for existence of field on message
await contactConvo.setDidApproveMe(Boolean(contactReceived.didApproveMe));
}
}
// only set for explicit true/false values incase outdated sender doesn't have the fields
if (contactReceived.isBlocked === true) {
if (contactConvo.isIncomingRequest()) {
// handling case where restored device's declined message requests were getting restored
await ConversationInteraction.deleteAllMessagesByConvoIdNoConfirmation(contactConvo.id);
}
await BlockedNumberController.block(contactConvo.id);
} else if (contactReceived.isBlocked === false) {
await BlockedNumberController.unblock(contactConvo.id);
}
void updateProfileOneAtATime(contactConvo, profile, contactReceived.profileKey);

@ -3,7 +3,7 @@ import { handleSwarmDataMessage } from './dataMessage';
import { removeFromCache, updateCache } from './cache';
import { SignalService } from '../protobuf';
import * as Lodash from 'lodash';
import _, * as Lodash from 'lodash';
import { PubKey } from '../session/types';
import { BlockedNumberController } from '../util/blockedNumberController';
@ -13,17 +13,12 @@ import { concatUInt8Array, getSodium } from '../session/crypto';
import { getConversationController } from '../session/conversations';
import { ECKeyPair } from './keypairs';
import { handleConfigurationMessage } from './configMessage';
import { ConversationTypeEnum } from '../models/conversation';
import { removeMessagePadding } from '../session/crypto/BufferPadding';
import { perfEnd, perfStart } from '../session/utils/Performance';
import { getAllCachedECKeyPair } from './closedGroups';
import { getMessageBySenderAndTimestamp } from '../data/data';
import { handleCallMessage } from './callMessage';
import {
deleteMessagesFromSwarmAndCompletelyLocally,
deleteMessagesFromSwarmAndMarkAsDeletedLocally,
} from '../interactions/conversations/unsendingInteractions';
import { SettingsKey } from '../data/settings-key';
import { ConversationTypeEnum } from '../models/conversation';
export async function handleSwarmContentMessage(envelope: EnvelopePlus, messageHash: string) {
try {
@ -307,7 +302,7 @@ function shouldDropBlockedUserMessage(content: SignalService.Content): boolean {
// first check that dataMessage is the only field set in the Content
let msgWithoutDataMessage = Lodash.pickBy(
content,
(_, key) => key !== 'dataMessage' && key !== 'toJSON'
(_value, key) => key !== 'dataMessage' && key !== 'toJSON'
);
msgWithoutDataMessage = Lodash.pickBy(msgWithoutDataMessage, Lodash.identity);
@ -326,6 +321,7 @@ function shouldDropBlockedUserMessage(content: SignalService.Content): boolean {
return !isControlDataMessageOnly;
}
// tslint:disable-next-line: cyclomatic-complexity
export async function innerHandleSwarmContentMessage(
envelope: EnvelopePlus,
plaintext: ArrayBuffer,
@ -380,6 +376,7 @@ export async function innerHandleSwarmContentMessage(
content.dataMessage.profileKey = null;
}
perfStart(`handleSwarmDataMessage-${envelope.id}`);
await handleSwarmDataMessage(
envelope,
content.dataMessage as SignalService.DataMessage,
@ -431,6 +428,12 @@ export async function innerHandleSwarmContentMessage(
if (content.callMessage && window.sessionFeatureFlags?.useCallMessage) {
await handleCallMessage(envelope, content.callMessage as SignalService.CallMessage);
}
if (content.messageRequestResponse) {
await handleMessageRequestResponse(
envelope,
content.messageRequestResponse as SignalService.MessageRequestResponse
);
}
} catch (e) {
window?.log?.warn(e);
}
@ -534,7 +537,6 @@ async function handleUnsendMessage(envelope: EnvelopePlus, unsendMessage: Signal
return;
}
if (!unsendMessage) {
//#region early exit conditions
window?.log?.error('handleUnsendMessage: Invalid parameters -- dropping message.');
await removeFromCache(envelope);
@ -546,40 +548,41 @@ async function handleUnsendMessage(envelope: EnvelopePlus, unsendMessage: Signal
return;
}
}
const messageToDelete = await getMessageBySenderAndTimestamp({
source: messageAuthor,
timestamp: Lodash.toNumber(timestamp),
});
const messageHash = messageToDelete?.get('messageHash');
//#endregion
//#region executing deletion
if (messageHash && messageToDelete) {
window.log.info('handleUnsendMessage: got a request to delete ', messageHash);
const conversation = getConversationController().get(messageToDelete.get('conversationId'));
if (!conversation) {
await removeFromCache(envelope);
/**
* Sets approval fields for conversation depending on response's values. If request is approving, pushes notification and
*/
async function handleMessageRequestResponse(
envelope: EnvelopePlus,
messageRequestResponse: SignalService.MessageRequestResponse
) {
const { isApproved } = messageRequestResponse;
if (!messageRequestResponse) {
window?.log?.error('handleMessageRequestResponse: Invalid parameters -- dropping message.');
await removeFromCache(envelope);
return;
}
return;
}
if (messageToDelete.getSource() === UserUtils.getOurPubKeyStrFromCache()) {
// a message we sent is completely removed when we get a unsend request
void deleteMessagesFromSwarmAndCompletelyLocally(conversation, [messageToDelete]);
} else {
void deleteMessagesFromSwarmAndMarkAsDeletedLocally(conversation, [messageToDelete]);
}
} else {
window.log.info(
'handleUnsendMessage: got a request to delete an unknown messageHash:',
messageHash,
' and found messageToDelete:',
messageToDelete?.id
const convoId = envelope.source;
const conversationToApprove = getConversationController().get(convoId);
if (!conversationToApprove || conversationToApprove.didApproveMe() === isApproved) {
window?.log?.info(
'Conversation already contains the correct value for the didApproveMe field.'
);
return;
}
await conversationToApprove.setDidApproveMe(isApproved);
if (isApproved === true) {
// Conversation was not approved before so a sync is needed
await conversationToApprove.addIncomingApprovalMessage(
_.toNumber(envelope.timestamp),
envelope.source
);
}
await removeFromCache(envelope);
//#endregion
await removeFromCache(envelope);
}
/**

@ -6,11 +6,13 @@ import _ from 'lodash';
import { getConversationController } from '../session/conversations';
import { ConversationModel, ConversationTypeEnum } from '../models/conversation';
import { MessageModel } from '../models/message';
import { getMessageById, getMessagesBySentAt } from '../../ts/data/data';
import { getMessageById, getMessageCountByType, getMessagesBySentAt } from '../../ts/data/data';
import { updateProfileOneAtATime } from './dataMessage';
import { SignalService } from '../protobuf';
import { UserUtils } from '../session/utils';
import { showMessageRequestBanner } from '../state/ducks/userConfig';
import { MessageDirection } from '../models/messageType';
function contentTypeSupported(type: string): boolean {
const Chrome = window.Signal.Util.GoogleChrome;
@ -248,13 +250,38 @@ async function handleRegularMessage(
if (type === 'incoming') {
updateReadStatus(message, conversation);
if (conversation.isPrivate()) {
const incomingMessageCount = await getMessageCountByType(
conversation.id,
MessageDirection.incoming
);
const isFirstRequestMessage = incomingMessageCount < 2;
if (
conversation.isIncomingRequest() &&
isFirstRequestMessage &&
window.inboxStore?.getState().userConfig.hideMessageRequests
) {
window.inboxStore?.dispatch(showMessageRequestBanner());
}
// For edge case when messaging a client that's unable to explicitly send request approvals
if (conversation.isOutgoingRequest()) {
// Conversation was not approved before so a sync is needed
await conversation.addIncomingApprovalMessage(
_.toNumber(message.get('sent_at')) - 1,
source
);
}
// should only occur after isOutgoing request as it relies on didApproveMe being false.
await conversation.setDidApproveMe(true);
// edge case end
}
}
if (type === 'outgoing') {
await handleSyncedReceipts(message, conversation);
if (window.sessionFeatureFlags.useMessageRequests) {
// assumes sync receipts are always from linked device outgoings
if (conversation.isPrivate()) {
await conversation.setIsApproved(true);
}
}

@ -6,7 +6,7 @@ import crypto from 'crypto';
// libsodium-wrappers requires the `require` call to work
// tslint:disable-next-line: no-require-imports
import libsodiumwrappers from 'libsodium-wrappers';
import libsodiumwrappers from 'libsodium-wrappers-sumo';
import { toHex } from '../utils/String';
import { ECKeyPair } from '../../receiver/keypairs';

@ -95,6 +95,7 @@ export class ConfigurationMessageContact {
public profileKey?: Uint8Array;
public isApproved?: boolean;
public isBlocked?: boolean;
public didApproveMe?: boolean;
public constructor({
publicKey,
@ -103,6 +104,7 @@ export class ConfigurationMessageContact {
profileKey,
isApproved,
isBlocked,
didApproveMe,
}: {
publicKey: string;
displayName: string;
@ -110,6 +112,7 @@ export class ConfigurationMessageContact {
profileKey?: Uint8Array;
isApproved?: boolean;
isBlocked?: boolean;
didApproveMe?: boolean;
}) {
this.publicKey = publicKey;
this.displayName = displayName;
@ -117,6 +120,7 @@ export class ConfigurationMessageContact {
this.profileKey = profileKey;
this.isApproved = isApproved;
this.isBlocked = isBlocked;
this.didApproveMe = didApproveMe;
// will throw if public key is invalid
PubKey.cast(publicKey);
@ -141,6 +145,7 @@ export class ConfigurationMessageContact {
profileKey: this.profileKey,
isApproved: this.isApproved,
isBlocked: this.isBlocked,
didApproveMe: this.didApproveMe,
});
}
}

@ -0,0 +1,31 @@
import { SignalService } from '../../../../protobuf';
import { ContentMessage } from '../ContentMessage';
import { MessageParams } from '../Message';
interface MessageRequestResponseParams extends MessageParams {
isApproved: boolean;
}
export class MessageRequestResponse extends ContentMessage {
private readonly isApproved: boolean;
constructor(params: MessageRequestResponseParams) {
super({
timestamp: params.timestamp,
isApproved: params.isApproved,
} as MessageRequestResponseParams);
this.isApproved = params.isApproved;
}
public contentProto(): SignalService.Content {
return new SignalService.Content({
messageRequestResponse: this.messageRequestResponseProto(),
});
}
public messageRequestResponseProto(): SignalService.MessageRequestResponse {
return new SignalService.MessageRequestResponse({
isApproved: this.isApproved,
});
}
}

@ -196,6 +196,10 @@ export function pushTooManyMembers() {
pushToastError('tooManyMembers', window.i18n('closedGroupMaxSize'));
}
export function pushMessageRequestPending() {
pushToastInfo('messageRequestPending', window.i18n('messageRequestPending'));
}
export function pushUnblockToSend() {
pushToastInfo('unblockToSend', window.i18n('unblockToSend'));
}
@ -263,3 +267,11 @@ export function pushNoAudioInputFound() {
export function pushNoAudioOutputFound() {
pushToastWarning('noAudioInputFound', window.i18n('noAudioOutputFound'));
}
export function pushNoMediaUntilApproved() {
pushToastError('noMediaUntilApproved', window.i18n('noMediaUntilApproved'));
}
export function pushMustBeApproved() {
pushToastError('mustBeApproved', window.i18n('mustBeApproved'));
}

@ -26,6 +26,7 @@ import { hasConversationOutgoingMessage } from '../../../data/data';
import { getCallMediaPermissionsSettings } from '../../../components/settings/SessionSettings';
import { PnServer } from '../../apis/push_notification_api';
import { getNowWithNetworkOffset } from '../../apis/snode_api/SNodeAPI';
import { approveConvoAndSendResponse } from '../../../interactions/conversationInteractions';
// tslint:disable: function-name
@ -504,6 +505,10 @@ export async function USER_callRecipient(recipient: string) {
callNotificationType: 'started-call',
unread: 0,
});
// initiating a call is analgous to sending a message request
await approveConvoAndSendResponse(recipient, true);
// we do it manually as the sendToPubkeyNonDurably rely on having a message saved to the db for MessageSentSuccess
// which is not the case for a pre offer message (the message only exists in memory)
const rawMessage = await MessageUtils.toRawMessage(PubKey.cast(recipient), preOfferMsg);
@ -834,6 +839,10 @@ export async function USER_acceptIncomingCallRequest(fromSender: string) {
unread: 0,
});
await buildAnswerAndSendIt(fromSender);
// consider the conversation completely approved
await callerConvo.setDidApproveMe(true);
await approveConvoAndSendResponse(fromSender, true);
}
export async function rejectCallAlreadyAnotherCall(fromSender: string, forcedUUID: string) {

@ -28,6 +28,7 @@ import { getV2OpenGroupRoom } from '../../data/opengroups';
import { getCompleteUrlFromRoom } from '../apis/open_group_api/utils/OpenGroupUtils';
import { DURATION } from '../constants';
import { UnsendMessage } from '../messages/outgoing/controlMessage/UnsendMessage';
import { MessageRequestResponse } from '../messages/outgoing/controlMessage/MessageRequestResponse';
const ITEM_ID_LAST_SYNC_TIMESTAMP = 'lastSyncedTimestamp';
@ -197,6 +198,7 @@ const getValidContacts = (convos: Array<ConversationModel>) => {
profileKey: !profileKeyForContact?.length ? undefined : profileKeyForContact,
isApproved: c.isApproved(),
isBlocked: c.isBlocked(),
didApproveMe: c.didApproveMe(),
});
} catch (e) {
window?.log.warn('getValidContacts', e);
@ -307,6 +309,7 @@ export type SyncMessageType =
| VisibleMessage
| ExpirationTimerUpdateMessage
| ConfigurationMessage
| MessageRequestResponse
| UnsendMessage;
export const buildSyncMessage = (

@ -56,10 +56,17 @@ const darkBackgroundPrimary = '#474646';
const darkButtonGreen = accentDarkTheme;
const darkModalBackground = '#101011';
const grey67 = '#434343';
const darkMessageRequestBannerBackground = darkConversationItemHasUnread;
const darkMessageRequestBannerIconBackground = grey67;
const darkMessageRequestBannerUnreadBackground = grey67;
const darkMessageRequestBannerIcon = '#adadad';
export const switchHtmlToDarkTheme = () => {
document.documentElement.style.setProperty('--color-accent', darkColorAccent);
document.documentElement.style.setProperty('--color-accent-button', darkColorAccentButton);
document.documentElement.style.setProperty('--color-text', darkColorText);
document.documentElement.style.setProperty('--color-text-menu-highlighted', lightColorText);
document.documentElement.style.setProperty('--color-text-subtle', darkColorTextSubtle);
document.documentElement.style.setProperty('--color-text-accent', darkColorTextAccent);
document.documentElement.style.setProperty('--color-text-opposite', darkColorTextOpposite);
@ -138,6 +145,23 @@ export const switchHtmlToDarkTheme = () => {
document.documentElement.style.setProperty('--color-button-green', darkButtonGreen);
document.documentElement.style.setProperty('--color-modal-background', darkModalBackground);
document.documentElement.style.setProperty('--border-session', darkColorSessionBorder);
document.documentElement.style.setProperty(
'--color-request-banner-background',
darkMessageRequestBannerBackground
);
document.documentElement.style.setProperty(
'--color-request-banner-icon-background',
darkMessageRequestBannerIconBackground
);
document.documentElement.style.setProperty(
'--color-request-banner-unread-background',
darkMessageRequestBannerUnreadBackground
);
document.documentElement.style.setProperty(
'--color-request-banner-icon',
darkMessageRequestBannerIcon
);
};
// LIGHT COLORS
@ -185,10 +209,16 @@ const lightBackgroundPrimary = '#272726';
const lightButtonGreen = '#272726';
const lightModalBackground = '#fcfcfc';
const lightMessageRequestBannerBackground = lightColorQuoteBottomBarBg;
const lightMessageRequestBannerIconBackground = '#585858';
const lightMessageRequestBannerUnreadBackground = lightColorClickableHovered;
const lightMessageRequestBannerIcon = white;
export const switchHtmlToLightTheme = () => {
document.documentElement.style.setProperty('--color-accent', lightColorAccent);
document.documentElement.style.setProperty('--color-accent-button', lightColorAccentButton);
document.documentElement.style.setProperty('--color-text', lightColorText);
document.documentElement.style.setProperty('--color-text-menu-highlighted', lightColorText);
document.documentElement.style.setProperty('--color-text-subtle', lightColorTextSubtle);
document.documentElement.style.setProperty('--color-text-accent', lightColorTextAccent);
document.documentElement.style.setProperty('--color-text-opposite', lightColorTextOpposite);
@ -276,6 +306,22 @@ export const switchHtmlToLightTheme = () => {
document.documentElement.style.setProperty('--color-button-green', lightButtonGreen);
document.documentElement.style.setProperty('--color-modal-background', lightModalBackground);
document.documentElement.style.setProperty('--border-session', lightColorSessionBorder);
document.documentElement.style.setProperty(
'--color-request-banner-background',
lightMessageRequestBannerBackground
);
document.documentElement.style.setProperty(
'--color-request-banner-icon-background',
lightMessageRequestBannerIconBackground
);
document.documentElement.style.setProperty(
'--color-request-banner-unread-background',
lightMessageRequestBannerUnreadBackground
);
document.documentElement.style.setProperty(
'--color-request-banner-icon',
lightMessageRequestBannerIcon
);
};
// default to light theme

@ -15,6 +15,7 @@ import {
MessageDeliveryStatus,
MessageModelType,
PropsForDataExtractionNotification,
PropsForMessageRequestResponse,
} from '../../models/messageType';
import { omit } from 'lodash';
import { ReplyingToMessageProps } from '../../components/conversation/composition/CompositionBox';
@ -36,6 +37,7 @@ export type MessageModelPropsWithoutConvoProps = {
propsForDataExtractionNotification?: PropsForDataExtractionNotification;
propsForGroupUpdateMessage?: PropsForGroupUpdate;
propsForCallNotification?: PropsForCallNotification;
propsForMessageRequestResponse?: PropsForMessageRequestResponse;
};
export type MessageModelPropsWithConvoProps = SortedMessageModelProps & {
@ -262,6 +264,7 @@ export interface ReduxConversationType {
isPinned?: boolean;
isApproved?: boolean;
didApproveMe?: boolean;
}
export interface NotificationForConvoOption {
@ -691,7 +694,12 @@ const conversationsSlice = createSlice({
firstUnreadMessageId: undefined,
};
},
/**
* Closes any existing conversation and returns state to the placeholder screen
*/
resetConversationExternal(state: ConversationsStateType) {
return { ...getEmptyConversationState(), conversationLookup: state.conversationLookup };
},
openConversationExternal(
state: ConversationsStateType,
action: PayloadAction<{
@ -968,6 +976,10 @@ export async function openConversationWithMessages(args: {
);
}
export function clearConversationFocus() {
window.inboxStore?.dispatch(actions.resetConversationExternal());
}
export async function openConversationToSpecificMessage(args: {
conversationKey: string;
messageIdToNavigateTo: string;

@ -7,13 +7,13 @@ import { createSlice } from '@reduxjs/toolkit';
export interface UserConfigState {
audioAutoplay: boolean;
showRecoveryPhrasePrompt: boolean;
messageRequests: boolean;
hideMessageRequests: boolean;
}
export const initialUserConfigState = {
audioAutoplay: false,
showRecoveryPhrasePrompt: true,
messageRequests: false,
hideMessageRequests: false,
};
const userConfigSlice = createSlice({
@ -27,11 +27,23 @@ const userConfigSlice = createSlice({
state.showRecoveryPhrasePrompt = false;
},
toggleMessageRequests: state => {
state.messageRequests = !state.messageRequests;
state.hideMessageRequests = !state.hideMessageRequests;
},
showMessageRequestBanner: state => {
state.hideMessageRequests = false;
},
hideMessageRequestBanner: state => {
state.hideMessageRequests = true;
},
},
});
const { actions, reducer } = userConfigSlice;
export const { toggleAudioAutoplay, disableRecoveryPhrasePrompt, toggleMessageRequests } = actions;
export const {
toggleAudioAutoplay,
disableRecoveryPhrasePrompt,
toggleMessageRequests,
showMessageRequestBanner,
hideMessageRequestBanner,
} = actions;
export const userConfigReducer = reducer;

@ -14,11 +14,10 @@ import {
import { getIntl, getOurNumber } from './user';
import { BlockedNumberController } from '../../util';
import { ConversationTypeEnum } from '../../models/conversation';
import { ConversationModel, ConversationTypeEnum } from '../../models/conversation';
import { LocalizerType } from '../../types/Util';
import { ConversationHeaderTitleProps } from '../../components/conversation/ConversationHeader';
import _ from 'lodash';
import { getIsMessageRequestsEnabled } from './userConfig';
import { ReplyingToMessageProps } from '../../components/conversation/composition/CompositionBox';
import { MessageAttachmentSelectorProps } from '../../components/conversation/message/message-content/MessageAttachment';
import { MessageAuthorSelectorProps } from '../../components/conversation/message/message-content/MessageAuthorText';
@ -174,6 +173,7 @@ export type MessagePropsType =
| 'group-notification'
| 'group-invitation'
| 'data-extraction'
| 'message-request-response'
| 'timer-notification'
| 'regular-message'
| 'unread-indicator'
@ -213,6 +213,17 @@ export const getSortedMessagesTypesOfSelectedConversation = createSelector(
};
}
if (msg.propsForMessageRequestResponse) {
return {
showUnreadIndicator: isFirstUnread,
showDateBreak,
message: {
messageType: 'message-request-response',
props: { ...msg.propsForMessageRequestResponse, messageId: msg.propsForMessage.id },
},
};
}
if (msg.propsForGroupInvitation) {
return {
showUnreadIndicator: isFirstUnread,
@ -321,8 +332,7 @@ export const getConversationComparator = createSelector(getIntl, _getConversatio
// export only because we use it in some of our tests
// tslint:disable-next-line: cyclomatic-complexity
export const _getLeftPaneLists = (
sortedConversations: Array<ReduxConversationType>,
isMessageRequestEnabled?: boolean
sortedConversations: Array<ReduxConversationType>
): {
conversations: Array<ReduxConversationType>;
contacts: Array<ReduxConversationType>;
@ -333,18 +343,24 @@ export const _getLeftPaneLists = (
let unreadCount = 0;
for (const conversation of sortedConversations) {
const excludeUnapproved =
isMessageRequestEnabled && window.sessionFeatureFlags?.useMessageRequests;
if (conversation.activeAt !== undefined && conversation.type === ConversationTypeEnum.PRIVATE) {
if (
conversation.activeAt !== undefined &&
conversation.type === ConversationTypeEnum.PRIVATE &&
conversation.isApproved &&
!conversation.isBlocked
) {
directConversations.push(conversation);
}
if (excludeUnapproved && !conversation.isApproved && !conversation.isBlocked) {
if (!conversation.isApproved) {
// dont increase unread counter, don't push to convo list.
continue;
}
if (conversation.isBlocked) {
continue;
}
if (
unreadCount < 9 &&
conversation.unreadCount &&
@ -417,37 +433,54 @@ export const getSortedConversations = createSelector(
_getSortedConversations
);
/**
*
* @param sortedConversations List of conversations that are valid for both requests and regular conversation inbox
* @returns A list of message request conversations.
*/
const _getConversationRequests = (
sortedConversations: Array<ReduxConversationType>,
isMessageRequestEnabled?: boolean
sortedConversations: Array<ReduxConversationType>
): Array<ReduxConversationType> => {
const pushToMessageRequests =
isMessageRequestEnabled && window?.sessionFeatureFlags?.useMessageRequests;
return _.filter(sortedConversations, conversation => {
return pushToMessageRequests && !conversation.isApproved && !conversation.isBlocked;
const { isApproved, isBlocked, isPrivate, isMe } = conversation;
const isRequest = ConversationModel.hasValidIncomingRequestValues({
isApproved,
isBlocked,
isPrivate,
isMe,
});
return isRequest;
});
};
export const getConversationRequests = createSelector(
getSortedConversations,
getIsMessageRequestsEnabled,
_getConversationRequests
);
const _getUnreadConversationRequests = (
sortedConversationRequests: Array<ReduxConversationType>
): Array<ReduxConversationType> => {
return _.filter(sortedConversationRequests, conversation => {
return conversation && conversation.unreadCount && conversation.unreadCount > 0;
});
};
export const getUnreadConversationRequests = createSelector(
getConversationRequests,
_getUnreadConversationRequests
);
const _getPrivateContactsPubkeys = (
sortedConversations: Array<ReduxConversationType>,
isMessageRequestEnabled?: boolean
sortedConversations: Array<ReduxConversationType>
): Array<string> => {
const pushToMessageRequests =
(isMessageRequestEnabled && window?.sessionFeatureFlags?.useMessageRequests) ||
!isMessageRequestEnabled;
return _.filter(sortedConversations, conversation => {
return (
conversation.isPrivate &&
!conversation.isBlocked &&
!conversation.isMe &&
(conversation.isApproved || !pushToMessageRequests) &&
conversation.didApproveMe &&
conversation.isApproved &&
Boolean(conversation.activeAt)
);
}).map(convo => convo.id);
@ -463,15 +496,10 @@ const _getPrivateContactsPubkeys = (
*/
export const getPrivateContactsPubkeys = createSelector(
getSortedConversations,
getIsMessageRequestsEnabled,
_getPrivateContactsPubkeys
);
export const getLeftPaneLists = createSelector(
getSortedConversations,
getIsMessageRequestsEnabled,
_getLeftPaneLists
);
export const getLeftPaneLists = createSelector(getSortedConversations, _getLeftPaneLists);
export const getMe = createSelector(
[getConversationLookup, getOurNumber],

@ -14,7 +14,7 @@ export const getShowRecoveryPhrasePrompt = createSelector(
(state: UserConfigState): boolean => state.showRecoveryPhrasePrompt
);
export const getIsMessageRequestsEnabled = createSelector(
export const getHideMessageRequestBanner = createSelector(
getUserConfig,
(state: UserConfigState): boolean => state.messageRequests
(state: UserConfigState): boolean => state.hideMessageRequests
);

@ -96,6 +96,7 @@ export class MockConversation {
isTrustedForAttachmentDownload: false,
isPinned: false,
isApproved: false,
didApproveMe: false,
};
}

@ -44,7 +44,7 @@ export type LocalizerKeys =
| 'viewMenuToggleDevTools'
| 'fileSizeWarning'
| 'openGroupURL'
| 'messageRequestsDescription'
| 'hideRequestBannerDescription'
| 'hideMenuBarDescription'
| 'pickClosedGroupMember'
| 'ByUsingThisService...'
@ -236,7 +236,7 @@ export type LocalizerKeys =
| 'editMenuDeleteContact'
| 'hideMenuBarTitle'
| 'imageCaptionIconAlt'
| 'blockAll'
| 'clearAll'
| 'sendRecoveryPhraseTitle'
| 'multipleJoinedTheGroup'
| 'databaseError'
@ -429,4 +429,23 @@ export type LocalizerKeys =
| 'searchFor...'
| 'joinedTheGroup'
| 'editGroupName'
| 'trimDatabase'
| 'trimDatabaseDescription'
| 'trimDatabaseConfirmationBody'
| 'respondingToRequestWarning'
| 'messageRequestPending'
| 'messageRequestAccepted'
| 'messageRequestAcceptedOurs'
| 'messageRequestAcceptedOursNoName'
| 'declineRequestMessage'
| 'openMessageRequestInbox'
| 'openMessageRequestInboxDescription'
| 'hideRequestBanner'
| 'noMessageRequestsPending'
| 'noMediaUntilApproved'
| 'mustBeApproved'
| 'youHaveANewFriendRequest'
| 'clearAllConfirmationTitle'
| 'clearAllConfirmationBody'
| 'hideBanner'
| 'reportIssue';

@ -7,5 +7,9 @@ export function getInitials(name?: string): string | undefined {
return name[2];
}
return name[0];
const initials = name.split(' ').slice(0, 2).map(n => {
return n[0];
})
return initials.join('');
}

1
ts/window.d.ts vendored

@ -38,7 +38,6 @@ declare global {
log: any;
sessionFeatureFlags: {
useOnionRequests: boolean;
useMessageRequests: boolean;
useCallMessage: boolean;
};
SessionSnodeAPI: SessionSnodeAPI;

@ -1007,10 +1007,17 @@
resolved "https://registry.yarnpkg.com/@types/js-cookie/-/js-cookie-2.2.6.tgz#f1a1cb35aff47bc5cfb05cb0c441ca91e914c26f"
integrity sha512-+oY0FDTO2GYKEV0YPvSshGq9t7YozVkgvXLty7zogQNuCxBhT9/3INX9Q7H1aRZ4SUDRXAKlJuA4EA5nTt7SNw==
"@types/libsodium-wrappers@^0.7.8":
version "0.7.8"
resolved "https://registry.yarnpkg.com/@types/libsodium-wrappers/-/libsodium-wrappers-0.7.8.tgz#34575d7692fdbb7a7fdb63afcde381db86ec0de2"
integrity sha512-vkDSj6enD3K0+Ep83wnoGUk+f7sqsO4alsqxxEZ8BcTJhFmcY4UehYH3rTf4M3JGHXNhdpGFDdMbWFMgyvw/fA==
"@types/libsodium-wrappers-sumo@^0.7.5":
version "0.7.5"
resolved "https://registry.yarnpkg.com/@types/libsodium-wrappers-sumo/-/libsodium-wrappers-sumo-0.7.5.tgz#c5191fc28d44edd39583552bd1855f6b7b77ca88"
integrity sha512-CL7rmLxw28H/FpFUnMu5BzzRsE+ICxHBpRoaY8ks+3HMsCJdA/Vp809sj+qNhw64Ht0OEnfoN3BC1sHwagoVaw==
dependencies:
"@types/libsodium-wrappers" "*"
"@types/libsodium-wrappers@*":
version "0.7.9"
resolved "https://registry.yarnpkg.com/@types/libsodium-wrappers/-/libsodium-wrappers-0.7.9.tgz#89c3ad2156d5143e64bce86cfeb0045a983aeccc"
integrity sha512-LisgKLlYQk19baQwjkBZZXdJL0KbeTpdEnrAfz5hQACbklCY0gVFnsKUyjfNWF1UQsCSjw93Sj5jSbiO8RPfdw==
"@types/linkify-it@2.0.3":
version "2.0.3"
@ -4411,17 +4418,17 @@ levn@^0.3.0, levn@~0.3.0:
prelude-ls "~1.1.2"
type-check "~0.3.2"
libsodium-wrappers@^0.7.8:
version "0.7.8"
resolved "https://registry.yarnpkg.com/libsodium-wrappers/-/libsodium-wrappers-0.7.8.tgz#d95cdf3e7236c2aef76844bf8e1929ba9eef3e9e"
integrity sha512-PDhPWXBqd/SaqAFUBgH2Ux7b3VEEJgyD6BQB+VdNFJb9PbExGr/T/myc/MBoSvl8qLzfm0W0IVByOQS5L1MrCg==
dependencies:
libsodium "0.7.8"
libsodium-sumo@^0.7.0:
version "0.7.9"
resolved "https://registry.yarnpkg.com/libsodium-sumo/-/libsodium-sumo-0.7.9.tgz#dff3d6144849e30d9b9a7bf628d28243f1c49102"
integrity sha512-DcfJ57zlSlcmQU4s8KOX78pT0zKx5S9RLi0oyDuoIgm4K95+VNSaOidK/y9lUK4lxft14PtTPjoBy8tmLk1TDQ==
libsodium@0.7.8:
version "0.7.8"
resolved "https://registry.yarnpkg.com/libsodium/-/libsodium-0.7.8.tgz#fbd12247b7b1353f88d8de1cbc66bc1a07b2e008"
integrity sha512-/Qc+APf0jbeWSaeEruH0L1/tbbT+sbf884ZL0/zV/0JXaDPBzYkKbyb/wmxMHgAHzm3t6gqe7bOOXAVwfqVikQ==
libsodium-wrappers-sumo@^0.7.9:
version "0.7.9"
resolved "https://registry.yarnpkg.com/libsodium-wrappers-sumo/-/libsodium-wrappers-sumo-0.7.9.tgz#d15a346870c8e339baf1518f06b0ddccfa1e1046"
integrity sha512-XLgLkqY973PngrRElbjOH0y7bJKYEfMWVpWPmW5iuhBjO6zXvHYQWtN52MVEeie/h98ZXN1Aw9BE+GzxQVAfLg==
dependencies:
libsodium-sumo "^0.7.0"
lines-and-columns@^1.1.6:
version "1.1.6"

Loading…
Cancel
Save