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 c58986015..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; @@ -340,11 +336,6 @@ window.libsession.Utils.ToastUtils.pushSpellCheckDirty(); }; - window.toggleLinkPreview = () => { - const newValue = !window.getSettingValue('link-preview-setting'); - window.setSettingValue('link-preview-setting', newValue); - }; - window.toggleMediaPermissions = () => { const value = window.getMediaPermissions(); window.setMediaPermissions(!value); 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 00c0cfdf9..0bac97019 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 049b9fbef..81fbdd683 100644 --- a/ts/components/ConversationListItem.tsx +++ b/ts/components/ConversationListItem.tsx @@ -22,8 +22,12 @@ import { } from '../state/ducks/conversations'; import _ from 'underscore'; import { useMembersAvatars } from '../hooks/useMembersAvatar'; -import { useDispatch } from 'react-redux'; +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'; +// tslint:disable-next-line: no-empty-interface export interface ConversationListItemProps extends ReduxConversationType {} type PropsHousekeeping = { @@ -36,26 +40,73 @@ const Portal = ({ children }: { children: any }) => { return createPortal(children, document.querySelector('.inbox.index') as Element); }; -const AvatarItem = (props: { - avatarPath?: string; - conversationId: string; - memberAvatars?: Array; +const HeaderItem = (props: { + unreadCount: number; + isMe: boolean; + mentionedUs: boolean; + activeAt?: number; name?: string; profileName?: string; + conversationId: string; + isPinned: boolean; }) => { - const { avatarPath, name, conversationId, profileName, memberAvatars } = props; + const { + unreadCount, + mentionedUs, + activeAt, + isMe, + isPinned, + conversationId, + profileName, + name, + } = props; + const theme = useTheme(); - const userName = name || profileName || conversationId; + let atSymbol = null; + let unreadCountDiv = null; + if (unreadCount > 0) { + atSymbol = mentionedUs ?

@

: null; + unreadCountDiv =

{unreadCount}

; + } - return ( -
- + ) : null; + return ( +
+
0 ? 'module-conversation-list-item__header__name--with-unread' : null + )} + > + +
+ {pinIcon} + {unreadCountDiv} + {atSymbol} + { +
0 ? 'module-conversation-list-item__header__date--has-unread' : null + )} + > + {} +
+ }
); }; @@ -93,12 +144,11 @@ const UserItem = (props: { }; const MessageItem = (props: { - isTyping: boolean; lastMessage?: LastMessageType; + isTyping: boolean; unreadCount: number; }) => { const { lastMessage, isTyping, unreadCount } = props; - const theme = useTheme(); if (!lastMessage && !isTyping) { @@ -134,51 +184,26 @@ const MessageItem = (props: { ); }; -const HeaderItem = (props: { - unreadCount: number; - isMe: boolean; - mentionedUs: boolean; - activeAt?: number; +const AvatarItem = (props: { + avatarPath?: string; + conversationId: string; + memberAvatars?: Array; name?: string; profileName?: string; - conversationId: string; }) => { - const { unreadCount, mentionedUs, activeAt, isMe, conversationId, profileName, name } = props; + const { avatarPath, name, conversationId, profileName, memberAvatars } = props; - let atSymbol = null; - let unreadCountDiv = null; - if (unreadCount > 0) { - atSymbol = mentionedUs ?

@

: null; - unreadCountDiv =

{unreadCount}

; - } + const userName = name || profileName || conversationId; return ( -
-
0 ? 'module-conversation-list-item__header__name--with-unread' : null - )} - > - -
- {unreadCountDiv} - {atSymbol} - { -
0 ? 'module-conversation-list-item__header__date--has-unread' : null - )} - > - {} -
- } +
+
); }; @@ -195,6 +220,7 @@ const ConversationListItem = (props: Props) => { mentionedUs, isMe, name, + isPinned, profileName, isTyping, lastMessage, @@ -247,6 +273,7 @@ const ConversationListItem = (props: Props) => { unreadCount={unreadCount} activeAt={activeAt} isMe={isMe} + isPinned={isPinned} conversationId={conversationId} name={name} profileName={profileName} diff --git a/ts/components/LeftPane.tsx b/ts/components/LeftPane.tsx index d8711173f..a2d21d170 100644 --- a/ts/components/LeftPane.tsx +++ b/ts/components/LeftPane.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { ActionsPanel, SectionType } from './session/ActionsPanel'; +import { ActionsPanel } from './session/ActionsPanel'; import { LeftPaneMessageSection } from './session/LeftPaneMessageSection'; import { LeftPaneContactSection } from './session/LeftPaneContactSection'; @@ -8,11 +8,10 @@ import { LeftPaneSettingSection } from './session/LeftPaneSettingSection'; import { SessionTheme } from '../state/ducks/SessionTheme'; import { SessionExpiredWarning } from './session/network/SessionExpiredWarning'; import { getFocusedSection } from '../state/selectors/section'; -import { useDispatch, useSelector } from 'react-redux'; +import { 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 { useTheme } from 'styled-components'; +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 32d919442..acd15c412 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,9 +8,8 @@ import { import { openConversationExternal, 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'; import { SessionClosableOverlay, SessionClosableOverlayType } from './SessionClosableOverlay'; import { SessionIconType } from './icon'; @@ -20,7 +17,6 @@ import { ContactType } from './SessionMemberListItem'; import { SessionButton, SessionButtonColor, SessionButtonType } from './SessionButton'; import { PubKey } from '../../session/types'; import { ToastUtils, UserUtils } from '../../session/utils'; -import { DefaultTheme } from 'styled-components'; import { LeftPaneSectionHeader } from './LeftPaneSectionHeader'; import { getConversationController } from '../../session/conversations'; import { ConversationTypeEnum } from '../../models/conversation'; diff --git a/ts/components/session/conversation/SessionCompositionBox.tsx b/ts/components/session/conversation/SessionCompositionBox.tsx index 55a9362f4..86fddc66b 100644 --- a/ts/components/session/conversation/SessionCompositionBox.tsx +++ b/ts/components/session/conversation/SessionCompositionBox.tsx @@ -31,14 +31,20 @@ import { getConversationController } from '../../../session/conversations'; import { ReduxConversationType } 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, + showLeftPaneSection, + showSettingsSection, +} from '../../../state/ducks/section'; import { SessionButtonColor } from '../SessionButton'; -import { SessionConfirmDialogProps } from '../SessionConfirm'; -import { showLeftPaneSection, showSettingsSection } from '../../../state/ducks/section'; -import { pushAudioPermissionNeeded } from '../../../session/utils/Toast'; +import { + createOrUpdateItem, + getItemById, + hasLinkPreviewPopupBeenDisplayed, +} from '../../../data/data'; export interface ReplyingToMessageProps { convoId: string; @@ -215,7 +221,7 @@ export class SessionCompositionBox extends React.Component { imgBlob = item.getAsFile(); break; case 'text': - this.showLinkSharingConfirmationModalDialog(e); + void this.showLinkSharingConfirmationModalDialog(e); break; default: } @@ -234,18 +240,24 @@ export class SessionCompositionBox extends React.Component { * Check if what is pasted is a URL and prompt confirmation for a setting change * @param e paste event */ - private showLinkSharingConfirmationModalDialog(e: any) { + private async showLinkSharingConfirmationModalDialog(e: any) { const pastedText = e.clipboardData.getData('text'); if (this.isURL(pastedText)) { + const alreadyDisplayedPopup = + (await getItemById(hasLinkPreviewPopupBeenDisplayed))?.value || false; window.inboxStore?.dispatch( updateConfirmModal({ - shouldShowConfirm: !window.getSettingValue('link-preview-setting'), + shouldShowConfirm: + !window.getSettingValue('link-preview-setting') && !alreadyDisplayedPopup, title: window.i18n('linkPreviewsTitle'), message: window.i18n('linkPreviewsConfirmMessage'), okTheme: SessionButtonColor.Danger, onClickOk: () => { window.setSettingValue('link-preview-setting', true); }, + onClickClose: async () => { + await createOrUpdateItem({ id: hasLinkPreviewPopupBeenDisplayed, value: true }); + }, }) ); } 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 e3c6fe40e..5c26c77e7 100644 --- a/ts/components/session/menu/ConversationListItemContextMenu.tsx +++ b/ts/components/session/menu/ConversationListItemContextMenu.tsx @@ -13,6 +13,7 @@ import { getInviteContactMenuItem, getLeaveGroupMenuItem, getMarkAllReadMenuItem, + getPinConversationMenuItem, } from './Menu'; export type PropsContextConversationItem = { @@ -43,12 +44,12 @@ const ConversationListItemContextMenu = (props: PropsContextConversationItem) => const isGroup = type === 'group'; return ( + {getPinConversationMenuItem(conversationId)} {getBlockMenuItem(isMe, type === ConversationTypeEnum.PRIVATE, isBlocked, conversationId)} {getCopyMenuItem(isPublic, isGroup, conversationId)} {getMarkAllReadMenuItem(conversationId)} {getChangeNicknameMenuItem(isMe, isGroup, conversationId)} {getClearNicknameMenuItem(isMe, hasNickname, isGroup, conversationId)} - {getDeleteMessagesMenuItem(isPublic, conversationId)} {getInviteContactMenuItem(isGroup, isPublic, conversationId)} {getDeleteContactMenuItem(isMe, isGroup, isPublic, left, isKickedFromGroup, conversationId)} diff --git a/ts/components/session/menu/Menu.tsx b/ts/components/session/menu/Menu.tsx index 371f5f30f..271e95d01 100644 --- a/ts/components/session/menu/Menu.tsx +++ b/ts/components/session/menu/Menu.tsx @@ -1,10 +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, useSelector } from 'react-redux'; import { changeNickNameModal, updateConfirmModal } from '../../../state/ducks/modalDialog'; +import { SectionType } from '../../../state/ducks/section'; import { getConversationController } from '../../../session/conversations'; import { blockConvoById, @@ -23,6 +26,9 @@ import { } from '../../../interactions/conversationInteractions'; import { SessionButtonColor } from '../SessionButton'; import { getTimerOptions } from '../../../state/selectors/timerOptions'; +import { ToastUtils } from '../../../session/utils'; + +const maxNumberOfPinnedConversations = 5; function showTimerOptions( isPublic: boolean, @@ -119,6 +125,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/components/session/settings/SessionSettings.tsx b/ts/components/session/settings/SessionSettings.tsx index a643f5dff..75bba036e 100644 --- a/ts/components/session/settings/SessionSettings.tsx +++ b/ts/components/session/settings/SessionSettings.tsx @@ -9,7 +9,11 @@ import { StateType } from '../../../state/reducer'; import { getConversationController } from '../../../session/conversations'; import { getConversationLookup } from '../../../state/selectors/conversations'; import { connect, useSelector } from 'react-redux'; -import { getPasswordHash } from '../../../../ts/data/data'; +import { + createOrUpdateItem, + getPasswordHash, + hasLinkPreviewPopupBeenDisplayed, +} from '../../../../ts/data/data'; import { SpacerLG, SpacerXS } from '../../basic/Text'; import { shell } from 'electron'; import { SessionConfirmDialogProps } from '../SessionConfirm'; @@ -339,7 +343,13 @@ class SettingsViewInner extends React.Component { hidden: false, type: SessionSettingType.Toggle, category: SessionSettingCategory.Appearance, - setFn: window.toggleLinkPreview, + setFn: async () => { + const newValue = !window.getSettingValue('link-preview-setting'); + window.setSettingValue('link-preview-setting', newValue); + if (!newValue) { + await createOrUpdateItem({ id: hasLinkPreviewPopupBeenDisplayed, value: false }); + } + }, content: undefined, comparisonValue: undefined, onClick: undefined, diff --git a/ts/data/data.ts b/ts/data/data.ts index fcf464ebc..25e904da5 100644 --- a/ts/data/data.ts +++ b/ts/data/data.ts @@ -64,6 +64,7 @@ export type ServerToken = { export const hasSyncedInitialConfigurationItem = 'hasSyncedInitialConfigurationItem'; export const lastAvatarUploadTimestamp = 'lastAvatarUploadTimestamp'; +export const hasLinkPreviewPopupBeenDisplayed = 'hasLinkPreviewPopupBeenDisplayed'; const channelsToMake = { shutdown, diff --git a/ts/models/conversation.ts b/ts/models/conversation.ts index 8d318026d..290e4c9db 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, @@ -41,11 +40,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'; import { perfEnd, perfStart } from '../session/utils/Performance'; export enum ConversationTypeEnum { @@ -98,6 +93,7 @@ export interface ConversationAttributes { accessKey?: any; triggerNotificationsFor: ConversationNotificationSettingType; isTrustedForAttachmentDownload: boolean; + isPinned: boolean; } export interface ConversationAttributesOptionals { @@ -135,6 +131,7 @@ export interface ConversationAttributesOptionals { accessKey?: any; triggerNotificationsFor?: ConversationNotificationSettingType; isTrustedForAttachmentDownload?: boolean; + isPinned: boolean; } /** @@ -164,6 +161,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, }); }; @@ -436,6 +434,7 @@ export class ConversationModel extends Backbone.Model { members, expireTimer: this.get('expireTimer') || 0, subscriberCount: this.get('subscriberCount') || 0, + isPinned: this.isPinned(), }; } @@ -1122,6 +1121,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) { @@ -1253,6 +1262,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 d9dcd7080..f6205f99d 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -200,6 +200,8 @@ export interface ReduxConversationType { currentNotificationSetting: ConversationNotificationSettingType; notificationForConvo: Array; + + 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 c45a9fdbf..d755da206 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -71,6 +71,14 @@ const collator = new Intl.Collator(); export const _getConversationComparator = (testingi18n?: LocalizerType) => { return (left: ReduxConversationType, right: ReduxConversationType): 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) { @@ -244,3 +252,8 @@ export const getConversationHeaderProps = createSelector(getSelectedConversation isGroup: state.isGroup, }; }); + +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..bd24f567d --- /dev/null +++ b/ts/test/session/unit/selectors/conversations_test.ts @@ -0,0 +1,349 @@ +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', () => { + // tslint:disable-next-line: max-func-body-length + 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, + hasNickname: false, + isPublic: false, + subscriberCount: 0, + currentNotificationSetting: 'all', + weAreAdmin: false, + isGroup: false, + isPrivate: false, + notificationForConvo: [{ value: 'all', name: 'all' }], + avatarPath: '', + groupAdmins: [], + lastMessage: undefined, + members: [], + profileName: 'df', + expireTimer: 0, + 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, + hasNickname: false, + isPublic: false, + subscriberCount: 0, + currentNotificationSetting: 'all', + weAreAdmin: false, + isGroup: false, + isPrivate: false, + notificationForConvo: [{ value: 'all', name: 'all' }], + avatarPath: '', + groupAdmins: [], + lastMessage: undefined, + members: [], + profileName: 'df', + expireTimer: 0, + 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, + hasNickname: false, + isPublic: false, + subscriberCount: 0, + currentNotificationSetting: 'all', + weAreAdmin: false, + isGroup: false, + isPrivate: false, + notificationForConvo: [{ value: 'all', name: 'all' }], + avatarPath: '', + groupAdmins: [], + lastMessage: undefined, + members: [], + profileName: 'df', + expireTimer: 0, + 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, + hasNickname: false, + isPublic: false, + subscriberCount: 0, + currentNotificationSetting: 'all', + weAreAdmin: false, + isGroup: false, + isPrivate: false, + notificationForConvo: [{ value: 'all', name: 'all' }], + avatarPath: '', + groupAdmins: [], + expireTimer: 0, + lastMessage: undefined, + members: [], + profileName: 'df', + 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, + hasNickname: false, + isPublic: false, + subscriberCount: 0, + expireTimer: 0, + currentNotificationSetting: 'all', + weAreAdmin: false, + isGroup: false, + isPrivate: false, + notificationForConvo: [{ value: 'all', name: 'all' }], + avatarPath: '', + groupAdmins: [], + lastMessage: undefined, + members: [], + profileName: 'df', + 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', () => { + // 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; + 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, + subscriberCount: 0, + expireTimer: 0, + currentNotificationSetting: 'all', + weAreAdmin: false, + isGroup: false, + isPrivate: false, + notificationForConvo: [{ value: 'all', name: 'all' }], + avatarPath: '', + groupAdmins: [], + lastMessage: undefined, + members: [], + profileName: 'df', + isPinned: false, + hasNickname: false, + isPublic: 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, + subscriberCount: 0, + expireTimer: 0, + currentNotificationSetting: 'all', + weAreAdmin: false, + isGroup: false, + isPrivate: false, + notificationForConvo: [{ value: 'all', name: 'all' }], + avatarPath: '', + groupAdmins: [], + lastMessage: undefined, + members: [], + profileName: 'df', + isPinned: false, + hasNickname: false, + isPublic: 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, + subscriberCount: 0, + expireTimer: 0, + currentNotificationSetting: 'all', + weAreAdmin: false, + isGroup: false, + isPrivate: false, + notificationForConvo: [{ value: 'all', name: 'all' }], + avatarPath: '', + groupAdmins: [], + lastMessage: undefined, + members: [], + profileName: 'df', + isPinned: true, + hasNickname: false, + isPublic: 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, + subscriberCount: 0, + expireTimer: 0, + currentNotificationSetting: 'all', + weAreAdmin: false, + isGroup: false, + isPrivate: false, + notificationForConvo: [{ value: 'all', name: 'all' }], + avatarPath: '', + groupAdmins: [], + lastMessage: undefined, + members: [], + profileName: 'df', + isPinned: true, + hasNickname: false, + isPublic: 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, + + subscriberCount: 0, + expireTimer: 0, + currentNotificationSetting: 'all', + weAreAdmin: false, + isGroup: false, + isPrivate: false, + notificationForConvo: [{ value: 'all', name: 'all' }], + avatarPath: '', + groupAdmins: [], + lastMessage: undefined, + members: [], + profileName: 'df', + isPinned: false, + hasNickname: false, + isPublic: 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 831176444..000000000 --- a/ts/test/state/selectors/conversations_test.ts +++ /dev/null @@ -1,174 +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', () => { - // tslint:disable-next-line: max-func-body-length - 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, - hasNickname: false, - isPublic: false, - subscriberCount: 0, - currentNotificationSetting: 'all', - weAreAdmin: false, - isGroup: false, - isPrivate: false, - notificationForConvo: [{ value: 'all', name: 'all' }], - avatarPath: '', - groupAdmins: [], - lastMessage: undefined, - members: [], - profileName: 'df', - expireTimer: 0, - }, - 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, - hasNickname: false, - isPublic: false, - subscriberCount: 0, - currentNotificationSetting: 'all', - weAreAdmin: false, - isGroup: false, - isPrivate: false, - notificationForConvo: [{ value: 'all', name: 'all' }], - avatarPath: '', - groupAdmins: [], - lastMessage: undefined, - members: [], - profileName: 'df', - expireTimer: 0, - }, - 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, - hasNickname: false, - isPublic: false, - subscriberCount: 0, - currentNotificationSetting: 'all', - weAreAdmin: false, - isGroup: false, - isPrivate: false, - notificationForConvo: [{ value: 'all', name: 'all' }], - avatarPath: '', - groupAdmins: [], - lastMessage: undefined, - members: [], - profileName: 'df', - expireTimer: 0, - }, - 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, - hasNickname: false, - isPublic: false, - subscriberCount: 0, - currentNotificationSetting: 'all', - weAreAdmin: false, - isGroup: false, - isPrivate: false, - notificationForConvo: [{ value: 'all', name: 'all' }], - avatarPath: '', - groupAdmins: [], - expireTimer: 0, - lastMessage: undefined, - members: [], - profileName: 'df', - }, - 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, - hasNickname: false, - isPublic: false, - subscriberCount: 0, - expireTimer: 0, - currentNotificationSetting: 'all', - weAreAdmin: false, - isGroup: false, - isPrivate: false, - notificationForConvo: [{ value: 'all', name: 'all' }], - avatarPath: '', - groupAdmins: [], - lastMessage: undefined, - members: [], - profileName: 'df', - }, - }; - 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 f8e7490ab..c31b16749 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,10 +57,8 @@ declare global { getSeedNodeList: () => Array | undefined; setPassword: any; setSettingValue: any; - showResetSessionIdDialog: any; storage: any; textsecure: LibTextsecure; - toggleLinkPreview: any; toggleMediaPermissions: any; toggleMenuBar: any; toggleSpellCheck: any; @@ -79,8 +78,6 @@ declare global { expired: (boolean) => void; expiredStatus: () => boolean; }; - lightTheme: DefaultTheme; - darkTheme: DefaultTheme; LokiPushNotificationServer: any; globalOnlineStatus: boolean; confirmationDialog: any;