diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 84e508fa2..a6242ffb4 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -436,6 +436,11 @@ "notificationSubtitle": "Notifications - $setting$", "surveyTitle": "Take our Session Survey", "goToOurSurvey": "Go to our survey", + "blockAll": "Block All", + "messageRequests": "Message Requests", + "requestsSubtitle": "Pending Requests", + "requestsPlaceholder": "No requests", + "messageRequestsDescription": "Enable Message Request Inbox", "incomingCallFrom": "Incoming call from '$name$'", "ringing": "Ringing...", "establishingConnection": "Establishing connection...", diff --git a/app/sql.js b/app/sql.js index a1d421450..5125442f3 100644 --- a/app/sql.js +++ b/app/sql.js @@ -1612,7 +1612,7 @@ function updateConversation(data) { members = $members, name = $name, profileName = $profileName - WHERE id = $id;` + WHERE id = $id;` ) .run({ id, diff --git a/package.json b/package.json index 7a4f72254..98441bd91 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "transpile": "tsc --incremental", "transpile:watch": "tsc -w", "integration-test": "mocha --recursive --exit --timeout 30000 \"./ts/test-integration/**/*.test.js\" \"./ts/test/*.test.js\"", - "clean-transpile": "rimraf 'ts/**/*.js ts/*.js' 'ts/*.js.map' 'ts/**/*.js.map' && rimraf tsconfig.tsbuildinfo;", + "clean-transpile": "rimraf 'ts/**/*.js' 'ts/*.js' 'ts/*.js.map' 'ts/**/*.js.map' && rimraf tsconfig.tsbuildinfo;", "ready": "yarn clean-transpile; yarn grunt && yarn lint-full && yarn test", "build:webpack:sql-worker": "cross-env NODE_ENV=production webpack -c webpack-sql-worker.config.ts", "sedtoAppImage": "sed -i 's/\"target\": \\[\"deb\", \"rpm\", \"freebsd\"\\]/\"target\": \"AppImage\"/g' package.json", diff --git a/preload.js b/preload.js index f4e468f1a..ebe50c750 100644 --- a/preload.js +++ b/preload.js @@ -39,6 +39,7 @@ window.isBehindProxy = () => Boolean(config.proxyUrl); window.lokiFeatureFlags = { useOnionRequests: true, + useMessageRequests: true, useCallMessage: true, }; diff --git a/protos/SignalService.proto b/protos/SignalService.proto index 5234f108d..e3a9583c5 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -193,6 +193,8 @@ message ConfigurationMessage { required string name = 2; optional string profilePicture = 3; optional bytes profileKey = 4; + optional bool isApproved = 5; + optional bool isBlocked = 6; } repeated ClosedGroup closedGroups = 1; diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index cda5a79b5..badeba855 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -888,6 +888,10 @@ flex-direction: column; align-items: stretch; overflow: hidden; + + .session-icon-button:first-child { + margin-right: $session-margin-sm; + } } .module-conversation-list-item__header { diff --git a/stylesheets/_session_left_pane.scss b/stylesheets/_session_left_pane.scss index ade13d4d4..f33fba1df 100644 --- a/stylesheets/_session_left_pane.scss +++ b/stylesheets/_session_left_pane.scss @@ -249,6 +249,14 @@ $session-compose-margin: 20px; margin-bottom: 3rem; flex-shrink: 0; } + + .message-request-list__container { + width: 100%; + + .session-button { + margin: $session-margin-xs $session-margin-xs $session-margin-xs 0; + } + } } } .module-search-results { diff --git a/ts/components/ConversationListItem.tsx b/ts/components/ConversationListItem.tsx index cce539e08..d6e308cb1 100644 --- a/ts/components/ConversationListItem.tsx +++ b/ts/components/ConversationListItem.tsx @@ -25,7 +25,10 @@ import { useDispatch, useSelector } from 'react-redux'; import { SectionType } from '../state/ducks/section'; import { getFocusedSection } from '../state/selectors/section'; import { ConversationNotificationSettingType } from '../models/conversation'; +import { Flex } from './basic/Flex'; +import { forceSyncConfigurationNowIfNeeded } from '../session/utils/syncUtils'; import { updateUserDetailsModal } from '../state/ducks/modalDialog'; +import { approveConversation, blockConvoById } from '../interactions/conversationInteractions'; import { useAvatarPath, useConversationUsername, useIsMe } from '../hooks/useParamSelector'; // tslint:disable-next-line: no-empty-interface @@ -42,6 +45,7 @@ export const StyledConversationListItemIconWrapper = styled.div` type PropsHousekeeping = { style?: Object; + isMessageRequest?: boolean; }; // tslint:disable: use-simple-attributes @@ -226,6 +230,7 @@ const AvatarItem = (props: { conversationId: string; isPrivate: boolean }) => { ); }; +// tslint:disable: max-func-body-length const ConversationListItem = (props: Props) => { const { activeAt, @@ -247,6 +252,7 @@ const ConversationListItem = (props: Props) => { avatarPath, isPrivate, currentNotificationSetting, + isMessageRequest, } = props; const triggerId = `conversation-item-${conversationId}-ctxmenu`; const key = `conversation-item-${conversationId}`; @@ -261,6 +267,15 @@ const ConversationListItem = (props: Props) => { [conversationId] ); + /** + * 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 (
{ unreadCount={unreadCount || 0} lastMessage={lastMessage} /> + {isMessageRequest ? ( + + + { + await approveConversation(conversationId); + }} + backgroundColor="var(--color-accent)" + iconColor="var(--color-foreground-primary)" + iconPadding="var(--margins-xs)" + borderRadius="2px" + /> + + ) : null}
diff --git a/ts/components/LeftPane.tsx b/ts/components/LeftPane.tsx index 3d9bf780d..194f00a29 100644 --- a/ts/components/LeftPane.tsx +++ b/ts/components/LeftPane.tsx @@ -11,6 +11,7 @@ import { useSelector } from 'react-redux'; import { getLeftPaneLists } from '../state/selectors/conversations'; import { getQuery, getSearchResults, isSearching } from '../state/selectors/search'; import { SectionType } from '../state/ducks/section'; +import { getIsMessageRequestsEnabled } from '../state/selectors/userConfig'; // from https://github.com/bvaughn/react-virtualized/blob/fb3484ed5dcc41bffae8eab029126c0fb8f7abc0/source/List/types.js#L5 export type RowRendererParamsType = { @@ -29,7 +30,7 @@ const InnerLeftPaneMessageSection = () => { const searchResults = showSearch ? useSelector(getSearchResults) : undefined; const lists = showSearch ? undefined : useSelector(getLeftPaneLists); - // tslint:disable: use-simple-attributes + const messageRequestsEnabled = useSelector(getIsMessageRequestsEnabled); return ( { contacts={lists?.contacts || []} searchResults={searchResults} searchTerm={searchTerm} + messageRequestsEnabled={messageRequestsEnabled} /> ); }; diff --git a/ts/components/session/LeftPaneMessageSection.tsx b/ts/components/session/LeftPaneMessageSection.tsx index 0aabe6e5d..79d7874b3 100644 --- a/ts/components/session/LeftPaneMessageSection.tsx +++ b/ts/components/session/LeftPaneMessageSection.tsx @@ -28,6 +28,9 @@ import { onsNameRegex } from '../../session/snode_api/SNodeAPI'; import { SNodeAPI } from '../../session/snode_api'; import { clearSearch, search, updateSearchTerm } from '../../state/ducks/search'; import _ from 'lodash'; +import { MessageRequestsBanner } from './MessageRequestsBanner'; +import { BlockedNumberController } from '../../util'; +import { forceSyncConfigurationNowIfNeeded } from '../../session/utils/syncUtils'; export interface Props { searchTerm: string; @@ -35,6 +38,8 @@ export interface Props { contacts: Array; conversations?: Array; searchResults?: SearchResultsProps; + + messageRequestsEnabled?: boolean; } export enum SessionComposeToType { @@ -51,7 +56,7 @@ export type SessionGroupType = SessionComposeToType; interface State { loading: boolean; - overlay: false | SessionComposeToType; + overlay: false | SessionClosableOverlayType; valuePasted: string; } @@ -71,14 +76,19 @@ export class LeftPaneMessageSection extends React.Component { this.debouncedSearch = _.debounce(this.search.bind(this), 20); } - public renderRow = ({ index, key, style }: RowRendererParamsType): JSX.Element => { + public renderRow = ({ index, key, style }: RowRendererParamsType): JSX.Element | null => { const { conversations } = this.props; + //assume conversations that have been marked unapproved should be filtered out by selector. if (!conversations) { throw new Error('renderRow: Tried to render without conversations'); } const conversation = conversations[index]; + if (!conversation) { + throw new Error('renderRow: conversations selector returned element containing falsy value.'); + return null; + } return ; }; @@ -96,6 +106,7 @@ export class LeftPaneMessageSection extends React.Component { } const length = conversations.length; + const listKey = 0; // Note: conversations is not a known prop for List, but it is required to ensure that // it re-renders when our conversation data changes. Otherwise it would just render @@ -144,7 +155,7 @@ export class LeftPaneMessageSection extends React.Component { return (
{this.renderHeader()} - {overlay ? this.renderClosableOverlay(overlay) : this.renderConversations()} + {overlay ? this.renderClosableOverlay() : this.renderConversations()}
); } @@ -157,6 +168,9 @@ export class LeftPaneMessageSection extends React.Component { onChange={this.updateSearch} placeholder={window.i18n('searchFor...')} /> + {window.lokiFeatureFlags.useMessageRequests ? ( + + ) : null} {this.renderList()} {this.renderBottomButtons()} @@ -201,9 +215,57 @@ export class LeftPaneMessageSection extends React.Component { ); } - private renderClosableOverlay(overlay: SessionComposeToType) { + private handleMessageRequestsClick() { + this.handleToggleOverlay(SessionClosableOverlayType.MessageRequests); + } + + /** + * Blocks all message request conversations and synchronizes across linked devices + * @returns void + */ + private async handleBlockAllRequestsClick() { + const messageRequestsEnabled = + this.props.messageRequestsEnabled && window?.lokiFeatureFlags?.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(); + } + } + + private renderClosableOverlay() { const { searchTerm, searchResults } = this.props; - const { loading } = this.state; + const { loading, overlay } = this.state; const openGroupElement = ( { /> ); + const messageRequestsElement = ( + { + this.handleToggleOverlay(undefined); + }} + onButtonClick={this.handleBlockAllRequestsClick} + /> + ); + let overlayElement; switch (overlay) { - case SessionComposeToType.OpenGroup: + case SessionClosableOverlayType.OpenGroup: overlayElement = openGroupElement; break; - case SessionComposeToType.ClosedGroup: + case SessionClosableOverlayType.ClosedGroup: overlayElement = closedGroupElement; break; - default: + case SessionClosableOverlayType.Message: overlayElement = messageElement; + break; + case SessionClosableOverlayType.MessageRequests: + overlayElement = messageRequestsElement; + break; + default: + overlayElement = false; } return overlayElement; @@ -277,7 +355,7 @@ export class LeftPaneMessageSection extends React.Component { buttonType={SessionButtonType.SquareOutline} buttonColor={SessionButtonColor.Green} onClick={() => { - this.handleToggleOverlay(SessionComposeToType.OpenGroup); + this.handleToggleOverlay(SessionClosableOverlayType.OpenGroup); }} /> { buttonType={SessionButtonType.SquareOutline} buttonColor={SessionButtonColor.White} onClick={() => { - this.handleToggleOverlay(SessionComposeToType.ClosedGroup); + this.handleToggleOverlay(SessionClosableOverlayType.ClosedGroup); }} /> ); } - private handleToggleOverlay(conversationType?: SessionComposeToType) { - const overlayState = conversationType || false; + private handleToggleOverlay(overlayType?: SessionClosableOverlayType) { + const overlayState = overlayType || false; this.setState({ overlay: overlayState }); @@ -403,6 +481,6 @@ export class LeftPaneMessageSection extends React.Component { } private handleNewSessionButtonClick() { - this.handleToggleOverlay(SessionComposeToType.Message); + this.handleToggleOverlay(SessionClosableOverlayType.Message); } } diff --git a/ts/components/session/MessageRequestsBanner.tsx b/ts/components/session/MessageRequestsBanner.tsx new file mode 100644 index 000000000..e19642439 --- /dev/null +++ b/ts/components/session/MessageRequestsBanner.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import styled from 'styled-components'; +import { getConversationRequests } from '../../state/selectors/conversations'; +import { SessionIcon, SessionIconSize, SessionIconType } from './icon'; + +const StyledMessageRequestBanner = styled.div` + border-left: var(--border-unread); + height: 64px; + width: 100%; + max-width: 300px; + display: flex; + flex-direction: row; + padding: 8px 12px; // adjusting for unread border always being active + align-items: center; + cursor: pointer; + + transition: var(--session-transition-duration); + + &:hover { + background: var(--color-clickable-hovered); + } +`; + +const StyledMessageRequestBannerHeader = styled.span` + font-weight: bold; + font-size: 15px; + color: var(--color-text-subtle); + padding-left: var(--margin-xs); + margin-inline-start: 12px; + margin-top: var(--margin-sm); + line-height: 18px; + overflow-x: hidden; + overflow-y: hidden; + white-space: nowrap; + text-overflow: ellipsis; +`; + +const StyledCircleIcon = styled.div` + padding-left: var(--margin-xs); +`; + +const StyledUnreadCounter = styled.div` + font-weight: bold; + border-radius: 50%; + background-color: var(--color-clickable-hovered); + margin-left: 10px; + width: 20px; + height: 20px; + line-height: 25px; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; +`; + +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); +`; + +export const CirclularIcon = (props: { iconType: SessionIconType; iconSize: SessionIconSize }) => { + const { iconSize, iconType } = props; + + return ( + + + + + + ); +}; + +export const MessageRequestsBanner = (props: { handleOnClick: () => any }) => { + const { handleOnClick } = props; + const conversationRequests = useSelector(getConversationRequests); + + if (!conversationRequests.length) { + return null; + } + + return ( + + + + {window.i18n('messageRequests')} + + +
{conversationRequests.length || 0}
+
+
+ ); +}; diff --git a/ts/components/session/SessionClosableOverlay.tsx b/ts/components/session/SessionClosableOverlay.tsx index cae8021dd..967dc1b77 100644 --- a/ts/components/session/SessionClosableOverlay.tsx +++ b/ts/components/session/SessionClosableOverlay.tsx @@ -9,16 +9,20 @@ import { SessionSpinner } from './SessionSpinner'; import { ConversationTypeEnum } from '../../models/conversation'; import { SessionJoinableRooms } from './SessionJoinableDefaultRooms'; import { SpacerLG, SpacerMD } from '../basic/Text'; +import { useSelector } from 'react-redux'; +import { getConversationRequests } from '../../state/selectors/conversations'; +import { MemoConversationListItemWithDetails } from '../ConversationListItem'; export enum SessionClosableOverlayType { Message = 'message', OpenGroup = 'open-group', ClosedGroup = 'closed-group', + MessageRequests = 'message-requests', } interface Props { overlayMode: SessionClosableOverlayType; - onChangeSessionID: any; + onChangeSessionID?: any; onCloseClick: any; onButtonClick: any; contacts?: Array; @@ -106,6 +110,7 @@ export class SessionClosableOverlay extends React.Component { const isMessageView = overlayMode === SessionClosableOverlayType.Message; const isOpenGroupView = overlayMode === SessionClosableOverlayType.OpenGroup; const isClosedGroupView = overlayMode === SessionClosableOverlayType.ClosedGroup; + const isMessageRequestView = overlayMode === SessionClosableOverlayType.MessageRequests; let title; let buttonText; @@ -123,7 +128,6 @@ export class SessionClosableOverlay extends React.Component { case 'open-group': title = window.i18n('joinOpenGroup'); buttonText = window.i18n('next'); - // descriptionLong = ''; subtitle = window.i18n('openGroupURL'); placeholder = window.i18n('enterAnOpenGroupURL'); break; @@ -133,6 +137,12 @@ export class SessionClosableOverlay extends React.Component { subtitle = window.i18n('createClosedGroupNamePrompt'); placeholder = window.i18n('createClosedGroupPlaceholder'); break; + case SessionClosableOverlayType.MessageRequests: + title = window.i18n('messageRequests'); + buttonText = window.i18n('blockAll'); + subtitle = window.i18n('requestsSubtitle'); + placeholder = window.i18n('requestsPlaceholder'); + break; default: } @@ -172,14 +182,24 @@ export class SessionClosableOverlay extends React.Component { onPressEnter={() => onButtonClick(groupName, selectedMembers)} /> - ) : ( + ) : null} + + {isMessageView ? ( - )} + ) : null} + + {isMessageRequestView ? ( + <> + + + + + ) : null} @@ -266,3 +286,24 @@ export class SessionClosableOverlay extends React.Component { } } } + +/** + * A request needs to be be unapproved and not blocked to be valid. + * @returns List of message request items + */ +const MessageRequestList = () => { + const conversationRequests = useSelector(getConversationRequests); + return ( +
+ {conversationRequests.map(conversation => { + return ( + + ); + })} +
+ ); +}; diff --git a/ts/components/session/icon/Icons.tsx b/ts/components/session/icon/Icons.tsx index bad644f41..2079d9427 100644 --- a/ts/components/session/icon/Icons.tsx +++ b/ts/components/session/icon/Icons.tsx @@ -27,6 +27,7 @@ export type SessionIconType = | 'info' | 'link' | 'lock' + | 'messageRequest' | 'microphone' | 'microphoneFull' | 'moon' @@ -240,6 +241,12 @@ export const icons = { viewBox: '0 0 512 512', ratio: 1, }, + messageRequest: { + path: + 'M68.987 7.718H27.143c-2.73 0-5.25.473-7.508 1.417-2.257.945-4.357 2.363-6.248 4.253-1.89 1.89-3.308 3.99-4.253 6.248-.945 2.257-1.417 4.778-1.417 7.508V67.99c0 2.73.472 5.25 1.417 7.508.945 2.258 2.363 4.357 4.253 6.248 1.942 1.891 4.043 3.359 6.3 4.252 2.258.945 4.726 1.418 7.456 1.418h17.956c2.101 0 3.833 1.732 3.833 3.832 0 .473-.105.893-.21 1.313-.683 2.521-1.418 5.041-2.258 7.455-.893 2.574-1.837 4.988-2.888 7.352-.525 1.207-1.155 2.361-1.837 3.57 3.675-1.629 7.14-3.518 10.343-5.619 3.36-2.205 6.51-4.672 9.397-7.35 2.94-2.73 5.565-5.723 7.98-8.926.735-.996 1.89-1.521 3.045-1.521H87.94c2.73 0 5.198-.473 7.455-1.418 2.258-.945 4.358-2.363 6.301-4.252 1.89-1.891 3.308-3.99 4.253-6.248.944-2.258 1.417-4.779 1.417-7.508V27.249c0-2.73-.473-5.25-1.417-7.508-.945-2.258-2.363-4.357-4.253-6.248s-3.99-3.308-6.248-4.252c-2.258-.945-4.777-1.418-7.508-1.418H68.987v-.105zm-7.282 47.97h-9.976V54.61c0-1.833.188-3.327.574-4.471.386-1.155.958-2.193 1.721-3.143.762-.951 2.474-2.619 5.136-5.005 1.416-1.251 2.124-2.396 2.124-3.435 0-1.047-.287-1.852-.851-2.434-.574-.573-1.435-.864-2.59-.864-1.247 0-2.269.446-3.083 1.338-.816.883-1.335 2.444-1.561 4.657l-10.191-1.368c.349-4.054 1.711-7.314 4.078-9.787 2.376-2.473 6.015-3.706 10.917-3.706 3.818 0 6.893.863 9.24 2.58 3.184 2.338 4.778 5.441 4.778 9.321 0 1.61-.412 3.172-1.237 4.666-.815 1.493-2.501 3.327-5.037 5.48-1.766 1.523-2.887 2.735-3.353 3.657-.456.914-.689 2.116-.689 3.592zm-10.325 2.87h10.693v8.532H51.38v-8.532zM46.097.053H87.94c3.675 0 7.141.683 10.396 1.995 3.202 1.312 6.143 3.308 8.768 5.933 2.626 2.625 4.621 5.565 5.934 8.768 1.312 3.203 1.994 6.667 1.994 10.396V67.99c0 3.729-.683 7.193-1.994 10.396-1.313 3.201-3.308 6.141-5.934 8.768-2.625 2.625-5.565 4.566-8.768 5.932-3.202 1.313-6.668 1.996-10.396 1.996H74.395c-2.362 2.992-4.935 5.826-7.665 8.4-3.255 3.045-6.72 5.773-10.448 8.189-3.728 2.467-7.718 4.621-11.971 6.457-4.2 1.838-8.715 3.361-13.44 4.621-1.365.367-2.835-.053-3.833-1.156-1.417-1.574-1.26-3.988.315-5.406 2.205-1.943 4.095-3.938 5.618-5.934 1.47-1.941 2.678-3.938 3.57-5.984v-.053c.998-2.205 1.89-4.463 2.678-6.721.263-.787.525-1.627.788-2.467H27.091c-3.675 0-7.14-.684-10.396-1.996-3.203-1.313-6.143-3.307-8.768-5.932-2.625-2.625-4.62-5.566-5.933-8.768C.682 75.078 0 71.613 0 67.938V27.091c0-3.676.682-7.141 1.995-10.396 1.313-3.203 3.308-6.143 5.933-8.768 2.625-2.625 5.565-4.62 8.768-5.933S23.363 0 27.091 0h18.953l.053.053z', + viewBox: '0 0 115.031 122.88', + ratio: 1, + }, microphone: { path: 'M43.362728,18.444286 C46.0752408,18.444286 48.2861946,16.2442453 48.2861946,13.5451212 L48.2861946,6.8991648 C48.2861946,4.20004074 46.0752408,2 43.362728,2 C40.6502153,2 38.4392615,4.20004074 38.4392615,6.8991648 L38.4392615,13.5451212 C38.4392615,16.249338 40.6502153,18.444286 43.362728,18.444286 Z M51.0908304,12.9238134 C51.4388509,12.9238134 51.7203381,13.2039112 51.7203381,13.5502139 C51.7203381,17.9248319 48.3066664,21.5202689 43.9871178,21.8411082 L43.9871178,21.8411082 L43.9871178,25.747199 L47.2574869,25.747199 C47.6055074,25.747199 47.8869946,26.0272968 47.8869946,26.3735995 C47.8869946,26.7199022 47.6055074,27 47.2574869,27 L47.2574869,27 L39.4628512,27 C39.1148307,27 38.8333435,26.7199022 38.8333435,26.3735995 C38.8333435,26.0272968 39.1148307,25.747199 39.4628512,25.747199 L39.4628512,25.747199 L42.7332204,25.747199 L42.7332204,21.8411082 C38.4136717,21.5253616 35,17.9248319 35,13.5502139 C35,13.2039112 35.2814872,12.9238134 35.6295077,12.9238134 C35.9775282,12.9238134 36.2538974,13.2039112 36.2436615,13.5502139 C36.2436615,17.4512121 39.4321435,20.623956 43.3524921,20.623956 C47.2728408,20.623956 50.4613228,17.4512121 50.4613228,13.5502139 C50.4613228,13.2039112 50.7428099,12.9238134 51.0908304,12.9238134 Z M43.362728,3.24770829 C45.3843177,3.24770829 47.0322972,4.88755347 47.0322972,6.8991648 L47.0322972,13.5451212 C47.0322972,15.5567325 45.3843177,17.1965777 43.362728,17.1965777 C41.3411383,17.1965777 39.6931589,15.5567325 39.6931589,13.5451212 L39.6931589,6.8991648 C39.6931589,4.88755347 41.3411383,3.24770829 43.362728,3.24770829', diff --git a/ts/components/session/settings/section/CategoryPrivacy.tsx b/ts/components/session/settings/section/CategoryPrivacy.tsx index a0565fc83..e876bbc2b 100644 --- a/ts/components/session/settings/section/CategoryPrivacy.tsx +++ b/ts/components/session/settings/section/CategoryPrivacy.tsx @@ -1,7 +1,10 @@ import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; // tslint:disable-next-line: no-submodule-imports import useUpdate from 'react-use/lib/useUpdate'; import { sessionPassword, updateConfirmModal } from '../../../../state/ducks/modalDialog'; +import { toggleMessageRequests } from '../../../../state/ducks/userConfig'; +import { getIsMessageRequestsEnabled } from '../../../../state/selectors/userConfig'; import { PasswordAction } from '../../../dialog/SessionPasswordDialog'; import { SessionButtonColor } from '../../SessionButton'; import { SessionSettingButtonItem, SessionToggleWithDescription } from '../SessionSettingListItem'; @@ -52,6 +55,7 @@ export const SettingsCategoryPrivacy = (props: { onPasswordUpdated: (action: string) => void; }) => { const forceUpdate = useUpdate(); + const dispatch = useDispatch(); if (props.hasPassword !== null) { return ( @@ -107,6 +111,14 @@ export const SettingsCategoryPrivacy = (props: { description={window.i18n('autoUpdateSettingDescription')} active={Boolean(window.getSettingValue(settingsAutoUpdate))} /> + { + dispatch(toggleMessageRequests()); + }} + title={window.i18n('messageRequests')} + description={window.i18n('messageRequestsDescription')} + active={useSelector(getIsMessageRequestsEnabled)} + /> {!props.hasPassword && ( { - await channels.updateConversation(data); + const cleanedData = _cleanData(data); + await channels.updateConversation(cleanedData); } export async function removeConversation(id: string): Promise { @@ -600,7 +601,6 @@ export async function cleanLastHashes(): Promise { await channels.cleanLastHashes(); } -// TODO: Strictly type the following export async function saveSeenMessageHashes( data: Array<{ expiresAt: number; diff --git a/ts/interactions/conversationInteractions.ts b/ts/interactions/conversationInteractions.ts index 4851f6dc4..fad5af236 100644 --- a/ts/interactions/conversationInteractions.ts +++ b/ts/interactions/conversationInteractions.ts @@ -22,6 +22,7 @@ import { } from '../state/ducks/modalDialog'; import { createOrUpdateItem, + getConversationById, getItemById, getMessageById, hasLinkPreviewPopupBeenDisplayed, @@ -36,6 +37,7 @@ import { fromHexToArray, toHex } from '../session/utils/String'; import { SessionButtonColor } from '../components/session/SessionButton'; import { perfEnd, perfStart } from '../session/utils/Performance'; import { getCallMediaPermissionsSettings } from '../components/session/settings/SessionSettings'; +import { forceSyncConfigurationNowIfNeeded } from '../session/utils/syncUtils'; export const getCompleteUrlForV2ConvoId = async (convoId: string) => { if (convoId.match(openGroupV2ConversationIdRegex)) { @@ -115,6 +117,24 @@ export async function unblockConvoById(conversationId: string) { await conversation.commit(); } +/** + * marks the conversation as approved. + */ +export const approveConversation = async (conversationId: string) => { + const conversationToApprove = await getConversationById(conversationId); + + if (!conversationToApprove || conversationToApprove.isApproved()) { + window?.log?.info('Conversation is already approved.'); + return; + } + + await conversationToApprove?.setIsApproved(true); + + if (conversationToApprove?.isApproved() === true) { + await forceSyncConfigurationNowIfNeeded(); + } +}; + export async function showUpdateGroupNameByConvoId(conversationId: string) { const conversation = getConversationController().get(conversationId); if (conversation.isMediumGroup()) { diff --git a/ts/models/conversation.ts b/ts/models/conversation.ts index f70fe9f26..7a95a9979 100644 --- a/ts/models/conversation.ts +++ b/ts/models/conversation.ts @@ -48,6 +48,7 @@ import { import { ed25519Str } from '../session/onions/onionPath'; import { getDecryptedMediaUrl } from '../session/crypto/DecryptedAttachmentsManager'; import { IMAGE_JPEG } from '../types/MIME'; +import { forceSyncConfigurationNowIfNeeded } from '../session/utils/syncUtils'; import { getLatestTimestampOffset } from '../session/snode_api/SNodeAPI'; export enum ConversationTypeEnum { @@ -103,6 +104,7 @@ export interface ConversationAttributes { triggerNotificationsFor: ConversationNotificationSettingType; isTrustedForAttachmentDownload: boolean; isPinned: boolean; + isApproved: boolean; } export interface ConversationAttributesOptionals { @@ -143,6 +145,7 @@ export interface ConversationAttributesOptionals { triggerNotificationsFor?: ConversationNotificationSettingType; isTrustedForAttachmentDownload?: boolean; isPinned: boolean; + isApproved?: boolean; } /** @@ -173,6 +176,7 @@ export const fillConvoAttributesWithDefaults = ( triggerNotificationsFor: 'all', // if the settings is not set in the db, this is the default isTrustedForAttachmentDownload: false, // we don't trust a contact until we say so isPinned: false, + isApproved: false, }); }; @@ -432,6 +436,7 @@ export class ConversationModel extends Backbone.Model { const isBlocked = this.isBlocked(); const subscriberCount = this.get('subscriberCount'); const isPinned = this.isPinned(); + const isApproved = this.isApproved(); const hasNickname = !!this.getNickname(); const isKickedFromGroup = !!this.get('isKickedFromGroup'); const left = !!this.get('left'); @@ -507,6 +512,9 @@ export class ConversationModel extends Backbone.Model { if (isPinned) { toRet.isPinned = isPinned; } + if (isApproved) { + toRet.isApproved = isApproved; + } if (subscriberCount) { toRet.subscriberCount = subscriberCount; } @@ -726,6 +734,13 @@ export class ConversationModel extends Backbone.Model { lokiProfile: UserUtils.getOurProfile(), }; + const updateApprovalNeeded = + !this.isApproved() && (this.isPrivate() || this.isMediumGroup() || this.isClosedGroup()); + if (updateApprovalNeeded) { + await this.setIsApproved(true); + void forceSyncConfigurationNowIfNeeded(); + } + if (this.isOpenGroupV2()) { const chatMessageOpenGroupV2 = new OpenGroupVisibleMessage(chatMessageParams); const roomInfos = this.toOpenGroupV2(); @@ -1017,6 +1032,16 @@ export class ConversationModel extends Backbone.Model { public async addSingleMessage(messageAttributes: MessageAttributesOptionals, setToExpire = true) { const model = new MessageModel(messageAttributes); + const isMe = messageAttributes.source === UserUtils.getOurPubKeyStrFromCache(); + + if ( + isMe && + window.lokiFeatureFlags.useMessageRequests && + window.inboxStore?.getState().userConfig.messageRequests + ) { + await this.setIsApproved(true); + } + // no need to trigger a UI update now, we trigger a messageAdded just below const messageId = await model.commit(false); model.set({ id: messageId }); @@ -1252,6 +1277,17 @@ export class ConversationModel extends Backbone.Model { } } + public async setIsApproved(value: boolean) { + if (value !== this.get('isApproved')) { + window?.log?.info(`Setting ${this.attributes.profileName} isApproved to:: ${value}`); + this.set({ + isApproved: value, + }); + + await this.commit(); + } + } + public async setGroupName(name: string) { const profileName = this.get('name'); if (profileName !== name) { @@ -1359,6 +1395,10 @@ export class ConversationModel extends Backbone.Model { return this.get('isPinned'); } + public isApproved() { + return this.get('isApproved'); + } + public getTitle() { if (this.isPrivate()) { const profileName = this.getProfileName(); diff --git a/ts/receiver/configMessage.ts b/ts/receiver/configMessage.ts index 2a862865a..e396d76a1 100644 --- a/ts/receiver/configMessage.ts +++ b/ts/receiver/configMessage.ts @@ -11,6 +11,7 @@ import { getConversationController } from '../session/conversations'; import { UserUtils } from '../session/utils'; import { toHex } from '../session/utils/String'; import { configurationMessageReceived, trigger } from '../shims/events'; +import { BlockedNumberController } from '../util'; import { removeFromCache } from './cache'; import { handleNewClosedGroup } from './closedGroups'; import { updateProfileOneAtATime } from './dataMessage'; @@ -57,10 +58,14 @@ async function handleGroupsAndContactsFromConfigMessage( (await getItemById(hasSyncedInitialConfigurationItem))?.value || false; if (didWeHandleAConfigurationMessageAlready) { window?.log?.info( - 'Dropping configuration contacts/groups change as we already handled one... ' + '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))); + } return; } + await createOrUpdateItem({ id: 'hasSyncedInitialConfigurationItem', value: true, @@ -109,36 +114,55 @@ async function handleGroupsAndContactsFromConfigMessage( } } if (configMessage.contacts?.length) { - await Promise.all( - configMessage.contacts.map(async c => { - try { - if (!c.publicKey) { - return; - } - const contactConvo = await getConversationController().getOrCreateAndWait( - toHex(c.publicKey), - ConversationTypeEnum.PRIVATE - ); - const profile: SignalService.DataMessage.ILokiProfile = { - displayName: c.name, - profilePicture: c.profilePicture, - }; - // updateProfile will do a commit for us - contactConvo.set('active_at', _.toNumber(envelope.timestamp)); - - await updateProfileOneAtATime(contactConvo, profile, c.profileKey); - } catch (e) { - window?.log?.warn('failed to handle a new closed group from configuration message'); - } - }) - ); + await Promise.all(configMessage.contacts.map(async c => handleContactReceived(c, envelope))); } } +const handleContactReceived = async ( + contactReceived: SignalService.ConfigurationMessage.IContact, + envelope: EnvelopePlus +) => { + try { + if (!contactReceived.publicKey) { + return; + } + const contactConvo = await getConversationController().getOrCreateAndWait( + toHex(contactReceived.publicKey), + ConversationTypeEnum.PRIVATE + ); + const profile = { + displayName: contactReceived.name, + profilePictre: contactReceived.profilePicture, + }; + // updateProfile will do a commit for us + contactConvo.set('active_at', _.toNumber(envelope.timestamp)); + + if ( + window.lokiFeatureFlags.useMessageRequests && + window.inboxStore?.getState().userConfig.messageRequests + ) { + if (contactReceived.isApproved) { + await contactConvo.setIsApproved(Boolean(contactReceived.isApproved)); + } + + if (contactReceived.isBlocked) { + await BlockedNumberController.block(contactConvo.id); + } else { + await BlockedNumberController.unblock(contactConvo.id); + } + } + + void updateProfileOneAtATime(contactConvo, profile, contactReceived.profileKey); + } catch (e) { + window?.log?.warn('failed to handle a new closed group from configuration message'); + } +}; + export async function handleConfigurationMessage( envelope: EnvelopePlus, configurationMessage: SignalService.ConfigurationMessage ): Promise { + window?.log?.info('Handling configuration message'); const ourPubkey = UserUtils.getOurPubKeyStrFromCache(); if (!ourPubkey) { return; diff --git a/ts/receiver/dataMessage.ts b/ts/receiver/dataMessage.ts index f0619d434..03a57c1f7 100644 --- a/ts/receiver/dataMessage.ts +++ b/ts/receiver/dataMessage.ts @@ -28,11 +28,14 @@ export async function updateProfileOneAtATime( } const oneAtaTimeStr = `updateProfileOneAtATime:${conversation.id}`; return allowOnlyOneAtATime(oneAtaTimeStr, async () => { - return updateProfile(conversation, profile, profileKey); + return createOrUpdateProfile(conversation, profile, profileKey); }); } -async function updateProfile( +/** + * Creates a new profile from the profile provided. Creates the profile if it doesn't exist. + */ +async function createOrUpdateProfile( conversation: ConversationModel, profile: SignalService.DataMessage.ILokiProfile, profileKey?: Uint8Array | null // was any diff --git a/ts/receiver/queuedJob.ts b/ts/receiver/queuedJob.ts index b0bbeebe8..274770287 100644 --- a/ts/receiver/queuedJob.ts +++ b/ts/receiver/queuedJob.ts @@ -319,6 +319,11 @@ async function handleRegularMessage( if (type === 'outgoing') { await handleSyncedReceipts(message, conversation); + + if (window.lokiFeatureFlags.useMessageRequests) { + // assumes sync receipts are always from linked device outgoings + await conversation.setIsApproved(true); + } } const conversationActiveAt = conversation.get('active_at'); @@ -469,7 +474,7 @@ export async function handleMessageJob( conversationKey: conversation.id, messageModelProps: message.getMessageModelProps(), }); - trotthledAllMessagesAddedDispatch(); + throttledAllMessagesAddedDispatch(); if (message.get('unread')) { conversation.throttledNotify(message); } @@ -485,7 +490,7 @@ export async function handleMessageJob( } } -const trotthledAllMessagesAddedDispatch = _.throttle(() => { +const throttledAllMessagesAddedDispatch = _.throttle(() => { if (updatesToDispatch.size === 0) { return; } diff --git a/ts/receiver/receiver.ts b/ts/receiver/receiver.ts index b35827ca5..eccf7d339 100644 --- a/ts/receiver/receiver.ts +++ b/ts/receiver/receiver.ts @@ -161,12 +161,11 @@ export function handleRequest(body: any, options: ReqOptions, messageHash: strin incomingMessagePromises.push(promise); } -// tslint:enable:cyclomatic-complexity max-func-body-length */ - -// *********************************************************************** -// *********************************************************************** -// *********************************************************************** +// tslint:enable:cyclomatic-complexity max-func-body-length */ +/** + * Used in background.js + */ export async function queueAllCached() { const items = await getAllFromCache(); items.forEach(async item => { diff --git a/ts/session/conversations/ConversationController.ts b/ts/session/conversations/ConversationController.ts index 6aacc13f3..1de41ac0f 100644 --- a/ts/session/conversations/ConversationController.ts +++ b/ts/session/conversations/ConversationController.ts @@ -236,7 +236,10 @@ export class ConversationController { if (conversation.isPrivate()) { window.log.info(`deleteContact isPrivate, marking as inactive: ${id}`); - conversation.set('active_at', undefined); + conversation.set({ + active_at: undefined, + isApproved: false, + }); await conversation.commit(); } else { window.log.info(`deleteContact !isPrivate, removing convo from DB: ${id}`); diff --git a/ts/session/messages/outgoing/controlMessage/ConfigurationMessage.ts b/ts/session/messages/outgoing/controlMessage/ConfigurationMessage.ts index 82c6845db..d6e5ee656 100644 --- a/ts/session/messages/outgoing/controlMessage/ConfigurationMessage.ts +++ b/ts/session/messages/outgoing/controlMessage/ConfigurationMessage.ts @@ -93,22 +93,30 @@ export class ConfigurationMessageContact { public displayName: string; public profilePictureURL?: string; public profileKey?: Uint8Array; + public isApproved?: boolean; + public isBlocked?: boolean; public constructor({ publicKey, displayName, profilePictureURL, profileKey, + isApproved, + isBlocked, }: { publicKey: string; displayName: string; profilePictureURL?: string; profileKey?: Uint8Array; + isApproved?: boolean; + isBlocked?: boolean; }) { this.publicKey = publicKey; this.displayName = displayName; this.profilePictureURL = profilePictureURL; this.profileKey = profileKey; + this.isApproved = isApproved; + this.isBlocked = isBlocked; // will throw if public key is invalid PubKey.cast(publicKey); @@ -131,6 +139,8 @@ export class ConfigurationMessageContact { name: this.displayName, profilePicture: this.profilePictureURL, profileKey: this.profileKey, + isApproved: this.isApproved, + isBlocked: this.isBlocked, }); } } diff --git a/ts/session/utils/syncUtils.ts b/ts/session/utils/syncUtils.ts index 1ba1b1392..5b7194d68 100644 --- a/ts/session/utils/syncUtils.ts +++ b/ts/session/utils/syncUtils.ts @@ -1,5 +1,6 @@ import { createOrUpdateItem, + getAllConversations, getItemById, getLatestClosedGroupEncryptionKeyPair, } from '../../../ts/data/data'; @@ -37,6 +38,9 @@ const getLastSyncTimestampFromDb = async (): Promise => const writeLastSyncTimestampToDb = async (timestamp: number) => createOrUpdateItem({ id: ITEM_ID_LAST_SYNC_TIMESTAMP, value: timestamp }); +/** + * Conditionally Syncs user configuration with other devices linked. + */ export const syncConfigurationIfNeeded = async () => { const lastSyncedTimestamp = (await getLastSyncTimestampFromDb()) || 0; const now = Date.now(); @@ -46,7 +50,9 @@ export const syncConfigurationIfNeeded = async () => { return; } - const allConvos = getConversationController().getConversations(); + const allConvoCollection = await getAllConversations(); + const allConvos = allConvoCollection.models; + const configMessage = await getCurrentConfigurationMessage(allConvos); try { // window?.log?.info('syncConfigurationIfNeeded with', configMessage); @@ -62,8 +68,8 @@ export const syncConfigurationIfNeeded = async () => { }; export const forceSyncConfigurationNowIfNeeded = async (waitForMessageSent = false) => - new Promise(resolve => { - const allConvos = getConversationController().getConversations(); + new Promise(async resolve => { + const allConvos = (await getAllConversations()).models; // if we hang for more than 10sec, force resolve this promise. setTimeout(() => { @@ -156,7 +162,7 @@ const getValidClosedGroups = async (convos: Array) => { const getValidContacts = (convos: Array) => { // Filter contacts const contactsModels = convos.filter( - c => !!c.get('active_at') && c.getLokiProfile()?.displayName && c.isPrivate() && !c.isBlocked() + c => !!c.get('active_at') && c.getLokiProfile()?.displayName && c.isPrivate() ); const contacts = contactsModels.map(c => { @@ -192,6 +198,8 @@ const getValidContacts = (convos: Array) => { displayName: c.getLokiProfile()?.displayName, profilePictureURL: c.get('avatarPointer'), profileKey: !profileKeyForContact?.length ? undefined : profileKeyForContact, + isApproved: c.isApproved(), + isBlocked: c.isBlocked(), }); } catch (e) { window?.log.warn('getValidContacts', e); @@ -201,7 +209,9 @@ const getValidContacts = (convos: Array) => { return _.compact(contacts); }; -export const getCurrentConfigurationMessage = async (convos: Array) => { +export const getCurrentConfigurationMessage = async ( + convos: Array +): Promise => { const ourPubKey = UserUtils.getOurPubKeyStrFromCache(); const ourConvo = convos.find(convo => convo.id === ourPubKey); diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 177ca87bc..6124189f5 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -252,6 +252,7 @@ export interface ReduxConversationType { currentNotificationSetting?: ConversationNotificationSettingType; isPinned?: boolean; + isApproved?: boolean; } export interface NotificationForConvoOption { diff --git a/ts/state/ducks/userConfig.tsx b/ts/state/ducks/userConfig.tsx index af64b2cb4..57af32fdc 100644 --- a/ts/state/ducks/userConfig.tsx +++ b/ts/state/ducks/userConfig.tsx @@ -7,11 +7,13 @@ import { createSlice } from '@reduxjs/toolkit'; export interface UserConfigState { audioAutoplay: boolean; showRecoveryPhrasePrompt: boolean; + messageRequests: boolean; } export const initialUserConfigState = { audioAutoplay: false, showRecoveryPhrasePrompt: true, + messageRequests: true, }; const userConfigSlice = createSlice({ @@ -24,9 +26,12 @@ const userConfigSlice = createSlice({ disableRecoveryPhrasePrompt: state => { state.showRecoveryPhrasePrompt = false; }, + toggleMessageRequests: state => { + state.messageRequests = !state.messageRequests; + }, }, }); const { actions, reducer } = userConfigSlice; -export const { toggleAudioAutoplay, disableRecoveryPhrasePrompt } = actions; +export const { toggleAudioAutoplay, disableRecoveryPhrasePrompt, toggleMessageRequests } = actions; export const userConfigReducer = reducer; diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index 16016868a..7ee0fe576 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -36,6 +36,7 @@ import { MessageAttachmentSelectorProps } from '../../components/conversation/me import { MessageContentSelectorProps } from '../../components/conversation/message/MessageContent'; import { MessageContentWithStatusSelectorProps } from '../../components/conversation/message/MessageContentWithStatus'; import { GenericReadableMessageSelectorProps } from '../../components/conversation/message/GenericReadableMessage'; +import { getIsMessageRequestsEnabled } from './userConfig'; export const getConversations = (state: StateType): ConversationsStateType => state.conversations; @@ -329,22 +330,61 @@ export const _getConversationComparator = (testingi18n?: LocalizerType) => { export const getConversationComparator = createSelector(getIntl, _getConversationComparator); // export only because we use it in some of our tests +// tslint:disable-next-line: cyclomatic-complexity export const _getLeftPaneLists = ( - lookup: ConversationLookupType, - comparator: (left: ReduxConversationType, right: ReduxConversationType) => number, - selectedConversation?: string + sortedConversations: Array, + isMessageRequestEnabled?: boolean ): { conversations: Array; contacts: Array; unreadCount: number; } => { - const values = Object.values(lookup); - const sorted = values.sort(comparator); - const conversations: Array = []; const directConversations: Array = []; let unreadCount = 0; + for (const conversation of sortedConversations) { + const excludeUnapproved = + isMessageRequestEnabled && window.lokiFeatureFlags?.useMessageRequests; + + if (conversation.activeAt !== undefined && conversation.type === ConversationTypeEnum.PRIVATE) { + directConversations.push(conversation); + } + + if (excludeUnapproved && !conversation.isApproved && !conversation.isBlocked) { + // dont increase unread counter, don't push to convo list. + continue; + } + + if ( + unreadCount < 9 && + conversation.unreadCount && + conversation.unreadCount > 0 && + conversation.currentNotificationSetting !== 'disabled' + ) { + unreadCount += conversation.unreadCount; + } + + conversations.push(conversation); + } + + return { + conversations, + contacts: directConversations, + unreadCount, + }; +}; + +export const _getSortedConversations = ( + lookup: ConversationLookupType, + comparator: (left: ReduxConversationType, right: ReduxConversationType) => number, + selectedConversation?: string +): Array => { + const values = Object.values(lookup); + const sorted = values.sort(comparator); + + const sortedConversations: Array = []; + for (let conversation of sorted) { if (selectedConversation === conversation.id) { conversation = { @@ -352,6 +392,7 @@ export const _getLeftPaneLists = ( isSelected: true, }; } + const isBlocked = BlockedNumberController.isBlocked(conversation.id) || BlockedNumberController.isGroupBlocked(conversation.id); @@ -374,33 +415,39 @@ export const _getLeftPaneLists = ( continue; } - if (conversation.activeAt !== undefined && conversation.type === ConversationTypeEnum.PRIVATE) { - directConversations.push(conversation); - } - - if ( - unreadCount < 9 && - conversation.unreadCount && - conversation.unreadCount > 0 && - conversation.currentNotificationSetting !== 'disabled' - ) { - unreadCount += conversation.unreadCount; - } - - conversations.push(conversation); + sortedConversations.push(conversation); } - return { - conversations, - contacts: directConversations, - unreadCount, - }; + return sortedConversations; }; -export const getLeftPaneLists = createSelector( +export const getSortedConversations = createSelector( getConversationLookup, getConversationComparator, getSelectedConversationKey, + _getSortedConversations +); + +export const _getConversationRequests = ( + sortedConversations: Array, + isMessageRequestEnabled?: boolean +): Array => { + const pushToMessageRequests = + isMessageRequestEnabled && window?.lokiFeatureFlags?.useMessageRequests; + return _.filter(sortedConversations, conversation => { + return pushToMessageRequests && !conversation.isApproved && !conversation.isBlocked; + }); +}; + +export const getConversationRequests = createSelector( + getSortedConversations, + getIsMessageRequestsEnabled, + _getConversationRequests +); + +export const getLeftPaneLists = createSelector( + getSortedConversations, + getIsMessageRequestsEnabled, _getLeftPaneLists ); diff --git a/ts/state/selectors/userConfig.ts b/ts/state/selectors/userConfig.ts index 9c8641cb2..39dd45eba 100644 --- a/ts/state/selectors/userConfig.ts +++ b/ts/state/selectors/userConfig.ts @@ -13,3 +13,8 @@ export const getShowRecoveryPhrasePrompt = createSelector( getUserConfig, (state: UserConfigState): boolean => state.showRecoveryPhrasePrompt ); + +export const getIsMessageRequestsEnabled = createSelector( + getUserConfig, + (state: UserConfigState): boolean => state.messageRequests +); diff --git a/ts/test/session/unit/selectors/conversations_test.ts b/ts/test/session/unit/selectors/conversations_test.ts index 30e5bfb89..d3b8c9677 100644 --- a/ts/test/session/unit/selectors/conversations_test.ts +++ b/ts/test/session/unit/selectors/conversations_test.ts @@ -4,11 +4,11 @@ import { ConversationTypeEnum } from '../../../../models/conversation'; import { ConversationLookupType } from '../../../../state/ducks/conversations'; import { _getConversationComparator, - _getLeftPaneLists, + _getSortedConversations, } from '../../../../state/selectors/conversations'; describe('state/selectors/conversations', () => { - describe('#getLeftPaneList', () => { + describe('#getSortedConversationsList', () => { // tslint:disable-next-line: max-func-body-length it('sorts conversations based on timestamp then by intl-friendly title', () => { const i18n = (key: string) => key; @@ -160,7 +160,7 @@ describe('state/selectors/conversations', () => { }, }; const comparator = _getConversationComparator(i18n); - const { conversations } = _getLeftPaneLists(data, comparator); + const conversations = _getSortedConversations(data, comparator); assert.strictEqual(conversations[0].name, 'First!'); assert.strictEqual(conversations[1].name, 'Á'); @@ -169,7 +169,7 @@ describe('state/selectors/conversations', () => { }); }); - describe('#getLeftPaneListWithPinned', () => { + describe('#getSortedConversationsWithPinned', () => { // tslint:disable-next-line: max-func-body-length it('sorts conversations based on pin, timestamp then by intl-friendly title', () => { const i18n = (key: string) => key; @@ -325,7 +325,7 @@ describe('state/selectors/conversations', () => { }, }; const comparator = _getConversationComparator(i18n); - const { conversations } = _getLeftPaneLists(data, comparator); + const conversations = _getSortedConversations(data, comparator); assert.strictEqual(conversations[0].name, 'Á'); assert.strictEqual(conversations[1].name, 'C'); diff --git a/ts/test/test-utils/utils/message.ts b/ts/test/test-utils/utils/message.ts index b45ac39ef..de5d4c7a6 100644 --- a/ts/test/test-utils/utils/message.ts +++ b/ts/test/test-utils/utils/message.ts @@ -95,6 +95,7 @@ export class MockConversation { triggerNotificationsFor: 'all', isTrustedForAttachmentDownload: false, isPinned: false, + isApproved: false, }; } diff --git a/ts/window.d.ts b/ts/window.d.ts index 3692cb89d..389e29876 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -43,6 +43,7 @@ declare global { log: any; lokiFeatureFlags: { useOnionRequests: boolean; + useMessageRequests: boolean; useCallMessage: boolean; }; lokiSnodeAPI: LokiSnodeAPI;