diff --git a/Gruntfile.js b/Gruntfile.js index 623b6c8ad..62c72ad2c 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -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', ]; diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 59b420e64..c75189c1b 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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" } diff --git a/app/sql.js b/app/sql.js index deb91b955..4893aed9f 100644 --- a/app/sql.js +++ b/app/sql.js @@ -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; diff --git a/build/README.md b/build/README.md index 4bd2603c7..7443681c6 100644 --- a/build/README.md +++ b/build/README.md @@ -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 diff --git a/build/icon.icns b/build/icon-linux.icns similarity index 100% rename from build/icon.icns rename to build/icon-linux.icns diff --git a/build/icon-mac.icns b/build/icon-mac.icns new file mode 100644 index 000000000..b1e9c1ece Binary files /dev/null and b/build/icon-mac.icns differ diff --git a/main.js b/main.js index c7b5157d3..b5ad53f93 100644 --- a/main.js +++ b/main.js @@ -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(); }); diff --git a/package.json b/package.json index 43f170be1..742aec818 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/preload.js b/preload.js index f1d8e21cb..3d1cef324 100644 --- a/preload.js +++ b/preload.js @@ -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, }; diff --git a/protos/SignalService.proto b/protos/SignalService.proto index a71bde61b..5caffd649 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -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; diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 5c61e37fc..ff105b3aa 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -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; diff --git a/stylesheets/_session.scss b/stylesheets/_session.scss index 53f349bd3..219a1d4ca 100644 --- a/stylesheets/_session.scss +++ b/stylesheets/_session.scss @@ -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; diff --git a/ts/components/avatar/AvatarPlaceHolder/AvatarPlaceHolder.tsx b/ts/components/avatar/AvatarPlaceHolder/AvatarPlaceHolder.tsx index 3136d728c..a5760249b 100644 --- a/ts/components/avatar/AvatarPlaceHolder/AvatarPlaceHolder.tsx +++ b/ts/components/avatar/AvatarPlaceHolder/AvatarPlaceHolder.tsx @@ -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} diff --git a/ts/components/conversation/ConversationHeader.tsx b/ts/components/conversation/ConversationHeader.tsx index eeaf14580..c8ae281d6 100644 --- a/ts/components/conversation/ConversationHeader.tsx +++ b/ts/components/conversation/ConversationHeader.tsx @@ -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 = () => { - {!isKickedFromGroup && } {!isSelectionMode && ( - <> + + {!isKickedFromGroup && ( + + )} { @@ -376,7 +379,7 @@ export const ConversationHeaderWithDetails = () => { pubkey={selectedConvoKey} showBackButton={isMessageDetailOpened} /> - > + )} diff --git a/ts/components/conversation/ConversationRequestButtons.tsx b/ts/components/conversation/ConversationRequestButtons.tsx new file mode 100644 index 000000000..7475e07de --- /dev/null +++ b/ts/components/conversation/ConversationRequestButtons.tsx @@ -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 ( + + + + + + + ); +}; + +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); +`; diff --git a/ts/components/conversation/ConversationRequestInfo.tsx b/ts/components/conversation/ConversationRequestInfo.tsx new file mode 100644 index 000000000..90ece3b31 --- /dev/null +++ b/ts/components/conversation/ConversationRequestInfo.tsx @@ -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 ( + + + {window.i18n('respondingToRequestWarning')} + + + ); +}; + +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; +`; diff --git a/ts/components/conversation/SessionConversation.tsx b/ts/components/conversation/SessionConversation.tsx index 4a2b3d4c1..cc02b2862 100644 --- a/ts/components/conversation/SessionConversation.tsx +++ b/ts/components/conversation/SessionConversation.tsx @@ -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 { - {lightBoxOptions?.media && this.renderLightBox(lightBoxOptions)} + } bottom={ @@ -254,6 +256,7 @@ export class SessionConversation extends React.Component { {isDraggingFile && } + , dateBreak, unreadIndicator]; } + if (messageProps.message?.messageType === 'message-request-response') { + const msgProps = messageProps.message.props as PropsForMessageRequestResponse; + + return [ + , + dateBreak, + unreadIndicator, + ]; + } + if (messageProps.message?.messageType === 'data-extraction') { const msgProps = messageProps.message.props as PropsForDataExtractionNotification; diff --git a/ts/components/conversation/composition/CompositionBox.tsx b/ts/components/conversation/composition/CompositionBox.tsx index fd2f5a40b..70f09a1fa 100644 --- a/ts/components/conversation/composition/CompositionBox.tsx +++ b/ts/components/conversation/composition/CompositionBox.tsx @@ -404,16 +404,24 @@ class CompositionBoxInner extends React.Component { 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 { } private onChooseAttachment() { + if ( + !this.props.selectedConversation?.didApproveMe && + this.props.selectedConversation?.isPrivate + ) { + ToastUtils.pushNoMediaUntilApproved(); + return; + } this.fileInput.current?.click(); } diff --git a/ts/components/conversation/message/message-content/ClickToTrustSender.tsx b/ts/components/conversation/message/message-content/ClickToTrustSender.tsx index 3787b3c02..9e0f974b6 100644 --- a/ts/components/conversation/message/message-content/ClickToTrustSender.tsx +++ b/ts/components/conversation/message/message-content/ClickToTrustSender.tsx @@ -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; `; diff --git a/ts/components/conversation/message/message-item/MessageRequestResponse.tsx b/ts/components/conversation/message/message-item/MessageRequestResponse.tsx new file mode 100644 index 000000000..bd97b18fe --- /dev/null +++ b/ts/components/conversation/message/message-item/MessageRequestResponse.tsx @@ -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 ( + + + + + + + ); +}; diff --git a/ts/components/leftpane/ActionsPanel.tsx b/ts/components/leftpane/ActionsPanel.tsx index ed321dd1a..e30b3ede6 100644 --- a/ts/components/leftpane/ActionsPanel.tsx +++ b/ts/components/leftpane/ActionsPanel.tsx @@ -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)); } }; diff --git a/ts/components/leftpane/LeftPane.tsx b/ts/components/leftpane/LeftPane.tsx index 10b9fed72..54beff741 100644 --- a/ts/components/leftpane/LeftPane.tsx +++ b/ts/components/leftpane/LeftPane.tsx @@ -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 ( diff --git a/ts/components/leftpane/LeftPaneMessageSection.tsx b/ts/components/leftpane/LeftPaneMessageSection.tsx index 5459aaa4d..f6c6d59f2 100644 --- a/ts/components/leftpane/LeftPaneMessageSection.tsx +++ b/ts/components/leftpane/LeftPaneMessageSection.tsx @@ -110,13 +110,11 @@ export class LeftPaneMessageSection extends React.Component { return ( - {window.sessionFeatureFlags.useMessageRequests ? ( - { - window.inboxStore?.dispatch(setOverlayMode('message-requests')); - }} - /> - ) : null} + { + window.inboxStore?.dispatch(setOverlayMode('message-requests')); + }} + /> {this.renderList()} {this.renderBottomButtons()} diff --git a/ts/components/leftpane/LeftPaneSectionHeader.tsx b/ts/components/leftpane/LeftPaneSectionHeader.tsx index 6bf0a7a50..a2610e7f4 100644 --- a/ts/components/leftpane/LeftPaneSectionHeader.tsx +++ b/ts/components/leftpane/LeftPaneSectionHeader.tsx @@ -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 ( + {showBackButton && ( + { + dispatch(setOverlayMode(undefined)); + }} + iconType="chevron" + iconRotation={90} + iconSize="medium" + margin="0 0 var(--margins-xs) var(--margins-xs)" + /> + )} {label} - {isMessageSection && ( + {isMessageSection && !isMessageRequestOverlay && ( diff --git a/ts/components/leftpane/MessageRequestsBanner.tsx b/ts/components/leftpane/MessageRequestsBanner.tsx index 28f07d442..01334bf4a 100644 --- a/ts/components/leftpane/MessageRequestsBanner.tsx +++ b/ts/components/leftpane/MessageRequestsBanner.tsx @@ -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 @@ -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) => { + if (e.button === 0) { + handleOnClick(); + } + }; + return ( - - - - {window.i18n('messageRequests')} - - - {conversationRequests.length || 0} - - + <> + { + e.stopPropagation(); + e.preventDefault(); + }} + > + + + {window.i18n('messageRequests')} + + + {conversationRequestsUnread || 0} + + + + + + > ); }; + +const Portal = ({ children }: { children: any }) => { + return createPortal(children, document.querySelector('.inbox.index') as Element); +}; diff --git a/ts/components/leftpane/conversation-list-item/MessageItem.tsx b/ts/components/leftpane/conversation-list-item/MessageItem.tsx index a87384cfe..5966ae293 100644 --- a/ts/components/leftpane/conversation-list-item/MessageItem.tsx +++ b/ts/components/leftpane/conversation-list-item/MessageItem.tsx @@ -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 }) => { )} - {!isSearchingMode && lastMessage && lastMessage.status && !props.isMessageRequest ? ( ) : null} diff --git a/ts/components/leftpane/conversation-list-item/MessageRequest.tsx b/ts/components/leftpane/conversation-list-item/MessageRequest.tsx deleted file mode 100644 index d33129aec..000000000 --- a/ts/components/leftpane/conversation-list-item/MessageRequest.tsx +++ /dev/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 ( - - ); -}; - -const ApproveMessageRequestButton = () => { - const conversationId = useContext(ContextConversationId); - - return ( - { - 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 ( - <> - - - > - ); -}; diff --git a/ts/components/leftpane/overlay/OverlayHeader.tsx b/ts/components/leftpane/overlay/OverlayHeader.tsx index e185714fa..990fcea17 100644 --- a/ts/components/leftpane/overlay/OverlayHeader.tsx +++ b/ts/components/leftpane/overlay/OverlayHeader.tsx @@ -12,7 +12,8 @@ export const OverlayHeader = ({ subtitle, title }: { title: string; subtitle: st { dispatch(setOverlayMode(undefined)); }} diff --git a/ts/components/leftpane/overlay/OverlayMessageRequest.tsx b/ts/components/leftpane/overlay/OverlayMessageRequest.tsx index 374b40e7d..00d9bcdfa 100644 --- a/ts/components/leftpane/overlay/OverlayMessageRequest.tsx +++ b/ts/components/leftpane/overlay/OverlayMessageRequest.tsx @@ -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) { + 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 ( - - - - - - - { - void handleBlockAllRequestsClick(messageRequestSetting); - }} - /> + {convoRequestCount ? ( + <> + + + { + handleClearAllRequestsClick(messageRequests); + }} + /> + > + ) : ( + <> + + + {window.i18n('noMessageRequestsPending')} + + > + )} ); }; +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; `; /** diff --git a/ts/components/menu/ConversationHeaderMenu.tsx b/ts/components/menu/ConversationHeaderMenu.tsx index 4ea167c7f..4b4ffade6 100644 --- a/ts/components/menu/ConversationHeaderMenu.tsx +++ b/ts/components/menu/ConversationHeaderMenu.tsx @@ -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 ( + + diff --git a/ts/components/menu/ConversationListItemContextMenu.tsx b/ts/components/menu/ConversationListItemContextMenu.tsx index 0aaef25c0..75b02bce1 100644 --- a/ts/components/menu/ConversationListItemContextMenu.tsx +++ b/ts/components/menu/ConversationListItemContextMenu.tsx @@ -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 ( + + diff --git a/ts/components/menu/Menu.tsx b/ts/components/menu/Menu.tsx index d6457d392..b2f39dd8d 100644 --- a/ts/components/menu/Menu.tsx +++ b/ts/components/menu/Menu.tsx @@ -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 markAllReadByConvoId(convoId)}>{window.i18n('markAllAsRead')}; + const isRequest = useIsRequest(convoId); + if (!isRequest) { + return ( + markAllReadByConvoId(convoId)}>{window.i18n('markAllAsRead')} + ); + } 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 ( clearNickNameByConvoId(convoId)}>{window.i18n('clearNickname')} ); @@ -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 ( { @@ -547,6 +576,11 @@ export const ChangeNicknameMenuItem = () => { export const DeleteMessagesMenuItem = () => { const convoId = useContext(ContextConversationId); + const isRequest = useIsRequest(convoId); + + if (isRequest) { + return null; + } return ( { ); }; + +export const HideBannerMenuItem = (): JSX.Element => { + const dispatch = useDispatch(); + return ( + { + dispatch(hideMessageRequestBanner()); + }} + > + {window.i18n('hideBanner')} + + ); +}; + +export const AcceptMenuItem = () => { + const convoId = useContext(ContextConversationId); + const isRequest = useIsRequest(convoId); + const convo = getConversationController().get(convoId); + + if (isRequest) { + return ( + { + await convo.setDidApproveMe(true); + await convo.addOutgoingApprovalMessage(Date.now()); + await approveConvoAndSendResponse(convoId, true); + }} + > + {window.i18n('accept')} + + ); + } + return null; +}; + +export const DeclineMenuItem = () => { + const convoId = useContext(ContextConversationId); + const isRequest = useIsRequest(convoId); + + if (isRequest) { + return ( + { + declineConversationWithConfirm(convoId, true); + }} + > + {window.i18n('decline')} + + ); + } + return null; +}; diff --git a/ts/components/menu/MessageRequestBannerContextMenu.tsx b/ts/components/menu/MessageRequestBannerContextMenu.tsx new file mode 100644 index 000000000..672d07088 --- /dev/null +++ b/ts/components/menu/MessageRequestBannerContextMenu.tsx @@ -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 ( + + + + ); +}; + +function propsAreEqual(prev: PropsContextConversationItem, next: PropsContextConversationItem) { + return _.isEqual(prev, next); +} +export const MemoMessageRequestBannerContextMenu = React.memo( + MessageRequestBannerContextMenu, + propsAreEqual +); diff --git a/ts/components/settings/ZoomingSessionSlider.tsx b/ts/components/settings/ZoomingSessionSlider.tsx index 5878259a9..b94c82a4b 100644 --- a/ts/components/settings/ZoomingSessionSlider.tsx +++ b/ts/components/settings/ZoomingSessionSlider.tsx @@ -27,7 +27,7 @@ export const ZoomingSessionSlider = (props: { onSliderChange?: (value: number) = /> - {currentValueFromSettings}%` + {currentValueFromSettings}% diff --git a/ts/components/settings/section/CategoryPrivacy.tsx b/ts/components/settings/section/CategoryPrivacy.tsx index d0bab0ab8..6b8fe7a00 100644 --- a/ts/components/settings/section/CategoryPrivacy.tsx +++ b/ts/components/settings/section/CategoryPrivacy.tsx @@ -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 && ( - { - dispatch(toggleMessageRequests()); - }} - title={window.i18n('messageRequests')} - description={window.i18n('messageRequestsDescription')} - active={useSelector(getIsMessageRequestsEnabled)} - /> - )} + { + dispatch(toggleMessageRequests()); + }} + title={window.i18n('hideRequestBanner')} + description={window.i18n('hideRequestBannerDescription')} + active={useSelector(getHideMessageRequestBanner)} + /> + { + dispatch(showLeftPaneSection(SectionType.Message)); + dispatch(setOverlayMode('message-requests')); + }} + buttonColor={SessionButtonColor.Primary} + buttonText={window.i18n('openMessageRequestInbox')} + /> {!props.hasPassword && ( { + return channels.getMessageCountByType(conversationId, type); +} + export async function getMessagesByConversation( conversationId: string, { skipTimerInit = false, messageId = null }: { skipTimerInit?: false; messageId: string | null } diff --git a/ts/hooks/useParamSelector.ts b/ts/hooks/useParamSelector.ts index 3cff7c8a4..b3f04a062 100644 --- a/ts/hooks/useParamSelector.ts +++ b/ts/hooks/useParamSelector.ts @@ -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) { diff --git a/ts/interactions/conversationInteractions.ts b/ts/interactions/conversationInteractions.ts index ec1120528..30f97519a 100644 --- a/ts/interactions/conversationInteractions.ts +++ b/ts/interactions/conversationInteractions.ts @@ -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 { diff --git a/ts/models/conversation.ts b/ts/models/conversation.ts index 8bc6073b0..b18165411 100644 --- a/ts/models/conversation.ts +++ b/ts/models/conversation.ts @@ -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 { ); } + /** + * 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 { 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 { 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 { 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 { } } + /** + * 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 { public async addSingleIncomingMessage( messageAttributes: Omit ) { + // 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 { `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; const receiptMessage = new ReadReceiptMessage({ timestamp: Date.now(), @@ -1108,14 +1250,29 @@ export class ConversationModel extends Backbone.Model { } } - 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 { 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 { } 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 { 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 { 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 { ) { 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 { throw new Error('Need to provide either recipientId'); } + if (!this.isApproved()) { + return; + } + if (this.isMe()) { // note to self return; diff --git a/ts/models/message.ts b/ts/models/message.ts index dc06af0c8..fb189e6c8 100644 --- a/ts/models/message.ts +++ b/ts/models/message.ts @@ -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 { 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 { 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 { 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 { }; } + 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); } diff --git a/ts/models/messageType.ts b/ts/models/messageType.ts index 609df5990..f987e4cc6 100644 --- a/ts/models/messageType.ts +++ b/ts/models/messageType.ts @@ -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; joined?: Array; @@ -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; diff --git a/ts/receiver/closedGroups.ts b/ts/receiver/closedGroups.ts index 8dc33d3b2..c30877136 100644 --- a/ts/receiver/closedGroups.ts +++ b/ts/receiver/closedGroups.ts @@ -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; } diff --git a/ts/receiver/configMessage.ts b/ts/receiver/configMessage.ts index 1a9fbabc6..b2283039b 100644 --- a/ts/receiver/configMessage.ts +++ b/ts/receiver/configMessage.ts @@ -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) => { + 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); diff --git a/ts/receiver/contentMessage.ts b/ts/receiver/contentMessage.ts index c0265c2d5..9237ce416 100644 --- a/ts/receiver/contentMessage.ts +++ b/ts/receiver/contentMessage.ts @@ -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); } /** diff --git a/ts/receiver/queuedJob.ts b/ts/receiver/queuedJob.ts index 8569cd6d9..b413c83a9 100644 --- a/ts/receiver/queuedJob.ts +++ b/ts/receiver/queuedJob.ts @@ -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); } } diff --git a/ts/session/crypto/index.ts b/ts/session/crypto/index.ts index 995f8d726..56d821b06 100644 --- a/ts/session/crypto/index.ts +++ b/ts/session/crypto/index.ts @@ -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'; diff --git a/ts/session/messages/outgoing/controlMessage/ConfigurationMessage.ts b/ts/session/messages/outgoing/controlMessage/ConfigurationMessage.ts index d6e5ee656..00ec330c3 100644 --- a/ts/session/messages/outgoing/controlMessage/ConfigurationMessage.ts +++ b/ts/session/messages/outgoing/controlMessage/ConfigurationMessage.ts @@ -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, }); } } diff --git a/ts/session/messages/outgoing/controlMessage/MessageRequestResponse.ts b/ts/session/messages/outgoing/controlMessage/MessageRequestResponse.ts new file mode 100644 index 000000000..8ebdfdd60 --- /dev/null +++ b/ts/session/messages/outgoing/controlMessage/MessageRequestResponse.ts @@ -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, + }); + } +} diff --git a/ts/session/utils/Toast.tsx b/ts/session/utils/Toast.tsx index 945d53254..cb64faad8 100644 --- a/ts/session/utils/Toast.tsx +++ b/ts/session/utils/Toast.tsx @@ -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')); +} diff --git a/ts/session/utils/calling/CallManager.ts b/ts/session/utils/calling/CallManager.ts index d583a43d4..1e7f8faf0 100644 --- a/ts/session/utils/calling/CallManager.ts +++ b/ts/session/utils/calling/CallManager.ts @@ -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) { diff --git a/ts/session/utils/syncUtils.ts b/ts/session/utils/syncUtils.ts index 8a8365c48..bae205fb2 100644 --- a/ts/session/utils/syncUtils.ts +++ b/ts/session/utils/syncUtils.ts @@ -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) => { 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 = ( diff --git a/ts/state/ducks/SessionTheme.tsx b/ts/state/ducks/SessionTheme.tsx index 1f73c9bdc..8a88e81d4 100644 --- a/ts/state/ducks/SessionTheme.tsx +++ b/ts/state/ducks/SessionTheme.tsx @@ -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 diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 7a807639f..0ba7e00b8 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -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; diff --git a/ts/state/ducks/userConfig.tsx b/ts/state/ducks/userConfig.tsx index 8f6fcd9f3..7881ffa62 100644 --- a/ts/state/ducks/userConfig.tsx +++ b/ts/state/ducks/userConfig.tsx @@ -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; diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index 3a665f979..b06b20e13 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -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, - isMessageRequestEnabled?: boolean + sortedConversations: Array ): { conversations: Array; contacts: Array; @@ -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, - isMessageRequestEnabled?: boolean + sortedConversations: Array ): Array => { - 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 +): Array => { + return _.filter(sortedConversationRequests, conversation => { + return conversation && conversation.unreadCount && conversation.unreadCount > 0; + }); +}; + +export const getUnreadConversationRequests = createSelector( + getConversationRequests, + _getUnreadConversationRequests +); + const _getPrivateContactsPubkeys = ( - sortedConversations: Array, - isMessageRequestEnabled?: boolean + sortedConversations: Array ): Array => { - 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], diff --git a/ts/state/selectors/userConfig.ts b/ts/state/selectors/userConfig.ts index 39dd45eba..ac3ce005d 100644 --- a/ts/state/selectors/userConfig.ts +++ b/ts/state/selectors/userConfig.ts @@ -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 ); diff --git a/ts/test/test-utils/utils/message.ts b/ts/test/test-utils/utils/message.ts index 5b087df95..d1e2d9d7c 100644 --- a/ts/test/test-utils/utils/message.ts +++ b/ts/test/test-utils/utils/message.ts @@ -96,6 +96,7 @@ export class MockConversation { isTrustedForAttachmentDownload: false, isPinned: false, isApproved: false, + didApproveMe: false, }; } diff --git a/ts/types/LocalizerKeys.ts b/ts/types/LocalizerKeys.ts index 2ad0e3a4f..3db49120f 100644 --- a/ts/types/LocalizerKeys.ts +++ b/ts/types/LocalizerKeys.ts @@ -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'; diff --git a/ts/util/getInitials.ts b/ts/util/getInitials.ts index dd0d62464..b890870f6 100644 --- a/ts/util/getInitials.ts +++ b/ts/util/getInitials.ts @@ -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(''); } diff --git a/ts/window.d.ts b/ts/window.d.ts index 9c6d0f8a4..a075e8a5b 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -38,7 +38,6 @@ declare global { log: any; sessionFeatureFlags: { useOnionRequests: boolean; - useMessageRequests: boolean; useCallMessage: boolean; }; SessionSnodeAPI: SessionSnodeAPI; diff --git a/yarn.lock b/yarn.lock index bf671054f..897a05294 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"
{currentValueFromSettings}%`
{currentValueFromSettings}%