From f415ef36ddaba3f1060e7291fc2fd8ddee8b1f6b Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Wed, 17 Aug 2022 09:37:29 +1000 Subject: [PATCH] feat: add sorted contacts list merged by starting char --- stylesheets/_global.scss | 1 + .../overlay/choose-action/ContactRow.tsx | 97 +++++++++++++++++++ .../choose-action/ContactsListWithBreaks.tsx | 49 +++++++--- ts/state/ducks/SessionTheme.tsx | 3 +- ts/state/selectors/call.ts | 38 +++----- ts/state/selectors/conversations.ts | 73 +++++++++----- 6 files changed, 199 insertions(+), 62 deletions(-) create mode 100644 ts/components/leftpane/overlay/choose-action/ContactRow.tsx diff --git a/stylesheets/_global.scss b/stylesheets/_global.scss index b4b46e8cb..658071d55 100644 --- a/stylesheets/_global.scss +++ b/stylesheets/_global.scss @@ -55,6 +55,7 @@ audio { button { cursor: pointer; font-size: inherit; + border: none; &[disabled='disabled'] { &, diff --git a/ts/components/leftpane/overlay/choose-action/ContactRow.tsx b/ts/components/leftpane/overlay/choose-action/ContactRow.tsx new file mode 100644 index 000000000..d85e25e05 --- /dev/null +++ b/ts/components/leftpane/overlay/choose-action/ContactRow.tsx @@ -0,0 +1,97 @@ +import React from 'react'; +import { useDispatch } from 'react-redux'; +import styled, { CSSProperties } from 'styled-components'; +import { useAvatarPath } from '../../../../hooks/useParamSelector'; +import { openConversationWithMessages } from '../../../../state/ducks/conversations'; +import { updateUserDetailsModal } from '../../../../state/ducks/modalDialog'; +import { Avatar, AvatarSize } from '../../../avatar/Avatar'; + +type Props = { id: string; displayName?: string; style: CSSProperties }; + +const StyledAvatarItem = styled.div` + padding-right: var(--margins-sm); +`; + +const AvatarItem = (props: Pick) => { + const { id, displayName } = props; + + const avatarPath = useAvatarPath(id); + const dispatch = useDispatch(); + function onPrivateAvatarClick() { + dispatch( + updateUserDetailsModal({ + conversationId: id, + userName: displayName || '', + authorAvatarPath: avatarPath, + }) + ); + } + + return ( + + + + ); +}; + +const StyledContactRowName = styled.div` + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + /* font-weight: 600; */ + font-size: var(--font-size-lg); +`; + +const StyledRowContainer = styled.div` + display: flex; + align-items: center; + padding: 0 var(--margins-lg); + + transition: background-color var(--default-duration) linear; + cursor: pointer; + + border-bottom: 1px var(--color-session-border) solid; + + &:first-child { + border-top: 1px var(--color-session-border) solid; + } + + :hover { + background-color: var(--color-clickable-hovered); + } +`; + +export const ContactRow = (props: Props) => { + const { id, style, displayName } = props; + + return ( + openConversationWithMessages({ conversationKey: id, messageId: null })} + > + + {displayName || id} + + ); +}; + +const StyledBreak = styled.div` + display: flex; + align-items: center; + padding: 0 var(--margins-lg); + color: var(--color-text-subtle); + font-size: var(--font-size-md); + height: 25px; // should also be changed in rowHeight + + border-bottom: 1px var(--color-session-border) solid; +`; + +export const ContactRowBreak = (props: { char: string; key: string; style: CSSProperties }) => { + const { char, key, style } = props; + + return ( + + {char} + + ); +}; diff --git a/ts/components/leftpane/overlay/choose-action/ContactsListWithBreaks.tsx b/ts/components/leftpane/overlay/choose-action/ContactsListWithBreaks.tsx index 8df435569..625392edb 100644 --- a/ts/components/leftpane/overlay/choose-action/ContactsListWithBreaks.tsx +++ b/ts/components/leftpane/overlay/choose-action/ContactsListWithBreaks.tsx @@ -1,13 +1,15 @@ +import { isString } from 'lodash'; import React from 'react'; import { useSelector } from 'react-redux'; -import { AutoSizer, List, ListRowProps } from 'react-virtualized'; +import { AutoSizer, Index, List, ListRowProps } from 'react-virtualized'; import styled from 'styled-components'; import { - getDirectContacts, + DirectContactsByNameType, + getDirectContactsByName, getDirectContactsCount, } from '../../../../state/selectors/conversations'; -import { MemoConversationListItemWithDetails } from '../../conversation-list-item/ConversationListItem'; import { StyledLeftPaneList } from '../../LeftPaneList'; +import { ContactRow, ContactRowBreak } from './ContactRow'; import { StyledChooseActionTitle } from './OverlayChooseAction'; // tslint:disable: use-simple-attributes no-submodule-imports @@ -16,23 +18,45 @@ const renderRow = (props: ListRowProps) => { // ugly, but it seems react-viurtualized do not support very well functional components just yet // https://stackoverflow.com/questions/54488954/how-to-pass-prop-into-rowrender-of-react-virtualized - const directContacts = (parent as any).props.directContacts; - const item = directContacts?.[index]; + const directContactsByNameWithBreaks = (parent as any).props + .directContactsByNameWithBreaks as Array; + const item = directContactsByNameWithBreaks?.[index]; if (!item) { return null; } - return ; + if (isString(item)) { + return ; + } + + return ; }; +const unknownSection = 'unknown'; + const ContactListItemSection = () => { - const directContacts = useSelector(getDirectContacts); + const directContactsByName = useSelector(getDirectContactsByName); - if (!directContacts) { + if (!directContactsByName) { return null; } - const length = Number(directContacts.length); + // add a break wherever needed + let currentChar = ''; + // if the item is a string we consider it to be a break of that string + const directContactsByNameWithBreaks: Array = []; + directContactsByName.forEach(m => { + if (m.displayName && m.displayName[0] !== currentChar) { + currentChar = m.displayName[0]; + directContactsByNameWithBreaks.push(currentChar.toUpperCase()); + } else if (!m.displayName && currentChar !== unknownSection) { + currentChar = unknownSection; + directContactsByNameWithBreaks.push(window.i18n('unknown')); + } + directContactsByNameWithBreaks.push(m); + }); + + const length = Number(directContactsByNameWithBreaks.length); return ( @@ -43,8 +67,11 @@ const ContactListItemSection = () => { className="module-left-pane__virtual-list" height={height} rowCount={length} - rowHeight={64} - directContacts={directContacts} + rowHeight={ + (params: Index) => + isString(directContactsByNameWithBreaks[params.index]) ? 25 : 64 // should also be changed in `ContactRowBreak` + } + directContactsByNameWithBreaks={directContactsByNameWithBreaks} rowRenderer={renderRow} width={300} // the same as session-left-pane-width autoHeight={false} diff --git a/ts/state/ducks/SessionTheme.tsx b/ts/state/ducks/SessionTheme.tsx index 52f0ac55e..518c56f61 100644 --- a/ts/state/ducks/SessionTheme.tsx +++ b/ts/state/ducks/SessionTheme.tsx @@ -323,6 +323,7 @@ export const SessionGlobalStyles = createGlobalStyle` --font-size-xs: 11px; --font-size-sm: 13px; --font-size-md: 15px; + --font-size-lg: 17px; /* MARGINS */ --margins-xs: 5px; @@ -331,7 +332,7 @@ export const SessionGlobalStyles = createGlobalStyle` --margins-lg: 20px; /* ANIMATIONS */ - --default-duration: '0.25s'; + --default-duration: 0.25s; /* FILTERS */ --filter-session-text: ${lightFilterSessionText}; /* BORDERS */ diff --git a/ts/state/selectors/call.ts b/ts/state/selectors/call.ts index 788f4f6e1..4ee1b9e54 100644 --- a/ts/state/selectors/call.ts +++ b/ts/state/selectors/call.ts @@ -4,21 +4,16 @@ import { ConversationsStateType, ReduxConversationType } from '../ducks/conversa import { StateType } from '../reducer'; import { getConversations, getSelectedConversationKey } from './conversations'; -export const getCallState = (state: StateType): CallStateType => state.call; +const getCallState = (state: StateType): CallStateType => state.call; // --- INCOMING CALLS -export const getHasIncomingCallFrom = createSelector(getCallState, (state: CallStateType): - | string - | undefined => { - return state.ongoingWith && state.ongoingCallStatus === 'incoming' - ? state.ongoingWith +export const getHasIncomingCallFrom = (state: StateType) => { + return state.call.ongoingWith && state.call.ongoingCallStatus === 'incoming' + ? state.call.ongoingWith : undefined; -}); +}; -export const getHasIncomingCall = createSelector( - getHasIncomingCallFrom, - (withConvo: string | undefined): boolean => !!withConvo -); +export const getHasIncomingCall = (state: StateType) => !!getHasIncomingCallFrom(state); // --- ONGOING CALLS export const getHasOngoingCallWith = createSelector( @@ -55,21 +50,14 @@ export const getHasOngoingCallWithFocusedConvo = createSelector( } ); -const getCallStateWithFocusedConvo = createSelector( - getCallState, - getSelectedConversationKey, - (callState: CallStateType, selectedConvoPubkey?: string): CallStatusEnum => { - if ( - selectedConvoPubkey && - callState.ongoingWith && - selectedConvoPubkey === callState.ongoingWith - ) { - return callState.ongoingCallStatus; - } - - return undefined; +const getCallStateWithFocusedConvo = (state: StateType): CallStatusEnum => { + const selected = state.conversations.selectedConversation; + const ongoingWith = state.call.ongoingWith; + if (selected && ongoingWith && selected === ongoingWith) { + return state.call.ongoingCallStatus; } -); + return undefined; +}; export const getCallWithFocusedConvoIsOffering = createSelector( getCallStateWithFocusedConvo, diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index 831e6047c..2a571b4e0 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -12,12 +12,11 @@ import { SortedMessageModelProps, } from '../ducks/conversations'; -import { getIntl, getOurNumber } from './user'; +import { getIntl } from './user'; import { BlockedNumberController } from '../../util'; import { ConversationModel } from '../../models/conversation'; import { LocalizerType } from '../../types/Util'; import { ConversationHeaderTitleProps } from '../../components/conversation/ConversationHeader'; -import _ from 'lodash'; import { ReplyingToMessageProps } from '../../components/conversation/composition/CompositionBox'; import { MessageAttachmentSelectorProps } from '../../components/conversation/message/message-content/MessageAttachment'; import { MessageAuthorSelectorProps } from '../../components/conversation/message/message-content/MessageAuthorText'; @@ -35,6 +34,7 @@ import { getConversationController } from '../../session/conversations'; import { UserUtils } from '../../session/utils'; import { Storage } from '../../util/storage'; import { ConversationTypeEnum } from '../../models/conversationAttributes'; +import { filter, isEmpty, sortBy } from 'lodash'; export const getConversations = (state: StateType): ConversationsStateType => state.conversations; @@ -456,7 +456,7 @@ export const getSortedConversations = createSelector( const _getConversationRequests = ( sortedConversations: Array ): Array => { - return _.filter(sortedConversations, conversation => { + return filter(sortedConversations, conversation => { const { isApproved, isBlocked, isPrivate, isMe, activeAt } = conversation; const isRequest = ConversationModel.hasValidIncomingRequestValues({ isApproved, @@ -477,7 +477,7 @@ export const getConversationRequests = createSelector( const _getUnreadConversationRequests = ( sortedConversationRequests: Array ): Array => { - return _.filter(sortedConversationRequests, conversation => { + return filter(sortedConversationRequests, conversation => { return conversation && conversation.unreadCount && conversation.unreadCount > 0; }); }; @@ -490,7 +490,7 @@ export const getUnreadConversationRequests = createSelector( const _getPrivateContactsPubkeys = ( sortedConversations: Array ): Array => { - return _.filter(sortedConversations, conversation => { + return filter(sortedConversations, conversation => { return ( conversation.isPrivate && !conversation.isBlocked && @@ -517,13 +517,6 @@ export const getPrivateContactsPubkeys = createSelector( export const getLeftPaneLists = createSelector(getSortedConversations, _getLeftPaneLists); -export const getMe = createSelector( - [getConversationLookup, getOurNumber], - (lookup: ConversationLookupType, ourNumber: string): ReduxConversationType => { - return lookup[ourNumber]; - } -); - export const getDirectContacts = createSelector( getLeftPaneLists, (state: { @@ -538,6 +531,36 @@ export const getDirectContactsCount = createSelector( (contacts: Array) => contacts.length ); +export type DirectContactsByNameType = { + displayName?: string; + id: string; +}; + +// make sure that createSelector is called here so this function is memoized +export const getDirectContactsByName = createSelector( + getDirectContacts, + (contacts: Array): Array => { + const extractedContacts = contacts + .filter(m => m.id !== UserUtils.getOurPubKeyStrFromCache()) + .map(m => { + return { + id: m.id, + displayName: m.nickname || m.displayNameInProfile, + }; + }); + const extractedContactsNoDisplayName = sortBy( + extractedContacts.filter(m => !m.displayName), + 'id' + ); + const extractedContactsWithDisplayName = sortBy( + extractedContacts.filter(m => Boolean(m.displayName)), + 'displayName' + ); + + return [...extractedContactsWithDisplayName, ...extractedContactsNoDisplayName]; + } +); + export const getUnreadMessageCount = createSelector(getLeftPaneLists, (state): number => { return state.unreadCount; }); @@ -885,7 +908,7 @@ export const getMessagePropsByMessageId = createSelector( export const getMessageAvatarProps = createSelector(getMessagePropsByMessageId, (props): | MessageAvatarSelectorProps | undefined => { - if (!props || _.isEmpty(props)) { + if (!props || isEmpty(props)) { return undefined; } @@ -920,7 +943,7 @@ export const getMessageAvatarProps = createSelector(getMessagePropsByMessageId, export const getMessagePreviewProps = createSelector(getMessagePropsByMessageId, (props): | MessagePreviewSelectorProps | undefined => { - if (!props || _.isEmpty(props)) { + if (!props || isEmpty(props)) { return undefined; } @@ -937,7 +960,7 @@ export const getMessagePreviewProps = createSelector(getMessagePropsByMessageId, export const getMessageQuoteProps = createSelector(getMessagePropsByMessageId, (props): | MessageQuoteSelectorProps | undefined => { - if (!props || _.isEmpty(props)) { + if (!props || isEmpty(props)) { return undefined; } @@ -954,7 +977,7 @@ export const getMessageQuoteProps = createSelector(getMessagePropsByMessageId, ( export const getMessageStatusProps = createSelector(getMessagePropsByMessageId, (props): | MessageStatusSelectorProps | undefined => { - if (!props || _.isEmpty(props)) { + if (!props || isEmpty(props)) { return undefined; } @@ -971,7 +994,7 @@ export const getMessageStatusProps = createSelector(getMessagePropsByMessageId, export const getMessageTextProps = createSelector(getMessagePropsByMessageId, (props): | MessageTextSelectorProps | undefined => { - if (!props || _.isEmpty(props)) { + if (!props || isEmpty(props)) { return undefined; } @@ -991,7 +1014,7 @@ export const getMessageTextProps = createSelector(getMessagePropsByMessageId, (p export const getMessageContextMenuProps = createSelector(getMessagePropsByMessageId, (props): | MessageContextMenuSelectorProps | undefined => { - if (!props || _.isEmpty(props)) { + if (!props || isEmpty(props)) { return undefined; } @@ -1037,7 +1060,7 @@ export const getMessageContextMenuProps = createSelector(getMessagePropsByMessag export const getMessageAuthorProps = createSelector(getMessagePropsByMessageId, (props): | MessageAuthorSelectorProps | undefined => { - if (!props || _.isEmpty(props)) { + if (!props || isEmpty(props)) { return undefined; } @@ -1058,7 +1081,7 @@ export const getMessageAuthorProps = createSelector(getMessagePropsByMessageId, export const getMessageIsDeletable = createSelector( getMessagePropsByMessageId, (props): boolean => { - if (!props || _.isEmpty(props)) { + if (!props || isEmpty(props)) { return false; } @@ -1069,7 +1092,7 @@ export const getMessageIsDeletable = createSelector( export const getMessageAttachmentProps = createSelector(getMessagePropsByMessageId, (props): | MessageAttachmentSelectorProps | undefined => { - if (!props || _.isEmpty(props)) { + if (!props || isEmpty(props)) { return undefined; } @@ -1099,7 +1122,7 @@ export const getIsMessageSelected = createSelector( getMessagePropsByMessageId, getSelectedMessageIds, (props, selectedIds): boolean => { - if (!props || _.isEmpty(props)) { + if (!props || isEmpty(props)) { return false; } @@ -1112,7 +1135,7 @@ export const getIsMessageSelected = createSelector( export const getMessageContentSelectorProps = createSelector(getMessagePropsByMessageId, (props): | MessageContentSelectorProps | undefined => { - if (!props || _.isEmpty(props)) { + if (!props || isEmpty(props)) { return undefined; } @@ -1145,7 +1168,7 @@ export const getMessageContentSelectorProps = createSelector(getMessagePropsByMe export const getMessageContentWithStatusesSelectorProps = createSelector( getMessagePropsByMessageId, (props): MessageContentWithStatusSelectorProps | undefined => { - if (!props || _.isEmpty(props)) { + if (!props || isEmpty(props)) { return undefined; } @@ -1170,7 +1193,7 @@ export const getMessageContentWithStatusesSelectorProps = createSelector( export const getGenericReadableMessageSelectorProps = createSelector( getMessagePropsByMessageId, (props): GenericReadableMessageSelectorProps | undefined => { - if (!props || _.isEmpty(props)) { + if (!props || isEmpty(props)) { return undefined; }