Merge pull request #2000 from warrickct/message-requests

Message requests
This commit is contained in:
Audric Ackermann 2021-11-29 17:06:26 +11:00 committed by GitHub
commit 56d58a35e5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 574 additions and 93 deletions

View File

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

View File

@ -1612,7 +1612,7 @@ function updateConversation(data) {
members = $members,
name = $name,
profileName = $profileName
WHERE id = $id;`
WHERE id = $id;`
)
.run({
id,

View File

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

View File

@ -39,6 +39,7 @@ window.isBehindProxy = () => Boolean(config.proxyUrl);
window.lokiFeatureFlags = {
useOnionRequests: true,
useMessageRequests: true,
useCallMessage: true,
};

View File

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

View File

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

View File

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

View File

@ -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 (
<div key={key}>
<div
@ -302,6 +317,35 @@ const ConversationListItem = (props: Props) => {
unreadCount={unreadCount || 0}
lastMessage={lastMessage}
/>
{isMessageRequest ? (
<Flex
className="module-conversation-list-item__button-container"
container={true}
flexDirection="row"
justifyContent="flex-end"
>
<SessionIconButton
iconType="exit"
iconSize="large"
onClick={handleConversationBlock}
backgroundColor="var(--color-destructive)"
iconColor="var(--color-foreground-primary)"
iconPadding="var(--margins-xs)"
borderRadius="2px"
/>
<SessionIconButton
iconType="check"
iconSize="large"
onClick={async () => {
await approveConversation(conversationId);
}}
backgroundColor="var(--color-accent)"
iconColor="var(--color-foreground-primary)"
iconPadding="var(--margins-xs)"
borderRadius="2px"
/>
</Flex>
) : null}
</div>
</div>
<Portal>

View File

@ -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 (
<LeftPaneMessageSection
@ -37,6 +38,7 @@ const InnerLeftPaneMessageSection = () => {
contacts={lists?.contacts || []}
searchResults={searchResults}
searchTerm={searchTerm}
messageRequestsEnabled={messageRequestsEnabled}
/>
);
};

View File

@ -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<ReduxConversationType>;
conversations?: Array<ConversationListItemProps>;
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<Props, State> {
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 <MemoConversationListItemWithDetails key={key} style={style} {...conversation} />;
};
@ -96,6 +106,7 @@ export class LeftPaneMessageSection extends React.Component<Props, State> {
}
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<Props, State> {
return (
<div className="session-left-pane-section-content">
{this.renderHeader()}
{overlay ? this.renderClosableOverlay(overlay) : this.renderConversations()}
{overlay ? this.renderClosableOverlay() : this.renderConversations()}
</div>
);
}
@ -157,6 +168,9 @@ export class LeftPaneMessageSection extends React.Component<Props, State> {
onChange={this.updateSearch}
placeholder={window.i18n('searchFor...')}
/>
{window.lokiFeatureFlags.useMessageRequests ? (
<MessageRequestsBanner handleOnClick={this.handleMessageRequestsClick} />
) : null}
{this.renderList()}
{this.renderBottomButtons()}
</div>
@ -201,9 +215,57 @@ export class LeftPaneMessageSection extends React.Component<Props, State> {
);
}
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 = (
<SessionClosableOverlay
@ -251,16 +313,32 @@ export class LeftPaneMessageSection extends React.Component<Props, State> {
/>
);
const messageRequestsElement = (
<SessionClosableOverlay
overlayMode={SessionClosableOverlayType.MessageRequests}
onCloseClick={() => {
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<Props, State> {
buttonType={SessionButtonType.SquareOutline}
buttonColor={SessionButtonColor.Green}
onClick={() => {
this.handleToggleOverlay(SessionComposeToType.OpenGroup);
this.handleToggleOverlay(SessionClosableOverlayType.OpenGroup);
}}
/>
<SessionButton
@ -285,15 +363,15 @@ export class LeftPaneMessageSection extends React.Component<Props, State> {
buttonType={SessionButtonType.SquareOutline}
buttonColor={SessionButtonColor.White}
onClick={() => {
this.handleToggleOverlay(SessionComposeToType.ClosedGroup);
this.handleToggleOverlay(SessionClosableOverlayType.ClosedGroup);
}}
/>
</div>
);
}
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<Props, State> {
}
private handleNewSessionButtonClick() {
this.handleToggleOverlay(SessionComposeToType.Message);
this.handleToggleOverlay(SessionClosableOverlayType.Message);
}
}

View File

@ -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 (
<StyledCircleIcon>
<StyledGridContainer>
<SessionIcon
iconType={iconType}
iconSize={iconSize}
iconColor={'var(--color-text-subtle)'}
/>
</StyledGridContainer>
</StyledCircleIcon>
);
};
export const MessageRequestsBanner = (props: { handleOnClick: () => any }) => {
const { handleOnClick } = props;
const conversationRequests = useSelector(getConversationRequests);
if (!conversationRequests.length) {
return null;
}
return (
<StyledMessageRequestBanner onClick={handleOnClick}>
<CirclularIcon iconType="messageRequest" iconSize="medium" />
<StyledMessageRequestBannerHeader>
{window.i18n('messageRequests')}
</StyledMessageRequestBannerHeader>
<StyledUnreadCounter>
<div>{conversationRequests.length || 0}</div>
</StyledUnreadCounter>
</StyledMessageRequestBanner>
);
};

View File

@ -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<ReduxConversationType>;
@ -106,6 +110,7 @@ export class SessionClosableOverlay extends React.Component<Props, State> {
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<Props, State> {
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<Props, State> {
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<Props, State> {
onPressEnter={() => onButtonClick(groupName, selectedMembers)}
/>
</div>
) : (
) : null}
{isMessageView ? (
<SessionIdEditable
ref={this.inputRef}
editable={!showLoadingSpinner}
placeholder={placeholder}
onChange={onChangeSessionID}
/>
)}
) : null}
{isMessageRequestView ? (
<>
<SpacerLG />
<MessageRequestList />
<SpacerLG />
</>
) : null}
<SessionSpinner loading={showLoadingSpinner} />
@ -266,3 +286,24 @@ export class SessionClosableOverlay extends React.Component<Props, State> {
}
}
}
/**
* 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 (
<div className="message-request-list__container">
{conversationRequests.map(conversation => {
return (
<MemoConversationListItemWithDetails
key={conversation.id}
isMessageRequest={true}
{...conversation}
/>
);
})}
</div>
);
};

View File

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

View File

@ -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))}
/>
<SessionToggleWithDescription
onClickToggle={() => {
dispatch(toggleMessageRequests());
}}
title={window.i18n('messageRequests')}
description={window.i18n('messageRequestsDescription')}
active={useSelector(getIsMessageRequestsEnabled)}
/>
{!props.hasPassword && (
<SessionSettingButtonItem
title={window.i18n('setAccountPasswordTitle')}

View File

@ -521,7 +521,8 @@ export async function getConversationById(id: string): Promise<ConversationModel
}
export async function updateConversation(data: ReduxConversationType): Promise<void> {
await channels.updateConversation(data);
const cleanedData = _cleanData(data);
await channels.updateConversation(cleanedData);
}
export async function removeConversation(id: string): Promise<void> {
@ -600,7 +601,6 @@ export async function cleanLastHashes(): Promise<void> {
await channels.cleanLastHashes();
}
// TODO: Strictly type the following
export async function saveSeenMessageHashes(
data: Array<{
expiresAt: number;

View File

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

View File

@ -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<ConversationAttributes> {
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<ConversationAttributes> {
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<ConversationAttributes> {
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<ConversationAttributes> {
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<ConversationAttributes> {
}
}
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<ConversationAttributes> {
return this.get('isPinned');
}
public isApproved() {
return this.get('isApproved');
}
public getTitle() {
if (this.isPrivate()) {
const profileName = this.getProfileName();

View File

@ -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<void> {
window?.log?.info('Handling configuration message');
const ourPubkey = UserUtils.getOurPubKeyStrFromCache();
if (!ourPubkey) {
return;

View File

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

View File

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

View File

@ -161,12 +161,11 @@ export function handleRequest(body: any, options: ReqOptions, messageHash: strin
incomingMessagePromises.push(promise);
}
// tslint:enable:cyclomatic-complexity max-func-body-length */
// ***********************************************************************
// ***********************************************************************
// ***********************************************************************
/**
* Used in background.js
*/
export async function queueAllCached() {
const items = await getAllFromCache();
items.forEach(async item => {

View File

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

View File

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

View File

@ -1,5 +1,6 @@
import {
createOrUpdateItem,
getAllConversations,
getItemById,
getLatestClosedGroupEncryptionKeyPair,
} from '../../../ts/data/data';
@ -37,6 +38,9 @@ const getLastSyncTimestampFromDb = async (): Promise<number | undefined> =>
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<ConversationModel>) => {
const getValidContacts = (convos: Array<ConversationModel>) => {
// 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<ConversationModel>) => {
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<ConversationModel>) => {
return _.compact(contacts);
};
export const getCurrentConfigurationMessage = async (convos: Array<ConversationModel>) => {
export const getCurrentConfigurationMessage = async (
convos: Array<ConversationModel>
): Promise<ConfigurationMessage> => {
const ourPubKey = UserUtils.getOurPubKeyStrFromCache();
const ourConvo = convos.find(convo => convo.id === ourPubKey);

View File

@ -252,6 +252,7 @@ export interface ReduxConversationType {
currentNotificationSetting?: ConversationNotificationSettingType;
isPinned?: boolean;
isApproved?: boolean;
}
export interface NotificationForConvoOption {

View File

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

View File

@ -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<ReduxConversationType>,
isMessageRequestEnabled?: boolean
): {
conversations: Array<ReduxConversationType>;
contacts: Array<ReduxConversationType>;
unreadCount: number;
} => {
const values = Object.values(lookup);
const sorted = values.sort(comparator);
const conversations: Array<ReduxConversationType> = [];
const directConversations: Array<ReduxConversationType> = [];
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<ReduxConversationType> => {
const values = Object.values(lookup);
const sorted = values.sort(comparator);
const sortedConversations: Array<ReduxConversationType> = [];
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<ReduxConversationType>,
isMessageRequestEnabled?: boolean
): Array<ReduxConversationType> => {
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
);

View File

@ -13,3 +13,8 @@ export const getShowRecoveryPhrasePrompt = createSelector(
getUserConfig,
(state: UserConfigState): boolean => state.showRecoveryPhrasePrompt
);
export const getIsMessageRequestsEnabled = createSelector(
getUserConfig,
(state: UserConfigState): boolean => state.messageRequests
);

View File

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

View File

@ -95,6 +95,7 @@ export class MockConversation {
triggerNotificationsFor: 'all',
isTrustedForAttachmentDownload: false,
isPinned: false,
isApproved: false,
};
}

1
ts/window.d.ts vendored
View File

@ -43,6 +43,7 @@ declare global {
log: any;
lokiFeatureFlags: {
useOnionRequests: boolean;
useMessageRequests: boolean;
useCallMessage: boolean;
};
lokiSnodeAPI: LokiSnodeAPI;