Merge pull request #1770 from Brice-W/pin-conversations

Pin conversations
pull/1779/head
Audric Ackermann 4 years ago committed by GitHub
commit bf76abacee
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -409,5 +409,9 @@
"audioMessageAutoplayDescription": "Automatically play consecutively sent audio messages", "audioMessageAutoplayDescription": "Automatically play consecutively sent audio messages",
"clickToTrustContact": "Click to download media", "clickToTrustContact": "Click to download media",
"trustThisContactDialogTitle": "Trust $name$?", "trustThisContactDialogTitle": "Trust $name$?",
"trustThisContactDialogDescription": "Are you sure you want to download media sent by $name$?" "trustThisContactDialogDescription": "Are you sure you want to download media sent by $name$?",
"pinConversation": "Pin Conversation",
"unpinConversation": "Unpin Conversation",
"pinConversationLimitTitle": "Pinned conversations limit",
"pinConversationLimitToastDescription": "You can only pin $number$ conversations"
} }

@ -390,5 +390,9 @@
"errorHappenedWhileRemovingModeratorDesc": "Une erreur est survenue lors de la suppression de cet utilisateur de la liste des modérateurs.", "errorHappenedWhileRemovingModeratorDesc": "Une erreur est survenue lors de la suppression de cet utilisateur de la liste des modérateurs.",
"orJoinOneOfThese": "Ou rejoignez un de ceux-ci...", "orJoinOneOfThese": "Ou rejoignez un de ceux-ci...",
"helpUsTranslateSession": "Help us Translate Session", "helpUsTranslateSession": "Help us Translate Session",
"translation": "Translation" "translation": "Translation",
"pinConversation": "Épingler la conversation",
"unpinConversation": "Défaire la conversation",
"pinConversationLimitTitle": "Limite de conversations épinglées",
"pinConversationLimitToastDescription": "Vous ne pouvez pas épingler plus de $number$ conversations"
} }

@ -297,10 +297,6 @@
window.addEventListener('focus', () => Whisper.Notifications.clear()); window.addEventListener('focus', () => Whisper.Notifications.clear());
window.addEventListener('unload', () => Whisper.Notifications.fastClear()); window.addEventListener('unload', () => Whisper.Notifications.fastClear());
window.showResetSessionIdDialog = () => {
appView.showResetSessionIdDialog();
};
// Set user's launch count. // Set user's launch count.
const prevLaunchCount = window.getSettingValue('launch-count'); const prevLaunchCount = window.getSettingValue('launch-count');
const launchCount = !prevLaunchCount ? 1 : prevLaunchCount + 1; const launchCount = !prevLaunchCount ? 1 : prevLaunchCount + 1;

@ -94,16 +94,5 @@
window.focus(); // FIXME window.focus(); // FIXME
return Promise.resolve(); return Promise.resolve();
}, },
showResetSessionIdDialog() {
const theme = this.getThemeObject();
const resetSessionIDDialog = new Whisper.SessionIDResetDialog({ theme });
this.el.prepend(resetSessionIDDialog.el);
},
getThemeObject() {
const themeSettings = storage.get('theme-setting') || 'light';
const theme = themeSettings === 'light' ? window.lightTheme : window.darkTheme;
return theme;
},
}); });
})(); })();

@ -56,6 +56,7 @@ window.lokiFeatureFlags = {
useFileOnionRequests: true, useFileOnionRequests: true,
useFileOnionRequestsV2: true, // more compact encoding of files in response useFileOnionRequestsV2: true, // more compact encoding of files in response
padOutgoingAttachments: true, padOutgoingAttachments: true,
enablePinConversations: false,
}; };
if (typeof process.env.NODE_ENV === 'string' && process.env.NODE_ENV.includes('test-integration')) { if (typeof process.env.NODE_ENV === 'string' && process.env.NODE_ENV.includes('test-integration')) {

@ -19,9 +19,17 @@ import {
} from './session/menu/ConversationListItemContextMenu'; } from './session/menu/ConversationListItemContextMenu';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { OutgoingMessageStatus } from './conversation/message/OutgoingMessageStatus'; import { OutgoingMessageStatus } from './conversation/message/OutgoingMessageStatus';
import { DefaultTheme, withTheme } from 'styled-components'; import { DefaultTheme, useTheme, withTheme } from 'styled-components';
import { PubKey } from '../session/types'; import { PubKey } from '../session/types';
import { ConversationType, openConversationExternal } from '../state/ducks/conversations'; import {
ConversationType,
LastMessageType,
openConversationExternal,
} from '../state/ducks/conversations';
import { SessionIcon, SessionIconSize, SessionIconType } from './session/icon';
import { useDispatch, useSelector } from 'react-redux';
import { SectionType } from '../state/ducks/section';
import { getFocusedSection } from '../state/selectors/section';
export interface ConversationListItemProps extends ConversationType { export interface ConversationListItemProps extends ConversationType {
index?: number; // used to force a refresh when one conversation is removed on top of the list index?: number; // used to force a refresh when one conversation is removed on top of the list
@ -39,183 +47,270 @@ const Portal = ({ children }: { children: any }) => {
return createPortal(children, document.querySelector('.inbox.index') as Element); return createPortal(children, document.querySelector('.inbox.index') as Element);
}; };
class ConversationListItem extends React.PureComponent<Props> { const ConversationListItem = (props: Props) => {
public constructor(props: Props) { const {
super(props); phoneNumber,
} unreadCount,
id,
public renderAvatar() { isSelected,
const { avatarPath, name, phoneNumber, profileName, memberAvatars } = this.props; isBlocked,
style,
mentionedUs,
avatarPath,
name,
profileName,
activeAt,
isMe,
isPinned,
isTyping,
type,
lastMessage,
memberAvatars,
} = props;
const triggerId: string = `conversation-item-${phoneNumber}-ctxmenu`;
const key: string = `conversation-item-${phoneNumber}`;
const userName = name || profileName || phoneNumber; const dispatch = useDispatch();
return ( return (
<div className="module-conversation-list-item__avatar-container"> <div key={key}>
<Avatar <div
role="button"
onClick={() => {
dispatch(openConversationExternal(id));
}}
onContextMenu={(e: any) => {
contextMenu.show({
id: triggerId,
event: e,
});
}}
style={style}
className={classNames(
'module-conversation-list-item',
unreadCount > 0 ? 'module-conversation-list-item--has-unread' : null,
unreadCount > 0 && mentionedUs ? 'module-conversation-list-item--mentioned-us' : null,
isSelected ? 'module-conversation-list-item--is-selected' : null,
isBlocked ? 'module-conversation-list-item--is-blocked' : null
)}
>
<ConversationListItemAvatar
avatarPath={avatarPath} avatarPath={avatarPath}
name={userName} name={name}
size={AvatarSize.S} profileName={profileName}
memberAvatars={memberAvatars} memberAvatars={memberAvatars}
pubkey={phoneNumber}
/> />
<div className="module-conversation-list-item__content">
<ConversationListItemHeader
unreadCount={unreadCount}
mentionedUs={mentionedUs}
activeAt={activeAt}
isPinned={isPinned}
name={name}
phoneNumber={phoneNumber}
profileName={profileName}
isMe={isMe}
/>
<ConversationListItemMessage
lastMessage={lastMessage}
isTyping={isTyping}
unreadCount={unreadCount}
/>
</div>
</div> </div>
); <Portal>
} <ConversationListItemContextMenu id={id} triggerId={triggerId} type={type} isMe={isMe} />
</Portal>
</div>
);
};
public renderHeader() { export interface ConversationListItemAvatarProps {
const { unreadCount, mentionedUs, activeAt } = this.props; avatarPath?: string;
name?: string;
profileName?: string;
phoneNumber?: string;
memberAvatars?: Array<ConversationAvatar>;
}
let atSymbol = null; export const ConversationListItemAvatar = (props: ConversationListItemAvatarProps) => {
let unreadCountDiv = null; const { avatarPath, name, phoneNumber, profileName, memberAvatars } = props;
if (unreadCount > 0) { const userName = name || profileName || phoneNumber;
atSymbol = mentionedUs ? <p className="at-symbol">@</p> : null;
unreadCountDiv = <p className="module-conversation-list-item__unread-count">{unreadCount}</p>;
}
return ( return (
<div className="module-conversation-list-item__header"> <div className="module-conversation-list-item__avatar-container">
<div <Avatar
className={classNames( avatarPath={avatarPath}
'module-conversation-list-item__header__name', name={userName}
unreadCount > 0 ? 'module-conversation-list-item__header__name--with-unread' : null size={AvatarSize.S}
)} memberAvatars={memberAvatars}
> pubkey={phoneNumber}
{this.renderUser()} />
</div> </div>
{unreadCountDiv} );
{atSymbol} };
{
<div
className={classNames(
'module-conversation-list-item__header__date',
unreadCount > 0 ? 'module-conversation-list-item__header__date--has-unread' : null
)}
>
{
<Timestamp
timestamp={activeAt}
extended={false}
isConversationListItem={true}
theme={this.props.theme}
/>
}
</div>
}
</div>
);
}
public renderMessage() { export interface ConversationListItemHeaderProps {
const { lastMessage, isTyping, unreadCount } = this.props; unreadCount: number;
mentionedUs: boolean;
activeAt?: number;
isPinned: boolean;
if (!lastMessage && !isTyping) { name?: string;
return null; phoneNumber: string;
} profileName?: string;
const text = lastMessage && lastMessage.text ? lastMessage.text : ''; isMe: boolean;
}
if (isEmpty(text)) { export const ConversationListItemHeader = (props: ConversationListItemHeaderProps) => {
return null; const {
} unreadCount,
mentionedUs,
activeAt,
isPinned,
name,
phoneNumber,
profileName,
isMe,
} = props;
return ( const theme = useTheme();
<div className="module-conversation-list-item__message">
<div let atSymbol = null;
className={classNames( let unreadCountDiv = null;
'module-conversation-list-item__message__text', if (unreadCount > 0) {
unreadCount > 0 ? 'module-conversation-list-item__message__text--has-unread' : null atSymbol = mentionedUs ? <p className="at-symbol">@</p> : null;
)} unreadCountDiv = <p className="module-conversation-list-item__unread-count">{unreadCount}</p>;
>
{isTyping ? (
<TypingAnimation />
) : (
<MessageBody isGroup={true} text={text} disableJumbomoji={true} disableLinks={true} />
)}
</div>
{lastMessage && lastMessage.status ? (
<OutgoingMessageStatus
status={lastMessage.status}
iconColor={this.props.theme.colors.textColorSubtle}
theme={this.props.theme}
/>
) : null}
</div>
);
} }
public render() { const isMessagesSection = useSelector(getFocusedSection) === SectionType.Message;
const { phoneNumber, unreadCount, id, isSelected, isBlocked, style, mentionedUs } = this.props;
const triggerId = `conversation-item-${phoneNumber}-ctxmenu`; const pinIcon =
const key = `conversation-item-${phoneNumber}`; isMessagesSection && isPinned ? (
<SessionIcon
iconType={SessionIconType.Pin}
iconColor={theme.colors.textColorSubtle}
iconSize={SessionIconSize.Tiny}
/>
) : null;
return ( return (
<div key={key}> <div className="module-conversation-list-item__header">
<div
className={classNames(
'module-conversation-list-item__header__name',
unreadCount > 0 ? 'module-conversation-list-item__header__name--with-unread' : null
)}
>
<ConversationListItemUser
name={name}
phoneNumber={phoneNumber}
profileName={profileName}
isMe={isMe}
/>
</div>
{pinIcon}
{unreadCountDiv}
{atSymbol}
{
<div <div
role="button"
onClick={() => {
window.inboxStore?.dispatch(openConversationExternal(id));
}}
onContextMenu={(e: any) => {
contextMenu.show({
id: triggerId,
event: e,
});
}}
style={style}
className={classNames( className={classNames(
'module-conversation-list-item', 'module-conversation-list-item__header__date',
unreadCount > 0 ? 'module-conversation-list-item--has-unread' : null, unreadCount > 0 ? 'module-conversation-list-item__header__date--has-unread' : null
unreadCount > 0 && mentionedUs ? 'module-conversation-list-item--mentioned-us' : null,
isSelected ? 'module-conversation-list-item--is-selected' : null,
isBlocked ? 'module-conversation-list-item--is-blocked' : null
)} )}
> >
{this.renderAvatar()} {
<div className="module-conversation-list-item__content"> <Timestamp
{this.renderHeader()} timestamp={activeAt}
{this.renderMessage()} extended={false}
</div> isConversationListItem={true}
theme={theme}
/>
}
</div> </div>
<Portal> }
<ConversationListItemContextMenu {...this.getMenuProps(triggerId)} /> </div>
</Portal> );
</div> };
);
export interface ConversationListMessageProps {
lastMessage: LastMessageType;
isTyping: boolean;
unreadCount: number;
}
export const ConversationListItemMessage = (props: any) => {
const { lastMessage, isTyping, unreadCount } = props;
const theme = useTheme();
if (!lastMessage && !isTyping) {
return null;
} }
const text = lastMessage && lastMessage.text ? lastMessage.text : '';
private getMenuProps(triggerId: string): PropsContextConversationItem { if (isEmpty(text)) {
return { return null;
triggerId,
...this.props,
};
} }
private renderUser() { return (
const { name, phoneNumber, profileName, isMe } = this.props; <div className="module-conversation-list-item__message">
<div
className={classNames(
'module-conversation-list-item__message__text',
unreadCount > 0 ? 'module-conversation-list-item__message__text--has-unread' : null
)}
>
{isTyping ? (
<TypingAnimation />
) : (
<MessageBody isGroup={true} text={text} disableJumbomoji={true} disableLinks={true} />
)}
</div>
{lastMessage && lastMessage.status ? (
<OutgoingMessageStatus
status={lastMessage.status}
iconColor={theme.colors.textColorSubtle}
theme={theme}
/>
) : null}
</div>
);
};
const shortenedPubkey = PubKey.shorten(phoneNumber); export interface ConversationListItemUserProps {
name?: string;
phoneNumber: string;
profileName?: string;
isMe: boolean;
}
const displayedPubkey = profileName ? shortenedPubkey : phoneNumber; export const ConversationListItemUser = (props: ConversationListItemUserProps) => {
const displayName = isMe ? window.i18n('noteToSelf') : profileName; const { name, phoneNumber, profileName, isMe } = props;
let shouldShowPubkey = false; const shortenedPubkey = PubKey.shorten(phoneNumber);
if ((!name || name.length === 0) && (!displayName || displayName.length === 0)) {
shouldShowPubkey = true;
}
return ( const displayedPubkey = profileName ? shortenedPubkey : phoneNumber;
<div className="module-conversation__user"> const displayName = isMe ? window.i18n('noteToSelf') : profileName;
<ContactName
phoneNumber={displayedPubkey} let shouldShowPubkey = false;
name={name} if ((!name || name.length === 0) && (!displayName || displayName.length === 0)) {
profileName={displayName} shouldShowPubkey = true;
module="module-conversation__user"
boldProfileName={true}
shouldShowPubkey={shouldShowPubkey}
/>
</div>
);
} }
}
return (
<div className="module-conversation__user">
<ContactName
phoneNumber={displayedPubkey}
name={name}
profileName={displayName}
module="module-conversation__user"
boldProfileName={true}
shouldShowPubkey={shouldShowPubkey}
/>
</div>
);
};
export const ConversationListItemWithDetails = usingClosedConversationDetails( export const ConversationListItemWithDetails = usingClosedConversationDetails(
withTheme(ConversationListItem) withTheme(ConversationListItem)

@ -1,6 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { ActionsPanel, SectionType } from './session/ActionsPanel'; import { ActionsPanel } from './session/ActionsPanel';
import { LeftPaneMessageSection } from './session/LeftPaneMessageSection'; import { LeftPaneMessageSection } from './session/LeftPaneMessageSection';
import { openConversationExternal } from '../state/ducks/conversations'; import { openConversationExternal } from '../state/ducks/conversations';
@ -13,6 +13,7 @@ import { useDispatch, useSelector } from 'react-redux';
import { getLeftPaneLists } from '../state/selectors/conversations'; import { getLeftPaneLists } from '../state/selectors/conversations';
import { getQuery, getSearchResults, isSearching } from '../state/selectors/search'; import { getQuery, getSearchResults, isSearching } from '../state/selectors/search';
import { clearSearch, search, updateSearchTerm } from '../state/ducks/search'; import { clearSearch, search, updateSearchTerm } from '../state/ducks/search';
import { SectionType } from '../state/ducks/section';
import { getTheme } from '../state/selectors/theme'; import { getTheme } from '../state/selectors/theme';
// from https://github.com/bvaughn/react-virtualized/blob/fb3484ed5dcc41bffae8eab029126c0fb8f7abc0/source/List/types.js#L5 // from https://github.com/bvaughn/react-virtualized/blob/fb3484ed5dcc41bffae8eab029126c0fb8f7abc0/source/List/types.js#L5

@ -30,7 +30,7 @@ import { applyTheme } from '../../state/ducks/theme';
import { getFocusedSection } from '../../state/selectors/section'; import { getFocusedSection } from '../../state/selectors/section';
import { useInterval } from '../../hooks/useInterval'; import { useInterval } from '../../hooks/useInterval';
import { clearSearch } from '../../state/ducks/search'; import { clearSearch } from '../../state/ducks/search';
import { showLeftPaneSection } from '../../state/ducks/section'; import { SectionType, showLeftPaneSection } from '../../state/ducks/section';
import { cleanUpOldDecryptedMedias } from '../../session/crypto/DecryptedAttachmentsManager'; import { cleanUpOldDecryptedMedias } from '../../session/crypto/DecryptedAttachmentsManager';
import { getOpenGroupManager } from '../../opengroup/opengroupV2/OpenGroupManagerV2'; import { getOpenGroupManager } from '../../opengroup/opengroupV2/OpenGroupManagerV2';
@ -47,16 +47,6 @@ import { ActionPanelOnionStatusLight } from '../OnionStatusPathDialog';
// tslint:disable-next-line: no-import-side-effect no-submodule-imports // tslint:disable-next-line: no-import-side-effect no-submodule-imports
export enum SectionType {
Profile,
Message,
Contact,
Channel,
Settings,
Moon,
PathIndicator,
}
const Section = (props: { type: SectionType; avatarPath?: string }) => { const Section = (props: { type: SectionType; avatarPath?: string }) => {
const ourNumber = useSelector(getOurNumber); const ourNumber = useSelector(getOurNumber);
const unreadMessageCount = useSelector(getUnreadMessageCount); const unreadMessageCount = useSelector(getUnreadMessageCount);
@ -143,15 +133,6 @@ const Section = (props: { type: SectionType; avatarPath?: string }) => {
); );
}; };
const showResetSessionIDDialogIfNeeded = async () => {
const userED25519KeyPairHex = await UserUtils.getUserED25519KeyPair();
if (userED25519KeyPairHex) {
return;
}
window.showResetSessionIdDialog();
};
const cleanUpMediasInterval = DURATION.MINUTES * 30; const cleanUpMediasInterval = DURATION.MINUTES * 30;
const setupTheme = () => { const setupTheme = () => {
@ -230,7 +211,6 @@ const doAppStartUp = () => {
void setupTheme(); void setupTheme();
// keep that one to make sure our users upgrade to new sessionIDS // keep that one to make sure our users upgrade to new sessionIDS
void showResetSessionIDDialogIfNeeded();
void removeAllV1OpenGroups(); void removeAllV1OpenGroups();
// this generates the key to encrypt attachments locally // this generates the key to encrypt attachments locally

@ -1,7 +1,5 @@
import React from 'react'; import React from 'react';
import { AutoSizer, List } from 'react-virtualized'; import { AutoSizer, List } from 'react-virtualized';
import { MainViewController } from '../MainViewController'; import { MainViewController } from '../MainViewController';
import { import {
ConversationListItemProps, ConversationListItemProps,
@ -10,7 +8,7 @@ import {
import { ConversationType as ReduxConversationType } from '../../state/ducks/conversations'; import { ConversationType as ReduxConversationType } from '../../state/ducks/conversations';
import { SearchResults, SearchResultsProps } from '../SearchResults'; import { SearchResults, SearchResultsProps } from '../SearchResults';
import { SessionSearchInput } from './SessionSearchInput'; import { SessionSearchInput } from './SessionSearchInput';
import { debounce } from 'lodash'; import _, { debounce } from 'lodash';
import { cleanSearchTerm } from '../../util/cleanSearchTerm'; import { cleanSearchTerm } from '../../util/cleanSearchTerm';
import { SearchOptions } from '../../types/Search'; import { SearchOptions } from '../../types/Search';
import { RowRendererParamsType } from '../LeftPane'; import { RowRendererParamsType } from '../LeftPane';
@ -30,8 +28,6 @@ import autoBind from 'auto-bind';
import { onsNameRegex } from '../../session/snode_api/SNodeAPI'; import { onsNameRegex } from '../../session/snode_api/SNodeAPI';
import { SNodeAPI } from '../../session/snode_api'; import { SNodeAPI } from '../../session/snode_api';
import { createClosedGroup } from '../../receiver/closedGroups';
export interface Props { export interface Props {
searchTerm: string; searchTerm: string;

@ -31,10 +31,10 @@ import { getConversationController } from '../../../session/conversations';
import { ConversationType } from '../../../state/ducks/conversations'; import { ConversationType } from '../../../state/ducks/conversations';
import { SessionMemberListItem } from '../SessionMemberListItem'; import { SessionMemberListItem } from '../SessionMemberListItem';
import autoBind from 'auto-bind'; import autoBind from 'auto-bind';
import { SectionType } from '../ActionsPanel';
import { SessionSettingCategory } from '../settings/SessionSettings'; import { SessionSettingCategory } from '../settings/SessionSettings';
import { getMentionsInput } from '../../../state/selectors/mentionsInput'; import { getMentionsInput } from '../../../state/selectors/mentionsInput';
import { updateConfirmModal } from '../../../state/ducks/modalDialog'; import { updateConfirmModal } from '../../../state/ducks/modalDialog';
import { SectionType } from '../../../state/ducks/section';
import { SessionButtonColor } from '../SessionButton'; import { SessionButtonColor } from '../SessionButton';
import { SessionConfirmDialogProps } from '../SessionConfirm'; import { SessionConfirmDialogProps } from '../SessionConfirm';
import { import {

@ -25,6 +25,7 @@ export enum SessionIconType {
Moon = 'moon', Moon = 'moon',
Pause = 'pause', Pause = 'pause',
Pencil = 'pencil', Pencil = 'pencil',
Pin = 'pin',
Play = 'play', Play = 'play',
Plus = 'plus', Plus = 'plus',
Reply = 'reply', Reply = 'reply',
@ -224,6 +225,12 @@ export const icons = {
viewBox: '1 1 21 21', viewBox: '1 1 21 21',
ratio: 1, ratio: 1,
}, },
[SessionIconType.Pin]: {
path:
'M83.88.451L122.427 39c.603.601.603 1.585 0 2.188l-13.128 13.125c-.602.604-1.586.604-2.187 0l-3.732-3.73-17.303 17.3c3.882 14.621.095 30.857-11.37 42.32-.266.268-.535.529-.808.787-1.004.955-.843.949-1.813-.021L47.597 86.48 0 122.867l36.399-47.584L11.874 50.76c-.978-.98-.896-.826.066-1.837.24-.251.485-.503.734-.753C24.137 36.707 40.376 32.917 54.996 36.8l17.301-17.3-3.733-3.732c-.601-.601-.601-1.585 0-2.188L81.691.451c.604-.601 1.588-.601 2.189 0z',
viewBox: '0 0 122.879 122.867',
ratio: 1,
},
[SessionIconType.Play]: { [SessionIconType.Play]: {
path: path:
'M29.462,15.707c0,1.061-0.562,2.043-1.474,2.583L6.479,30.999c-0.47,0.275-0.998,0.417-1.526,0.417 c-0.513,0-1.026-0.131-1.487-0.396c-0.936-0.534-1.513-1.527-1.513-2.604V2.998c0-1.077,0.578-2.07,1.513-2.605 C4.402-0.139,5.553-0.13,6.479,0.415l21.509,12.709C28.903,13.664,29.462,14.646,29.462,15.707z', 'M29.462,15.707c0,1.061-0.562,2.043-1.474,2.583L6.479,30.999c-0.47,0.275-0.998,0.417-1.526,0.417 c-0.513,0-1.026-0.131-1.487-0.396c-0.936-0.534-1.513-1.527-1.513-2.604V2.998c0-1.077,0.578-2.07,1.513-2.605 C4.402-0.139,5.553-0.13,6.479,0.415l21.509,12.709C28.903,13.664,29.462,14.646,29.462,15.707z',

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React from 'react';
import { animation, Menu } from 'react-contexify'; import { animation, Menu } from 'react-contexify';
import { ConversationTypeEnum } from '../../../models/conversation'; import { ConversationTypeEnum } from '../../../models/conversation';
@ -12,6 +12,7 @@ import {
getInviteContactMenuItem, getInviteContactMenuItem,
getLeaveGroupMenuItem, getLeaveGroupMenuItem,
getMarkAllReadMenuItem, getMarkAllReadMenuItem,
getPinConversationMenuItem,
} from './Menu'; } from './Menu';
export type PropsContextConversationItem = { export type PropsContextConversationItem = {
@ -38,7 +39,6 @@ export const ConversationListItemContextMenu = (props: PropsContextConversationI
type, type,
left, left,
isKickedFromGroup, isKickedFromGroup,
theme,
} = props; } = props;
const isGroup = type === 'group'; const isGroup = type === 'group';
@ -46,6 +46,7 @@ export const ConversationListItemContextMenu = (props: PropsContextConversationI
return ( return (
<> <>
<Menu id={triggerId} animation={animation.fade}> <Menu id={triggerId} animation={animation.fade}>
{getPinConversationMenuItem(conversationId)}
{getBlockMenuItem(isMe, type === ConversationTypeEnum.PRIVATE, isBlocked, conversationId)} {getBlockMenuItem(isMe, type === ConversationTypeEnum.PRIVATE, isBlocked, conversationId)}
{getCopyMenuItem(isPublic, isGroup, conversationId)} {getCopyMenuItem(isPublic, isGroup, conversationId)}
{getMarkAllReadMenuItem(conversationId)} {getMarkAllReadMenuItem(conversationId)}

@ -1,14 +1,13 @@
import React from 'react'; import React from 'react';
import { getNumberOfPinnedConversations } from '../../../state/selectors/conversations';
import { getFocusedSection } from '../../../state/selectors/section';
import { NotificationForConvoOption, TimerOption } from '../../conversation/ConversationHeader'; import { NotificationForConvoOption, TimerOption } from '../../conversation/ConversationHeader';
import { Item, Submenu } from 'react-contexify'; import { Item, Submenu } from 'react-contexify';
import { ConversationNotificationSettingType } from '../../../models/conversation'; import { ConversationNotificationSettingType } from '../../../models/conversation';
import { useDispatch } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { import { changeNickNameModal, updateConfirmModal } from '../../../state/ducks/modalDialog';
adminLeaveClosedGroup, import { SectionType } from '../../../state/ducks/section';
changeNickNameModal,
updateConfirmModal,
} from '../../../state/ducks/modalDialog';
import { getConversationController } from '../../../session/conversations'; import { getConversationController } from '../../../session/conversations';
import { import {
blockConvoById, blockConvoById,
@ -26,6 +25,9 @@ import {
unblockConvoById, unblockConvoById,
} from '../../../interactions/conversationInteractions'; } from '../../../interactions/conversationInteractions';
import { SessionButtonColor } from '../SessionButton'; import { SessionButtonColor } from '../SessionButton';
import { ToastUtils } from '../../../session/utils';
const maxNumberOfPinnedConversations = 5;
function showTimerOptions( function showTimerOptions(
isPublic: boolean, isPublic: boolean,
@ -126,6 +128,35 @@ export function getInviteContactMenuItem(
return null; return null;
} }
export interface PinConversationMenuItemProps {
conversationId: string;
}
export const getPinConversationMenuItem = (conversationId: string): JSX.Element | null => {
const isMessagesSection = useSelector(getFocusedSection) === SectionType.Message;
if (isMessagesSection && window.lokiFeatureFlags.enablePinConversations) {
const conversation = getConversationController().get(conversationId);
const isPinned = conversation.isPinned();
const nbOfAlreadyPinnedConvos = useSelector(getNumberOfPinnedConversations);
const togglePinConversation = async () => {
if ((!isPinned && nbOfAlreadyPinnedConvos < maxNumberOfPinnedConversations) || isPinned) {
await conversation.setIsPinned(!isPinned);
} else {
ToastUtils.pushToastWarning(
'pinConversationLimitToast',
window.i18n('pinConversationLimitTitle'),
window.i18n('pinConversationLimitToastDescription', maxNumberOfPinnedConversations)
);
}
};
const menuText = isPinned ? window.i18n('unpinConversation') : window.i18n('pinConversation');
return <Item onClick={togglePinConversation}>{menuText}</Item>;
}
return null;
};
export function getDeleteContactMenuItem( export function getDeleteContactMenuItem(
isMe: boolean | undefined, isMe: boolean | undefined,
isGroup: boolean | undefined, isGroup: boolean | undefined,

@ -16,7 +16,6 @@ import {
getMessagesByConversation, getMessagesByConversation,
getUnreadByConversation, getUnreadByConversation,
getUnreadCountByConversation, getUnreadCountByConversation,
removeAllMessagesInConversation,
removeMessage as dataRemoveMessage, removeMessage as dataRemoveMessage,
saveMessages, saveMessages,
updateConversation, updateConversation,
@ -40,11 +39,7 @@ import { ConversationInteraction } from '../interactions';
import { OpenGroupVisibleMessage } from '../session/messages/outgoing/visibleMessage/OpenGroupVisibleMessage'; import { OpenGroupVisibleMessage } from '../session/messages/outgoing/visibleMessage/OpenGroupVisibleMessage';
import { OpenGroupRequestCommonType } from '../opengroup/opengroupV2/ApiUtil'; import { OpenGroupRequestCommonType } from '../opengroup/opengroupV2/ApiUtil';
import { getOpenGroupV2FromConversationId } from '../opengroup/utils/OpenGroupUtils'; import { getOpenGroupV2FromConversationId } from '../opengroup/utils/OpenGroupUtils';
import { NotificationForConvoOption } from '../components/conversation/ConversationHeader';
import { useDispatch } from 'react-redux';
import { updateConfirmModal } from '../state/ducks/modalDialog';
import { createTaskWithTimeout } from '../session/utils/TaskWithTimeout'; import { createTaskWithTimeout } from '../session/utils/TaskWithTimeout';
import { DURATION, SWARM_POLLING_TIMEOUT } from '../session/constants';
export enum ConversationTypeEnum { export enum ConversationTypeEnum {
GROUP = 'group', GROUP = 'group',
@ -96,6 +91,7 @@ export interface ConversationAttributes {
accessKey?: any; accessKey?: any;
triggerNotificationsFor: ConversationNotificationSettingType; triggerNotificationsFor: ConversationNotificationSettingType;
isTrustedForAttachmentDownload: boolean; isTrustedForAttachmentDownload: boolean;
isPinned: boolean;
} }
export interface ConversationAttributesOptionals { export interface ConversationAttributesOptionals {
@ -133,6 +129,7 @@ export interface ConversationAttributesOptionals {
accessKey?: any; accessKey?: any;
triggerNotificationsFor?: ConversationNotificationSettingType; triggerNotificationsFor?: ConversationNotificationSettingType;
isTrustedForAttachmentDownload?: boolean; isTrustedForAttachmentDownload?: boolean;
isPinned: boolean;
} }
/** /**
@ -162,6 +159,7 @@ export const fillConvoAttributesWithDefaults = (
active_at: 0, active_at: 0,
triggerNotificationsFor: 'all', // if the settings is not set in the db, this is the default 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 isTrustedForAttachmentDownload: false, // we don't trust a contact until we say so
isPinned: false,
}); });
}; };
@ -409,6 +407,7 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
left: !!this.get('left'), left: !!this.get('left'),
groupAdmins, groupAdmins,
members, members,
isPinned: this.isPinned(),
}; };
} }
@ -1094,6 +1093,16 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
await this.commit(); await this.commit();
} }
} }
public async setIsPinned(value: boolean) {
if (value !== this.get('isPinned')) {
this.set({
isPinned: value,
});
await this.commit();
}
}
public async setGroupName(name: string) { public async setGroupName(name: string) {
const profileName = this.get('name'); const profileName = this.get('name');
if (profileName !== name) { if (profileName !== name) {
@ -1225,6 +1234,10 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
return this.get('name') || window.i18n('unknown'); return this.get('name') || window.i18n('unknown');
} }
public isPinned() {
return this.get('isPinned');
}
public getTitle() { public getTitle() {
if (this.isPrivate()) { if (this.isPrivate()) {
const profileName = this.getProfileName(); const profileName = this.getProfileName();

@ -26,7 +26,7 @@ const logger = createLogger({
logger: directConsole, logger: directConsole,
}); });
const persistConfig = { export const persistConfig = {
key: 'root', key: 'root',
storage, storage,
whitelist: ['userConfig'], whitelist: ['userConfig'],
@ -40,7 +40,6 @@ const middlewareList = disableLogging ? [promise] : [promise, logger];
export const createStore = (initialState: any) => export const createStore = (initialState: any) =>
configureStore({ configureStore({
// reducer: allReducers,
reducer: persistedReducer, reducer: persistedReducer,
preloadedState: initialState, preloadedState: initialState,
middleware: (getDefaultMiddleware: any) => getDefaultMiddleware().concat(middlewareList), middleware: (getDefaultMiddleware: any) => getDefaultMiddleware().concat(middlewareList),

@ -163,6 +163,3 @@ export const inversedTheme = (theme: DefaultTheme): DefaultTheme => {
export const SessionTheme = ({ children, theme }: { children: any; theme: DefaultTheme }) => ( export const SessionTheme = ({ children, theme }: { children: any; theme: DefaultTheme }) => (
<ThemeProvider theme={theme}>{children}</ThemeProvider> <ThemeProvider theme={theme}>{children}</ThemeProvider>
); );
window.lightTheme = lightTheme;
window.darkTheme = darkTheme;

@ -82,6 +82,7 @@ export interface ConversationType {
avatarPath?: string; // absolute filepath to the avatar avatarPath?: string; // absolute filepath to the avatar
groupAdmins?: Array<string>; // admins for closed groups and moderators for open groups groupAdmins?: Array<string>; // admins for closed groups and moderators for open groups
members?: Array<string>; // members for closed groups only members?: Array<string>; // members for closed groups only
isPinned: boolean;
} }
export type ConversationLookupType = { export type ConversationLookupType = {

@ -1,9 +1,18 @@
import { SectionType } from '../../components/session/ActionsPanel';
import { SessionSettingCategory } from '../../components/session/settings/SessionSettings'; import { SessionSettingCategory } from '../../components/session/settings/SessionSettings';
export const FOCUS_SECTION = 'FOCUS_SECTION'; export const FOCUS_SECTION = 'FOCUS_SECTION';
export const FOCUS_SETTINGS_SECTION = 'FOCUS_SETTINGS_SECTION'; export const FOCUS_SETTINGS_SECTION = 'FOCUS_SETTINGS_SECTION';
export enum SectionType {
Profile,
Message,
Contact,
Channel,
Settings,
Moon,
PathIndicator,
}
type FocusSectionActionType = { type FocusSectionActionType = {
type: 'FOCUS_SECTION'; type: 'FOCUS_SECTION';
payload: SectionType; payload: SectionType;

@ -64,6 +64,14 @@ const collator = new Intl.Collator();
export const _getConversationComparator = (testingi18n?: LocalizerType) => { export const _getConversationComparator = (testingi18n?: LocalizerType) => {
return (left: ConversationType, right: ConversationType): number => { return (left: ConversationType, right: ConversationType): number => {
// Pin is the first criteria to check
if (left.isPinned && !right.isPinned) {
return -1;
}
if (!left.isPinned && right.isPinned) {
return 1;
}
// Then if none is pinned, check other criteria
const leftActiveAt = left.activeAt; const leftActiveAt = left.activeAt;
const rightActiveAt = right.activeAt; const rightActiveAt = right.activeAt;
if (leftActiveAt && !rightActiveAt) { if (leftActiveAt && !rightActiveAt) {
@ -213,3 +221,8 @@ export const getMe = createSelector(
export const getUnreadMessageCount = createSelector(getLeftPaneLists, (state): number => { export const getUnreadMessageCount = createSelector(getLeftPaneLists, (state): number => {
return state.unreadCount; return state.unreadCount;
}); });
export const getNumberOfPinnedConversations = createSelector(getConversations, (state): number => {
const values = Object.values(state.conversationLookup);
return values.filter(conversation => conversation.isPinned).length;
});

@ -1,9 +1,9 @@
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { StateType } from '../reducer'; import { StateType } from '../reducer';
import { SectionType } from '../../components/session/ActionsPanel';
import { OnionState } from '../ducks/onion'; import { OnionState } from '../ducks/onion';
import { Snode } from '../../data/data'; import { Snode } from '../../data/data';
import { SectionType } from '../../state/ducks/section';
export const getOnionPaths = (state: StateType): OnionState => state.onionPaths; export const getOnionPaths = (state: StateType): OnionState => state.onionPaths;

@ -1,8 +1,7 @@
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { StateType } from '../reducer'; import { StateType } from '../reducer';
import { SectionStateType } from '../ducks/section'; import { SectionStateType, SectionType } from '../ducks/section';
import { SectionType } from '../../components/session/ActionsPanel';
import { SessionSettingCategory } from '../../components/session/settings/SessionSettings'; import { SessionSettingCategory } from '../../components/session/settings/SessionSettings';
export const getSection = (state: StateType): SectionStateType => state.section; export const getSection = (state: StateType): SectionStateType => state.section;

@ -0,0 +1,206 @@
import { assert } from 'chai';
import { ConversationTypeEnum } from '../../../../models/conversation';
import { ConversationLookupType } from '../../../../state/ducks/conversations';
import {
_getConversationComparator,
_getLeftPaneLists,
} from '../../../../state/selectors/conversations';
describe('state/selectors/conversations', () => {
describe('#getLeftPaneList', () => {
it('sorts conversations based on timestamp then by intl-friendly title', () => {
const i18n = (key: string) => key;
const data: ConversationLookupType = {
id1: {
id: 'id1',
activeAt: 0,
name: 'No timestamp',
phoneNumber: 'notused',
type: ConversationTypeEnum.PRIVATE,
isMe: false,
unreadCount: 1,
mentionedUs: false,
isSelected: false,
isTyping: false,
isBlocked: false,
isKickedFromGroup: false,
left: false,
isPinned: false,
},
id2: {
id: 'id2',
activeAt: 20,
name: 'B',
phoneNumber: 'notused',
type: ConversationTypeEnum.PRIVATE,
isMe: false,
unreadCount: 1,
mentionedUs: false,
isSelected: false,
isTyping: false,
isBlocked: false,
isKickedFromGroup: false,
left: false,
isPinned: false,
},
id3: {
id: 'id3',
activeAt: 20,
name: 'C',
phoneNumber: 'notused',
type: ConversationTypeEnum.PRIVATE,
isMe: false,
unreadCount: 1,
mentionedUs: false,
isSelected: false,
isTyping: false,
isBlocked: false,
isKickedFromGroup: false,
left: false,
isPinned: false,
},
id4: {
id: 'id4',
activeAt: 20,
name: 'Á',
phoneNumber: 'notused',
type: ConversationTypeEnum.PRIVATE,
isMe: false,
unreadCount: 1,
mentionedUs: false,
isSelected: false,
isTyping: false,
isBlocked: false,
isKickedFromGroup: false,
left: false,
isPinned: false,
},
id5: {
id: 'id5',
activeAt: 30,
name: 'First!',
phoneNumber: 'notused',
type: ConversationTypeEnum.PRIVATE,
isMe: false,
unreadCount: 1,
mentionedUs: false,
isSelected: false,
isTyping: false,
isBlocked: false,
isKickedFromGroup: false,
left: false,
isPinned: false,
},
};
const comparator = _getConversationComparator(i18n);
const { conversations } = _getLeftPaneLists(data, comparator);
assert.strictEqual(conversations[0].name, 'First!');
assert.strictEqual(conversations[1].name, 'Á');
assert.strictEqual(conversations[2].name, 'B');
assert.strictEqual(conversations[3].name, 'C');
});
});
describe('#getLeftPaneListWithPinned', () => {
it('sorts conversations based on pin, timestamp then by intl-friendly title', () => {
const i18n = (key: string) => key;
const data: ConversationLookupType = {
id1: {
id: 'id1',
activeAt: 0,
name: 'No timestamp',
phoneNumber: 'notused',
type: ConversationTypeEnum.PRIVATE,
isMe: false,
unreadCount: 1,
mentionedUs: false,
isSelected: false,
isTyping: false,
isBlocked: false,
isKickedFromGroup: false,
left: false,
isPinned: false,
},
id2: {
id: 'id2',
activeAt: 20,
name: 'B',
phoneNumber: 'notused',
type: ConversationTypeEnum.PRIVATE,
isMe: false,
unreadCount: 1,
mentionedUs: false,
isSelected: false,
isTyping: false,
isBlocked: false,
isKickedFromGroup: false,
left: false,
isPinned: false,
},
id3: {
id: 'id3',
activeAt: 20,
name: 'C',
phoneNumber: 'notused',
type: ConversationTypeEnum.PRIVATE,
isMe: false,
unreadCount: 1,
mentionedUs: false,
isSelected: false,
isTyping: false,
isBlocked: false,
isKickedFromGroup: false,
left: false,
isPinned: true,
},
id4: {
id: 'id4',
activeAt: 20,
name: 'Á',
phoneNumber: 'notused',
type: ConversationTypeEnum.PRIVATE,
isMe: false,
unreadCount: 1,
mentionedUs: false,
isSelected: false,
isTyping: false,
isBlocked: false,
isKickedFromGroup: false,
left: false,
isPinned: true,
},
id5: {
id: 'id5',
activeAt: 30,
name: 'First!',
phoneNumber: 'notused',
type: ConversationTypeEnum.PRIVATE,
isMe: false,
unreadCount: 1,
mentionedUs: false,
isSelected: false,
isTyping: false,
isBlocked: false,
isKickedFromGroup: false,
left: false,
isPinned: false,
},
};
const comparator = _getConversationComparator(i18n);
const { conversations } = _getLeftPaneLists(data, comparator);
assert.strictEqual(conversations[0].name, 'Á');
assert.strictEqual(conversations[1].name, 'C');
assert.strictEqual(conversations[2].name, 'First!');
assert.strictEqual(conversations[3].name, 'B');
});
});
});

@ -1,103 +0,0 @@
import { assert } from 'chai';
import { ConversationTypeEnum } from '../../../models/conversation';
import { ConversationLookupType } from '../../../state/ducks/conversations';
import {
_getConversationComparator,
_getLeftPaneLists,
} from '../../../state/selectors/conversations';
describe('state/selectors/conversations', () => {
describe('#getLeftPaneList', () => {
it('sorts conversations based on timestamp then by intl-friendly title', () => {
const i18n = (key: string) => key;
const data: ConversationLookupType = {
id1: {
id: 'id1',
activeAt: 0,
name: 'No timestamp',
phoneNumber: 'notused',
type: ConversationTypeEnum.PRIVATE,
isMe: false,
unreadCount: 1,
mentionedUs: false,
isSelected: false,
isTyping: false,
isBlocked: false,
isKickedFromGroup: false,
left: false,
},
id2: {
id: 'id2',
activeAt: 20,
name: 'B',
phoneNumber: 'notused',
type: ConversationTypeEnum.PRIVATE,
isMe: false,
unreadCount: 1,
mentionedUs: false,
isSelected: false,
isTyping: false,
isBlocked: false,
isKickedFromGroup: false,
left: false,
},
id3: {
id: 'id3',
activeAt: 20,
name: 'C',
phoneNumber: 'notused',
type: ConversationTypeEnum.PRIVATE,
isMe: false,
unreadCount: 1,
mentionedUs: false,
isSelected: false,
isTyping: false,
isBlocked: false,
isKickedFromGroup: false,
left: false,
},
id4: {
id: 'id4',
activeAt: 20,
name: 'Á',
phoneNumber: 'notused',
type: ConversationTypeEnum.PRIVATE,
isMe: false,
unreadCount: 1,
mentionedUs: false,
isSelected: false,
isTyping: false,
isBlocked: false,
isKickedFromGroup: false,
left: false,
},
id5: {
id: 'id5',
activeAt: 30,
name: 'First!',
phoneNumber: 'notused',
type: ConversationTypeEnum.PRIVATE,
isMe: false,
unreadCount: 1,
mentionedUs: false,
isSelected: false,
isTyping: false,
isBlocked: false,
isKickedFromGroup: false,
left: false,
},
};
const comparator = _getConversationComparator(i18n);
const { conversations } = _getLeftPaneLists(data, comparator);
assert.strictEqual(conversations[0].name, 'First!');
assert.strictEqual(conversations[1].name, 'Á');
assert.strictEqual(conversations[2].name, 'B');
assert.strictEqual(conversations[3].name, 'C');
});
});
});

@ -88,6 +88,7 @@ export class MockConversation {
zombies: [], zombies: [],
triggerNotificationsFor: 'all', triggerNotificationsFor: 'all',
isTrustedForAttachmentDownload: false, isTrustedForAttachmentDownload: false,
isPinned: false,
}; };
} }

@ -9,6 +9,7 @@ import { actions as userActions } from '../state/ducks/user';
import { mn_decode, mn_encode } from '../session/crypto/mnemonic'; import { mn_decode, mn_encode } from '../session/crypto/mnemonic';
import { ConversationTypeEnum } from '../models/conversation'; import { ConversationTypeEnum } from '../models/conversation';
import _ from 'underscore'; import _ from 'underscore';
import { persistStore } from 'redux-persist';
/** /**
* Might throw * Might throw

4
ts/window.d.ts vendored

@ -48,6 +48,7 @@ declare global {
useFileOnionRequests: boolean; useFileOnionRequests: boolean;
useFileOnionRequestsV2: boolean; useFileOnionRequestsV2: boolean;
padOutgoingAttachments: boolean; padOutgoingAttachments: boolean;
enablePinConversations: boolean;
}; };
lokiSnodeAPI: LokiSnodeAPI; lokiSnodeAPI: LokiSnodeAPI;
onLogin: any; onLogin: any;
@ -56,7 +57,6 @@ declare global {
getSeedNodeList: () => Array<any> | undefined; getSeedNodeList: () => Array<any> | undefined;
setPassword: any; setPassword: any;
setSettingValue: any; setSettingValue: any;
showResetSessionIdDialog: any;
storage: any; storage: any;
textsecure: LibTextsecure; textsecure: LibTextsecure;
toggleMediaPermissions: any; toggleMediaPermissions: any;
@ -78,8 +78,6 @@ declare global {
expired: (boolean) => void; expired: (boolean) => void;
expiredStatus: () => boolean; expiredStatus: () => boolean;
}; };
lightTheme: DefaultTheme;
darkTheme: DefaultTheme;
LokiPushNotificationServer: any; LokiPushNotificationServer: any;
globalOnlineStatus: boolean; globalOnlineStatus: boolean;
confirmationDialog: any; confirmationDialog: any;

Loading…
Cancel
Save