diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 5e9dbf34a..5db95684d 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -409,5 +409,9 @@ "audioMessageAutoplayDescription": "Automatically play consecutively sent audio messages", "clickToTrustContact": "Click to download media", "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" } diff --git a/_locales/fr/messages.json b/_locales/fr/messages.json index ab77e6b18..07b9dc6c1 100644 --- a/_locales/fr/messages.json +++ b/_locales/fr/messages.json @@ -390,5 +390,9 @@ "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...", "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" } diff --git a/js/background.js b/js/background.js index dcbf28b3f..3600881e6 100644 --- a/js/background.js +++ b/js/background.js @@ -297,10 +297,6 @@ window.addEventListener('focus', () => Whisper.Notifications.clear()); window.addEventListener('unload', () => Whisper.Notifications.fastClear()); - window.showResetSessionIdDialog = () => { - appView.showResetSessionIdDialog(); - }; - // Set user's launch count. const prevLaunchCount = window.getSettingValue('launch-count'); const launchCount = !prevLaunchCount ? 1 : prevLaunchCount + 1; diff --git a/js/views/app_view.js b/js/views/app_view.js index ba1432b43..126e920e9 100644 --- a/js/views/app_view.js +++ b/js/views/app_view.js @@ -94,16 +94,5 @@ window.focus(); // FIXME 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; - }, }); })(); diff --git a/preload.js b/preload.js index 5023abca0..87b3d051b 100644 --- a/preload.js +++ b/preload.js @@ -56,6 +56,7 @@ window.lokiFeatureFlags = { useFileOnionRequests: true, useFileOnionRequestsV2: true, // more compact encoding of files in response padOutgoingAttachments: true, + enablePinConversations: false, }; if (typeof process.env.NODE_ENV === 'string' && process.env.NODE_ENV.includes('test-integration')) { diff --git a/ts/components/ConversationListItem.tsx b/ts/components/ConversationListItem.tsx index 7c3594054..ebdd01aee 100644 --- a/ts/components/ConversationListItem.tsx +++ b/ts/components/ConversationListItem.tsx @@ -19,9 +19,17 @@ import { } from './session/menu/ConversationListItemContextMenu'; import { createPortal } from 'react-dom'; 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 { 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 { 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); }; -class ConversationListItem extends React.PureComponent { - public constructor(props: Props) { - super(props); - } - - public renderAvatar() { - const { avatarPath, name, phoneNumber, profileName, memberAvatars } = this.props; +const ConversationListItem = (props: Props) => { + const { + phoneNumber, + unreadCount, + id, + isSelected, + 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 ( -
- +
{ + 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 + )} + > + +
+ + +
- ); - } + + + +
+ ); +}; - public renderHeader() { - const { unreadCount, mentionedUs, activeAt } = this.props; +export interface ConversationListItemAvatarProps { + avatarPath?: string; + name?: string; + profileName?: string; + phoneNumber?: string; + memberAvatars?: Array; +} - let atSymbol = null; - let unreadCountDiv = null; - if (unreadCount > 0) { - atSymbol = mentionedUs ?

@

: null; - unreadCountDiv =

{unreadCount}

; - } +export const ConversationListItemAvatar = (props: ConversationListItemAvatarProps) => { + const { avatarPath, name, phoneNumber, profileName, memberAvatars } = props; + const userName = name || profileName || phoneNumber; - return ( -
-
0 ? 'module-conversation-list-item__header__name--with-unread' : null - )} - > - {this.renderUser()} -
- {unreadCountDiv} - {atSymbol} - { -
0 ? 'module-conversation-list-item__header__date--has-unread' : null - )} - > - { - - } -
- } -
- ); - } + return ( +
+ +
+ ); +}; - public renderMessage() { - const { lastMessage, isTyping, unreadCount } = this.props; +export interface ConversationListItemHeaderProps { + unreadCount: number; + mentionedUs: boolean; + activeAt?: number; + isPinned: boolean; - if (!lastMessage && !isTyping) { - return null; - } - const text = lastMessage && lastMessage.text ? lastMessage.text : ''; + name?: string; + phoneNumber: string; + profileName?: string; + isMe: boolean; +} - if (isEmpty(text)) { - return null; - } +export const ConversationListItemHeader = (props: ConversationListItemHeaderProps) => { + const { + unreadCount, + mentionedUs, + activeAt, + isPinned, + name, + phoneNumber, + profileName, + isMe, + } = props; - return ( -
-
0 ? 'module-conversation-list-item__message__text--has-unread' : null - )} - > - {isTyping ? ( - - ) : ( - - )} -
- {lastMessage && lastMessage.status ? ( - - ) : null} -
- ); + const theme = useTheme(); + + let atSymbol = null; + let unreadCountDiv = null; + if (unreadCount > 0) { + atSymbol = mentionedUs ?

@

: null; + unreadCountDiv =

{unreadCount}

; } - public render() { - const { phoneNumber, unreadCount, id, isSelected, isBlocked, style, mentionedUs } = this.props; - const triggerId = `conversation-item-${phoneNumber}-ctxmenu`; - const key = `conversation-item-${phoneNumber}`; + const isMessagesSection = useSelector(getFocusedSection) === SectionType.Message; + + const pinIcon = + isMessagesSection && isPinned ? ( + + ) : null; - return ( -
+ return ( +
+
0 ? 'module-conversation-list-item__header__name--with-unread' : null + )} + > + +
+ + {pinIcon} + {unreadCountDiv} + {atSymbol} + {
{ - window.inboxStore?.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 + 'module-conversation-list-item__header__date', + unreadCount > 0 ? 'module-conversation-list-item__header__date--has-unread' : null )} > - {this.renderAvatar()} -
- {this.renderHeader()} - {this.renderMessage()} -
+ { + + }
- - - -
- ); + } +
+ ); +}; + +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 { - return { - triggerId, - ...this.props, - }; + if (isEmpty(text)) { + return null; } - private renderUser() { - const { name, phoneNumber, profileName, isMe } = this.props; + return ( +
+
0 ? 'module-conversation-list-item__message__text--has-unread' : null + )} + > + {isTyping ? ( + + ) : ( + + )} +
+ {lastMessage && lastMessage.status ? ( + + ) : null} +
+ ); +}; - const shortenedPubkey = PubKey.shorten(phoneNumber); +export interface ConversationListItemUserProps { + name?: string; + phoneNumber: string; + profileName?: string; + isMe: boolean; +} - const displayedPubkey = profileName ? shortenedPubkey : phoneNumber; - const displayName = isMe ? window.i18n('noteToSelf') : profileName; +export const ConversationListItemUser = (props: ConversationListItemUserProps) => { + const { name, phoneNumber, profileName, isMe } = props; - let shouldShowPubkey = false; - if ((!name || name.length === 0) && (!displayName || displayName.length === 0)) { - shouldShowPubkey = true; - } + const shortenedPubkey = PubKey.shorten(phoneNumber); - return ( -
- -
- ); + const displayedPubkey = profileName ? shortenedPubkey : phoneNumber; + const displayName = isMe ? window.i18n('noteToSelf') : profileName; + + let shouldShowPubkey = false; + if ((!name || name.length === 0) && (!displayName || displayName.length === 0)) { + shouldShowPubkey = true; } -} + + return ( +
+ +
+ ); +}; export const ConversationListItemWithDetails = usingClosedConversationDetails( withTheme(ConversationListItem) diff --git a/ts/components/LeftPane.tsx b/ts/components/LeftPane.tsx index da5a16129..812298819 100644 --- a/ts/components/LeftPane.tsx +++ b/ts/components/LeftPane.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; -import { ActionsPanel, SectionType } from './session/ActionsPanel'; +import { ActionsPanel } from './session/ActionsPanel'; import { LeftPaneMessageSection } from './session/LeftPaneMessageSection'; import { openConversationExternal } from '../state/ducks/conversations'; @@ -13,6 +13,7 @@ import { useDispatch, useSelector } from 'react-redux'; import { getLeftPaneLists } from '../state/selectors/conversations'; import { getQuery, getSearchResults, isSearching } from '../state/selectors/search'; import { clearSearch, search, updateSearchTerm } from '../state/ducks/search'; +import { SectionType } from '../state/ducks/section'; import { getTheme } from '../state/selectors/theme'; // from https://github.com/bvaughn/react-virtualized/blob/fb3484ed5dcc41bffae8eab029126c0fb8f7abc0/source/List/types.js#L5 diff --git a/ts/components/session/ActionsPanel.tsx b/ts/components/session/ActionsPanel.tsx index 6c32f113d..bae63749c 100644 --- a/ts/components/session/ActionsPanel.tsx +++ b/ts/components/session/ActionsPanel.tsx @@ -30,7 +30,7 @@ import { applyTheme } from '../../state/ducks/theme'; import { getFocusedSection } from '../../state/selectors/section'; import { useInterval } from '../../hooks/useInterval'; 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 { getOpenGroupManager } from '../../opengroup/opengroupV2/OpenGroupManagerV2'; @@ -47,16 +47,6 @@ import { ActionPanelOnionStatusLight } from '../OnionStatusPathDialog'; // 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 ourNumber = useSelector(getOurNumber); 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 setupTheme = () => { @@ -230,7 +211,6 @@ const doAppStartUp = () => { void setupTheme(); // keep that one to make sure our users upgrade to new sessionIDS - void showResetSessionIDDialogIfNeeded(); void removeAllV1OpenGroups(); // this generates the key to encrypt attachments locally diff --git a/ts/components/session/LeftPaneMessageSection.tsx b/ts/components/session/LeftPaneMessageSection.tsx index 5dcdd7f3c..665293eb3 100644 --- a/ts/components/session/LeftPaneMessageSection.tsx +++ b/ts/components/session/LeftPaneMessageSection.tsx @@ -1,7 +1,5 @@ import React from 'react'; - import { AutoSizer, List } from 'react-virtualized'; - import { MainViewController } from '../MainViewController'; import { ConversationListItemProps, @@ -10,7 +8,7 @@ import { import { ConversationType as ReduxConversationType } from '../../state/ducks/conversations'; import { SearchResults, SearchResultsProps } from '../SearchResults'; import { SessionSearchInput } from './SessionSearchInput'; -import { debounce } from 'lodash'; +import _, { debounce } from 'lodash'; import { cleanSearchTerm } from '../../util/cleanSearchTerm'; import { SearchOptions } from '../../types/Search'; import { RowRendererParamsType } from '../LeftPane'; @@ -30,8 +28,6 @@ import autoBind from 'auto-bind'; import { onsNameRegex } from '../../session/snode_api/SNodeAPI'; import { SNodeAPI } from '../../session/snode_api'; -import { createClosedGroup } from '../../receiver/closedGroups'; - export interface Props { searchTerm: string; diff --git a/ts/components/session/conversation/SessionCompositionBox.tsx b/ts/components/session/conversation/SessionCompositionBox.tsx index bbc505ef2..c8caf2f71 100644 --- a/ts/components/session/conversation/SessionCompositionBox.tsx +++ b/ts/components/session/conversation/SessionCompositionBox.tsx @@ -31,10 +31,10 @@ import { getConversationController } from '../../../session/conversations'; import { ConversationType } from '../../../state/ducks/conversations'; import { SessionMemberListItem } from '../SessionMemberListItem'; import autoBind from 'auto-bind'; -import { SectionType } from '../ActionsPanel'; import { SessionSettingCategory } from '../settings/SessionSettings'; import { getMentionsInput } from '../../../state/selectors/mentionsInput'; import { updateConfirmModal } from '../../../state/ducks/modalDialog'; +import { SectionType } from '../../../state/ducks/section'; import { SessionButtonColor } from '../SessionButton'; import { SessionConfirmDialogProps } from '../SessionConfirm'; import { diff --git a/ts/components/session/icon/Icons.tsx b/ts/components/session/icon/Icons.tsx index 702fac158..39a7ff3d8 100644 --- a/ts/components/session/icon/Icons.tsx +++ b/ts/components/session/icon/Icons.tsx @@ -25,6 +25,7 @@ export enum SessionIconType { Moon = 'moon', Pause = 'pause', Pencil = 'pencil', + Pin = 'pin', Play = 'play', Plus = 'plus', Reply = 'reply', @@ -224,6 +225,12 @@ export const icons = { viewBox: '1 1 21 21', 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]: { 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', diff --git a/ts/components/session/menu/ConversationListItemContextMenu.tsx b/ts/components/session/menu/ConversationListItemContextMenu.tsx index b00546fdb..740eacfa1 100644 --- a/ts/components/session/menu/ConversationListItemContextMenu.tsx +++ b/ts/components/session/menu/ConversationListItemContextMenu.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React from 'react'; import { animation, Menu } from 'react-contexify'; import { ConversationTypeEnum } from '../../../models/conversation'; @@ -12,6 +12,7 @@ import { getInviteContactMenuItem, getLeaveGroupMenuItem, getMarkAllReadMenuItem, + getPinConversationMenuItem, } from './Menu'; export type PropsContextConversationItem = { @@ -38,7 +39,6 @@ export const ConversationListItemContextMenu = (props: PropsContextConversationI type, left, isKickedFromGroup, - theme, } = props; const isGroup = type === 'group'; @@ -46,6 +46,7 @@ export const ConversationListItemContextMenu = (props: PropsContextConversationI return ( <> + {getPinConversationMenuItem(conversationId)} {getBlockMenuItem(isMe, type === ConversationTypeEnum.PRIVATE, isBlocked, conversationId)} {getCopyMenuItem(isPublic, isGroup, conversationId)} {getMarkAllReadMenuItem(conversationId)} diff --git a/ts/components/session/menu/Menu.tsx b/ts/components/session/menu/Menu.tsx index e7c0bc1b5..9c4886aab 100644 --- a/ts/components/session/menu/Menu.tsx +++ b/ts/components/session/menu/Menu.tsx @@ -1,14 +1,13 @@ import React from 'react'; +import { getNumberOfPinnedConversations } from '../../../state/selectors/conversations'; +import { getFocusedSection } from '../../../state/selectors/section'; import { NotificationForConvoOption, TimerOption } from '../../conversation/ConversationHeader'; import { Item, Submenu } from 'react-contexify'; import { ConversationNotificationSettingType } from '../../../models/conversation'; -import { useDispatch } from 'react-redux'; -import { - adminLeaveClosedGroup, - changeNickNameModal, - updateConfirmModal, -} from '../../../state/ducks/modalDialog'; +import { useDispatch, useSelector } from 'react-redux'; +import { changeNickNameModal, updateConfirmModal } from '../../../state/ducks/modalDialog'; +import { SectionType } from '../../../state/ducks/section'; import { getConversationController } from '../../../session/conversations'; import { blockConvoById, @@ -26,6 +25,9 @@ import { unblockConvoById, } from '../../../interactions/conversationInteractions'; import { SessionButtonColor } from '../SessionButton'; +import { ToastUtils } from '../../../session/utils'; + +const maxNumberOfPinnedConversations = 5; function showTimerOptions( isPublic: boolean, @@ -126,6 +128,35 @@ export function getInviteContactMenuItem( 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 {menuText}; + } + return null; +}; + export function getDeleteContactMenuItem( isMe: boolean | undefined, isGroup: boolean | undefined, diff --git a/ts/models/conversation.ts b/ts/models/conversation.ts index c8c13fac0..5dae6d3c5 100644 --- a/ts/models/conversation.ts +++ b/ts/models/conversation.ts @@ -16,7 +16,6 @@ import { getMessagesByConversation, getUnreadByConversation, getUnreadCountByConversation, - removeAllMessagesInConversation, removeMessage as dataRemoveMessage, saveMessages, updateConversation, @@ -40,11 +39,7 @@ import { ConversationInteraction } from '../interactions'; import { OpenGroupVisibleMessage } from '../session/messages/outgoing/visibleMessage/OpenGroupVisibleMessage'; import { OpenGroupRequestCommonType } from '../opengroup/opengroupV2/ApiUtil'; 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 { DURATION, SWARM_POLLING_TIMEOUT } from '../session/constants'; export enum ConversationTypeEnum { GROUP = 'group', @@ -96,6 +91,7 @@ export interface ConversationAttributes { accessKey?: any; triggerNotificationsFor: ConversationNotificationSettingType; isTrustedForAttachmentDownload: boolean; + isPinned: boolean; } export interface ConversationAttributesOptionals { @@ -133,6 +129,7 @@ export interface ConversationAttributesOptionals { accessKey?: any; triggerNotificationsFor?: ConversationNotificationSettingType; isTrustedForAttachmentDownload?: boolean; + isPinned: boolean; } /** @@ -162,6 +159,7 @@ export const fillConvoAttributesWithDefaults = ( active_at: 0, 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, }); }; @@ -409,6 +407,7 @@ export class ConversationModel extends Backbone.Model { left: !!this.get('left'), groupAdmins, members, + isPinned: this.isPinned(), }; } @@ -1094,6 +1093,16 @@ export class ConversationModel extends Backbone.Model { 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) { const profileName = this.get('name'); if (profileName !== name) { @@ -1225,6 +1234,10 @@ export class ConversationModel extends Backbone.Model { return this.get('name') || window.i18n('unknown'); } + public isPinned() { + return this.get('isPinned'); + } + public getTitle() { if (this.isPrivate()) { const profileName = this.getProfileName(); diff --git a/ts/state/createStore.ts b/ts/state/createStore.ts index 949fb1de2..196ed439f 100644 --- a/ts/state/createStore.ts +++ b/ts/state/createStore.ts @@ -26,7 +26,7 @@ const logger = createLogger({ logger: directConsole, }); -const persistConfig = { +export const persistConfig = { key: 'root', storage, whitelist: ['userConfig'], @@ -40,7 +40,6 @@ const middlewareList = disableLogging ? [promise] : [promise, logger]; export const createStore = (initialState: any) => configureStore({ - // reducer: allReducers, reducer: persistedReducer, preloadedState: initialState, middleware: (getDefaultMiddleware: any) => getDefaultMiddleware().concat(middlewareList), diff --git a/ts/state/ducks/SessionTheme.tsx b/ts/state/ducks/SessionTheme.tsx index 3432efc28..6a4c43f93 100644 --- a/ts/state/ducks/SessionTheme.tsx +++ b/ts/state/ducks/SessionTheme.tsx @@ -163,6 +163,3 @@ export const inversedTheme = (theme: DefaultTheme): DefaultTheme => { export const SessionTheme = ({ children, theme }: { children: any; theme: DefaultTheme }) => ( {children} ); - -window.lightTheme = lightTheme; -window.darkTheme = darkTheme; diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 48be614ad..eb20669f9 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -82,6 +82,7 @@ export interface ConversationType { avatarPath?: string; // absolute filepath to the avatar groupAdmins?: Array; // admins for closed groups and moderators for open groups members?: Array; // members for closed groups only + isPinned: boolean; } export type ConversationLookupType = { diff --git a/ts/state/ducks/section.tsx b/ts/state/ducks/section.tsx index 3c75a20b2..6c6092cc3 100644 --- a/ts/state/ducks/section.tsx +++ b/ts/state/ducks/section.tsx @@ -1,9 +1,18 @@ -import { SectionType } from '../../components/session/ActionsPanel'; import { SessionSettingCategory } from '../../components/session/settings/SessionSettings'; export const FOCUS_SECTION = 'FOCUS_SECTION'; export const FOCUS_SETTINGS_SECTION = 'FOCUS_SETTINGS_SECTION'; +export enum SectionType { + Profile, + Message, + Contact, + Channel, + Settings, + Moon, + PathIndicator, +} + type FocusSectionActionType = { type: 'FOCUS_SECTION'; payload: SectionType; diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index b054f312a..f580406f3 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -64,6 +64,14 @@ const collator = new Intl.Collator(); export const _getConversationComparator = (testingi18n?: LocalizerType) => { 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 rightActiveAt = right.activeAt; if (leftActiveAt && !rightActiveAt) { @@ -213,3 +221,8 @@ export const getMe = createSelector( export const getUnreadMessageCount = createSelector(getLeftPaneLists, (state): number => { return state.unreadCount; }); + +export const getNumberOfPinnedConversations = createSelector(getConversations, (state): number => { + const values = Object.values(state.conversationLookup); + return values.filter(conversation => conversation.isPinned).length; +}); diff --git a/ts/state/selectors/onions.ts b/ts/state/selectors/onions.ts index 897e8b3ff..6bb0f9693 100644 --- a/ts/state/selectors/onions.ts +++ b/ts/state/selectors/onions.ts @@ -1,9 +1,9 @@ import { createSelector } from 'reselect'; import { StateType } from '../reducer'; -import { SectionType } from '../../components/session/ActionsPanel'; import { OnionState } from '../ducks/onion'; import { Snode } from '../../data/data'; +import { SectionType } from '../../state/ducks/section'; export const getOnionPaths = (state: StateType): OnionState => state.onionPaths; diff --git a/ts/state/selectors/section.ts b/ts/state/selectors/section.ts index 35f5df599..049ad4983 100644 --- a/ts/state/selectors/section.ts +++ b/ts/state/selectors/section.ts @@ -1,8 +1,7 @@ import { createSelector } from 'reselect'; import { StateType } from '../reducer'; -import { SectionStateType } from '../ducks/section'; -import { SectionType } from '../../components/session/ActionsPanel'; +import { SectionStateType, SectionType } from '../ducks/section'; import { SessionSettingCategory } from '../../components/session/settings/SessionSettings'; export const getSection = (state: StateType): SectionStateType => state.section; diff --git a/ts/test/session/unit/selectors/conversations_test.ts b/ts/test/session/unit/selectors/conversations_test.ts new file mode 100644 index 000000000..06b494aa5 --- /dev/null +++ b/ts/test/session/unit/selectors/conversations_test.ts @@ -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'); + }); + }); +}); diff --git a/ts/test/state/selectors/conversations_test.ts b/ts/test/state/selectors/conversations_test.ts deleted file mode 100644 index 99810915e..000000000 --- a/ts/test/state/selectors/conversations_test.ts +++ /dev/null @@ -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'); - }); - }); -}); diff --git a/ts/test/test-utils/utils/message.ts b/ts/test/test-utils/utils/message.ts index bb658a5d9..4d0cdf6f2 100644 --- a/ts/test/test-utils/utils/message.ts +++ b/ts/test/test-utils/utils/message.ts @@ -88,6 +88,7 @@ export class MockConversation { zombies: [], triggerNotificationsFor: 'all', isTrustedForAttachmentDownload: false, + isPinned: false, }; } diff --git a/ts/util/accountManager.ts b/ts/util/accountManager.ts index 9f4e4286e..7661bda9c 100644 --- a/ts/util/accountManager.ts +++ b/ts/util/accountManager.ts @@ -9,6 +9,7 @@ import { actions as userActions } from '../state/ducks/user'; import { mn_decode, mn_encode } from '../session/crypto/mnemonic'; import { ConversationTypeEnum } from '../models/conversation'; import _ from 'underscore'; +import { persistStore } from 'redux-persist'; /** * Might throw diff --git a/ts/window.d.ts b/ts/window.d.ts index a74ab6c7e..5f43a4e83 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -48,6 +48,7 @@ declare global { useFileOnionRequests: boolean; useFileOnionRequestsV2: boolean; padOutgoingAttachments: boolean; + enablePinConversations: boolean; }; lokiSnodeAPI: LokiSnodeAPI; onLogin: any; @@ -56,7 +57,6 @@ declare global { getSeedNodeList: () => Array | undefined; setPassword: any; setSettingValue: any; - showResetSessionIdDialog: any; storage: any; textsecure: LibTextsecure; toggleMediaPermissions: any; @@ -78,8 +78,6 @@ declare global { expired: (boolean) => void; expiredStatus: () => boolean; }; - lightTheme: DefaultTheme; - darkTheme: DefaultTheme; LokiPushNotificationServer: any; globalOnlineStatus: boolean; confirmationDialog: any;