diff --git a/package.json b/package.json index 32c0cc218..c701e3e2d 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,6 @@ "getobject": "^1.0.0", "ansi-regex": "^4.1.1", "async": "^2.6.4", - "moment": "^2.29.4", "lodash": "^4.17.20", "ini": "^1.3.6", "ejs": "^3.1.7", @@ -111,7 +110,6 @@ "long": "^4.0.0", "maxmind": "^4.3.18", "mic-recorder-to-mp3": "^2.2.2", - "moment": "^2.29.4", "node-fetch": "^2.6.7", "os-locale": "5.0.0", "p-retry": "^4.2.0", diff --git a/ts/components/SessionInboxView.tsx b/ts/components/SessionInboxView.tsx index 55af6b515..19710cffc 100644 --- a/ts/components/SessionInboxView.tsx +++ b/ts/components/SessionInboxView.tsx @@ -1,5 +1,4 @@ import { fromPairs, map } from 'lodash'; -import moment from 'moment'; import { Provider } from 'react-redux'; import useMount from 'react-use/lib/useMount'; @@ -40,7 +39,6 @@ import { SessionTheme } from '../themes/SessionTheme'; import { Storage } from '../util/storage'; import { NoticeBanner } from './NoticeBanner'; import { Flex } from './basic/Flex'; -import { getLocale } from '../util/i18n'; function makeLookup(items: Array, key: string): { [key: string]: T } { // Yep, we can't index into item without knowing what it is. True. But we want to. @@ -49,11 +47,6 @@ function makeLookup(items: Array, key: string): { [key: string]: T } { return fromPairs(pairs); } -// Default to the locale from env. It will be overridden if moment -// does not recognize it with what moment knows which is the closest. -// i.e. es-419 will return 'es'. -// We just need to use what we got from moment in getLocale on the updateLocale below -moment.locale(getLocale()); const StyledGutter = styled.div` width: var(--left-panel-width) !important; diff --git a/ts/components/calling/InConversationCallContainer.tsx b/ts/components/calling/InConversationCallContainer.tsx index 0cf99d87f..c4fa061fe 100644 --- a/ts/components/calling/InConversationCallContainer.tsx +++ b/ts/components/calling/InConversationCallContainer.tsx @@ -1,7 +1,6 @@ import { useRef, useState } from 'react'; import { useSelector } from 'react-redux'; -import moment from 'moment'; import useInterval from 'react-use/lib/useInterval'; import styled from 'styled-components'; import { CallManager, UserUtils } from '../../session/utils'; @@ -21,6 +20,7 @@ import { useVideoCallEventsListener } from '../../hooks/useVideoEventListener'; import { DEVICE_DISABLED_DEVICE_ID } from '../../session/utils/calling/CallManager'; import { CallWindowControls } from './CallButtons'; +import { useFormattedDuration } from '../../hooks/useFormattedDuration'; import { SessionSpinner } from '../loading'; const VideoContainer = styled.div` @@ -95,7 +95,7 @@ const ConnectingLabel = () => { }; const DurationLabel = () => { - const [callDuration, setCallDuration] = useState(undefined); + const [callDurationSeconds, setCallDuration] = useState(0); const ongoingCallWithFocusedIsConnected = useSelector(getCallWithFocusedConvosIsConnected); useInterval(() => { @@ -104,15 +104,12 @@ const DurationLabel = () => { setCallDuration(duration); } }, 100); + const dateString = useFormattedDuration(callDurationSeconds, { forceHours: true }); - if (!ongoingCallWithFocusedIsConnected || !callDuration || callDuration < 0) { + if (!ongoingCallWithFocusedIsConnected || !callDurationSeconds || callDurationSeconds < 0) { return null; } - const ms = callDuration * 1000; - const d = moment.duration(ms); - - const dateString = Math.floor(d.asHours()) + moment.utc(ms).format(':mm:ss'); return {dateString}; }; diff --git a/ts/components/conversation/SessionRecording.tsx b/ts/components/conversation/SessionRecording.tsx index 82ac335f7..e2dcf97a8 100644 --- a/ts/components/conversation/SessionRecording.tsx +++ b/ts/components/conversation/SessionRecording.tsx @@ -1,7 +1,6 @@ /* eslint-disable @typescript-eslint/no-misused-promises */ import classNames from 'classnames'; -import moment from 'moment'; import autoBind from 'auto-bind'; import MicRecorder from 'mic-recorder-to-mp3'; @@ -11,6 +10,7 @@ import { Constants } from '../../session'; import { MAX_ATTACHMENT_FILESIZE_BYTES } from '../../session/constants'; import { ToastUtils } from '../../session/utils'; import { SessionIconButton } from '../icon'; +import { useFormattedDuration } from '../../hooks/useFormattedDuration'; interface Props { onExitVoiceNoteView: () => void; @@ -76,6 +76,36 @@ const StyledFlexWrapper = styled.div` } `; +function RecordingDurations({ + isRecording, + displaySeconds, + remainingSeconds, +}: { + isRecording: boolean; + displaySeconds: number; + remainingSeconds: number; +}) { + const displayTimeString = useFormattedDuration(displaySeconds, { forceHours: false }); + const remainingTimeString = useFormattedDuration(remainingSeconds, { forceHours: false }); + + return ( +
+ {displayTimeString + (remainingTimeString ? ` / ${remainingTimeString}` : '')} +
+ ); +} + +function RecordingTimer({ displaySeconds }: { displaySeconds: number }) { + const displayTimeString = useFormattedDuration(displaySeconds, { forceHours: false }); + + return ( +
+ {displayTimeString} + +
+ ); +} + export class SessionRecording extends Component { private recorder?: any; private audioBlobMp3?: Blob; @@ -134,14 +164,8 @@ export class SessionRecording extends Component { (this.audioElement.currentTime * 1000 || this.audioElement?.duration)) || 0; - const displayTimeString = moment.utc(displayTimeMs).format('m:ss'); const recordingDurationMs = this.audioElement?.duration ? this.audioElement.duration * 1000 : 1; - let remainingTimeString = ''; - if (recordingDurationMs !== undefined) { - remainingTimeString = ` / ${moment.utc(recordingDurationMs).format('m:ss')}`; - } - const actionPauseFn = isPlaying ? this.pauseAudio : this.stopRecordingStream; return ( @@ -176,17 +200,14 @@ export class SessionRecording extends Component { {hasRecording && !isRecording ? ( -
- {displayTimeString + remainingTimeString} -
+ ) : null} - {isRecording ? ( -
- {displayTimeString} - -
- ) : null} + {isRecording ? : null} {!isRecording && (
diff --git a/ts/components/conversation/Timestamp.tsx b/ts/components/conversation/Timestamp.tsx index a46b1445c..1ebf4dd44 100644 --- a/ts/components/conversation/Timestamp.tsx +++ b/ts/components/conversation/Timestamp.tsx @@ -1,14 +1,15 @@ -import moment from 'moment'; - import useInterval from 'react-use/lib/useInterval'; import useUpdate from 'react-use/lib/useUpdate'; import styled from 'styled-components'; import { CONVERSATION } from '../../session/constants'; +import { formatFullDate, getConversationItemString } from '../../util/i18n'; type Props = { timestamp?: number; - isConversationListItem?: boolean; - momentFromNow: boolean; + /** + * We display the timestamp differently (UI) when displaying a search result + */ + isConversationSearchResult: boolean; }; const UPDATE_FREQUENCY = 60 * 1000; @@ -34,7 +35,7 @@ export const Timestamp = (props: Props) => { const update = useUpdate(); useInterval(update, UPDATE_FREQUENCY); - const { timestamp, momentFromNow } = props; + const { timestamp, isConversationSearchResult } = props; if (timestamp === null || timestamp === undefined) { return null; @@ -44,17 +45,12 @@ export const Timestamp = (props: Props) => { let dateString = ''; if (timestamp !== CONVERSATION.LAST_JOINED_FALLBACK_TIMESTAMP) { - const momentValue = moment(timestamp); - // this is a hack to make the date string shorter, looks like moment does not have a localized way of doing this for now. - - dateString = momentFromNow - ? momentValue.fromNow().replace('minutes', 'mins').replace('minute', 'min') - : momentValue.format('lll'); + dateString = getConversationItemString(new Date(timestamp)); - title = moment(timestamp).format('llll'); + title = formatFullDate(new Date(timestamp)); } - if (props.isConversationListItem) { + if (isConversationSearchResult) { return {dateString}; } return {dateString}; diff --git a/ts/components/conversation/media-gallery/DocumentListItem.tsx b/ts/components/conversation/media-gallery/DocumentListItem.tsx index 651d5b60e..d3f14e78d 100644 --- a/ts/components/conversation/media-gallery/DocumentListItem.tsx +++ b/ts/components/conversation/media-gallery/DocumentListItem.tsx @@ -1,12 +1,11 @@ import classNames from 'classnames'; import { useCallback } from 'react'; -import moment from 'moment'; - import formatFileSize from 'filesize'; import { useSelectedConversationKey } from '../../../state/selectors/selectedConversation'; import { saveAttachmentToDisk } from '../../../util/attachmentsUtil'; import { MediaItemType } from '../../lightbox/LightboxGallery'; +import { formatWithLocale } from '../../../util/i18n'; type Props = { // Required @@ -64,7 +63,7 @@ export const DocumentListItem = (props: Props) => {
- {moment(timestamp).format('ddd, MMM D, Y')} + {formatWithLocale({ date: new Date(timestamp), formatStr: 'ddd, MMM D, Y' })}
diff --git a/ts/components/conversation/media-gallery/groupMediaItemsByDate.ts b/ts/components/conversation/media-gallery/groupMediaItemsByDate.ts deleted file mode 100644 index 032117285..000000000 --- a/ts/components/conversation/media-gallery/groupMediaItemsByDate.ts +++ /dev/null @@ -1,142 +0,0 @@ -import moment from 'moment'; -import { compact, groupBy, sortBy } from 'lodash'; -import { MediaItemType } from '../../lightbox/LightboxGallery'; - -// import { missingCaseError } from '../../../util/missingCaseError'; - -type StaticSectionType = 'today' | 'yesterday' | 'thisWeek' | 'thisMonth'; -type YearMonthSectionType = 'yearMonth'; - -interface GenericSection { - type: T; - mediaItems: Array; -} - -type StaticSection = GenericSection; -type YearMonthSection = GenericSection & { - year: number; - month: number; -}; -export type Section = StaticSection | YearMonthSection; -export const groupMediaItemsByDate = ( - timestamp: number, - mediaItems: Array -): Array
=> { - // TODO: find where this is used and change to use date-fns - const referenceDateTime = moment.utc(timestamp); - - const sortedMediaItem = sortBy(mediaItems, mediaItem => { - const { messageTimestamp } = mediaItem; - - return -messageTimestamp; - }); - const messagesWithSection = sortedMediaItem.map(withSection(referenceDateTime)); - const groupedMediaItem = groupBy(messagesWithSection, 'type'); - const yearMonthMediaItem = Object.values(groupBy(groupedMediaItem.yearMonth, 'order')).reverse(); - - return compact([ - toSection(groupedMediaItem.today), - toSection(groupedMediaItem.yesterday), - toSection(groupedMediaItem.thisWeek), - toSection(groupedMediaItem.thisMonth), - ...yearMonthMediaItem.map(toSection), - ]); -}; - -const toSection = ( - messagesWithSection: Array | undefined -): Section | undefined => { - if (!messagesWithSection || messagesWithSection.length === 0) { - return undefined; - } - - const firstMediaItemWithSection: MediaItemWithSection = messagesWithSection[0]; - if (!firstMediaItemWithSection) { - return undefined; - } - - const mediaItems = messagesWithSection.map(messageWithSection => messageWithSection.mediaItem); - switch (firstMediaItemWithSection.type) { - case 'today': - case 'yesterday': - case 'thisWeek': - case 'thisMonth': - return { - type: firstMediaItemWithSection.type, - mediaItems, - }; - case 'yearMonth': - return { - type: firstMediaItemWithSection.type, - year: firstMediaItemWithSection.year, - month: firstMediaItemWithSection.month, - mediaItems, - }; - default: - return undefined; - } -}; - -interface GenericMediaItemWithSection { - order: number; - type: T; - mediaItem: MediaItemType; -} - -type MediaItemWithStaticSection = GenericMediaItemWithSection; -type MediaItemWithYearMonthSection = GenericMediaItemWithSection & { - year: number; - month: number; -}; -type MediaItemWithSection = MediaItemWithStaticSection | MediaItemWithYearMonthSection; - -const withSection = - (referenceDateTime: moment.Moment) => - (mediaItem: MediaItemType): MediaItemWithSection => { - const today = moment(referenceDateTime).startOf('day'); - const yesterday = moment(referenceDateTime).subtract(1, 'day').startOf('day'); - const thisWeek = moment(referenceDateTime).startOf('isoWeek'); - const thisMonth = moment(referenceDateTime).startOf('month'); - - const { messageTimestamp } = mediaItem; - const mediaItemReceivedDate = moment.utc(messageTimestamp); - if (mediaItemReceivedDate.isAfter(today)) { - return { - order: 0, - type: 'today', - mediaItem, - }; - } - if (mediaItemReceivedDate.isAfter(yesterday)) { - return { - order: 1, - type: 'yesterday', - mediaItem, - }; - } - if (mediaItemReceivedDate.isAfter(thisWeek)) { - return { - order: 2, - type: 'thisWeek', - mediaItem, - }; - } - if (mediaItemReceivedDate.isAfter(thisMonth)) { - return { - order: 3, - type: 'thisMonth', - mediaItem, - }; - } - - const month: number = mediaItemReceivedDate.month(); - const year: number = mediaItemReceivedDate.year(); - - return { - order: year * 100 + month, - type: 'yearMonth', - month, - year, - mediaItem, - }; - }; diff --git a/ts/components/conversation/message/message-content/MessageContent.tsx b/ts/components/conversation/message/message-content/MessageContent.tsx index 1f0c87219..274703551 100644 --- a/ts/components/conversation/message/message-content/MessageContent.tsx +++ b/ts/components/conversation/message/message-content/MessageContent.tsx @@ -1,6 +1,5 @@ import classNames from 'classnames'; import { isEmpty } from 'lodash'; -import moment from 'moment'; import { MouseEvent, useCallback, useLayoutEffect, useState } from 'react'; import { InView } from 'react-intersection-observer'; import { useSelector } from 'react-redux'; @@ -22,6 +21,7 @@ import { } from '../../../../state/selectors/conversations'; import { useSelectedIsPrivate } from '../../../../state/selectors/selectedConversation'; import { canDisplayImagePreview } from '../../../../types/Attachment'; +import { formatFullDate } from '../../../../util/i18n'; import { MessageAttachment } from './MessageAttachment'; import { MessageAvatar } from './MessageAvatar'; import { MessageHighlighter } from './MessageHighlighter'; @@ -152,7 +152,7 @@ export const MessageContent = (props: Props) => { const hasContentBeforeAttachment = !isEmpty(previews) || !isEmpty(quote) || !isEmpty(text); - const toolTipTitle = moment(serverTimestamp || timestamp).format('llll'); + const toolTipTitle = formatFullDate(new Date(serverTimestamp || timestamp)); const isDetailViewAndSupportsAttachmentCarousel = isDetailView && canDisplayImagePreview(attachments); diff --git a/ts/components/conversation/message/message-item/DateBreak.tsx b/ts/components/conversation/message/message-item/DateBreak.tsx index 012488293..e826e582f 100644 --- a/ts/components/conversation/message/message-item/DateBreak.tsx +++ b/ts/components/conversation/message/message-item/DateBreak.tsx @@ -1,6 +1,6 @@ -import moment from 'moment'; - import styled from 'styled-components'; +import { DURATION } from '../../../../session/constants'; +import { formatFullDate, formatRelativeWithLocale } from '../../../../util/i18n'; const DateBreakContainer = styled.div``; @@ -17,9 +17,12 @@ const DateBreakText = styled.div` export const MessageDateBreak = (props: { timestamp: number; messageId: string }) => { const { timestamp, messageId } = props; - const text = moment(timestamp).calendar(undefined, { - sameElse: 'llll', - }); + // if less than 7 days, we display the "last Thursday at 4:10" syntax + // otherwise, we display the date + time separately + const text = + Date.now() - timestamp <= DURATION.DAYS * 7 + ? formatRelativeWithLocale(timestamp) + : formatFullDate(new Date(timestamp)); return ( diff --git a/ts/components/conversation/right-panel/overlay/message-info/components/MessageInfo.tsx b/ts/components/conversation/right-panel/overlay/message-info/components/MessageInfo.tsx index 9c63dff7e..22babd591 100644 --- a/ts/components/conversation/right-panel/overlay/message-info/components/MessageInfo.tsx +++ b/ts/components/conversation/right-panel/overlay/message-info/components/MessageInfo.tsx @@ -1,6 +1,5 @@ import { ipcRenderer } from 'electron'; import { isEmpty } from 'lodash'; -import moment from 'moment'; import styled from 'styled-components'; import { MessageFrom } from '.'; @@ -20,7 +19,11 @@ import { import { isDevProd } from '../../../../../../shared/env_vars'; import { useSelectedConversationKey } from '../../../../../../state/selectors/selectedConversation'; -import { formatTimeDuration, formatTimeDistanceToNow } from '../../../../../../util/i18n'; +import { + formatTimeDuration, + formatTimeDistanceToNow, + formatWithLocale, +} from '../../../../../../util/i18n'; import { Flex } from '../../../../../basic/Flex'; import { SpacerSM } from '../../../../../basic/Text'; import { CopyToClipboardIcon } from '../../../../../buttons'; @@ -71,8 +74,7 @@ export const LabelWithInfo = (props: LabelWithInfoProps) => { ); }; -// Message timestamp format: "06:02 PM Tue, 15/11/2022" -const formatTimestamps = 'hh:mm A ddd, D/M/Y'; +const formatTimestampStr = 'hh:mm d LLL, yyyy' as const; const showDebugLog = () => { ipcRenderer.send('show-debug-log'); @@ -124,8 +126,14 @@ export const MessageInfo = ({ messageId, errors }: { messageId: string; errors: return null; } - const sentAtStr = `${moment(serverTimestamp || sentAt).format(formatTimestamps)}`; - const receivedAtStr = `${moment(receivedAt).format(formatTimestamps)}`; + const sentAtStr = formatWithLocale({ + date: new Date(serverTimestamp || sentAt || 0), + formatStr: formatTimestampStr, + }); + const receivedAtStr = formatWithLocale({ + date: new Date(receivedAt || 0), + formatStr: formatTimestampStr, + }); const hasError = !isEmpty(errors); const errorString = hasError diff --git a/ts/components/leftpane/MessageRequestsBanner.tsx b/ts/components/leftpane/MessageRequestsBanner.tsx index 53da66ba4..a053516f1 100644 --- a/ts/components/leftpane/MessageRequestsBanner.tsx +++ b/ts/components/leftpane/MessageRequestsBanner.tsx @@ -4,7 +4,7 @@ import { createPortal } from 'react-dom'; import { useSelector } from 'react-redux'; import styled from 'styled-components'; import { getUnreadConversationRequests } from '../../state/selectors/conversations'; -import { isSearching } from '../../state/selectors/search'; +import { useIsSearching } from '../../state/selectors/search'; import { getHideMessageRequestBanner } from '../../state/selectors/userConfig'; import { SessionIcon, SessionIconSize, SessionIconType } from '../icon'; import { MessageRequestBannerContextMenu } from '../menu/MessageRequestBannerContextMenu'; @@ -87,7 +87,7 @@ export const MessageRequestsBanner = (props: { handleOnClick: () => any }) => { const hideRequestBanner = useSelector(getHideMessageRequestBanner); // when searching hide the message request banner - const isCurrentlySearching = useSelector(isSearching); + const isCurrentlySearching = useIsSearching(); if (!conversationRequestsUnread || hideRequestBanner || isCurrentlySearching) { return null; diff --git a/ts/components/leftpane/conversation-list-item/ConversationListItem.tsx b/ts/components/leftpane/conversation-list-item/ConversationListItem.tsx index 78970c6ad..c867146d5 100644 --- a/ts/components/leftpane/conversation-list-item/ConversationListItem.tsx +++ b/ts/components/leftpane/conversation-list-item/ConversationListItem.tsx @@ -3,7 +3,7 @@ import { isNil } from 'lodash'; import { MouseEvent, ReactNode, useCallback } from 'react'; import { contextMenu } from 'react-contexify'; import { createPortal } from 'react-dom'; -import { useDispatch, useSelector } from 'react-redux'; +import { useDispatch } from 'react-redux'; import { CSSProperties } from 'styled-components'; import { Avatar, AvatarSize } from '../../avatar/Avatar'; @@ -23,7 +23,7 @@ import { useIsPrivate, useMentionedUs, } from '../../../hooks/useParamSelector'; -import { isSearching } from '../../../state/selectors/search'; +import { useIsSearching } from '../../../state/selectors/search'; import { useSelectedConversationKey } from '../../../state/selectors/selectedConversation'; import { SpacerXS } from '../../basic/Text'; import { MemoConversationListItemContextMenu } from '../../menu/ConversationListItemContextMenu'; @@ -71,7 +71,7 @@ export const ConversationListItem = (props: Props) => { let hasUnreadMentionedUs = useMentionedUs(conversationId); let isBlocked = useIsBlocked(conversationId); - const isSearch = useSelector(isSearching); + const isSearch = useIsSearching(); const selectedConvo = useSelectedConversationKey(); const isSelectedConvo = conversationId === selectedConvo && !isNil(selectedConvo); diff --git a/ts/components/leftpane/conversation-list-item/HeaderItem.tsx b/ts/components/leftpane/conversation-list-item/HeaderItem.tsx index f7bdb0f45..85878befc 100644 --- a/ts/components/leftpane/conversation-list-item/HeaderItem.tsx +++ b/ts/components/leftpane/conversation-list-item/HeaderItem.tsx @@ -19,7 +19,7 @@ import { openConversationToSpecificMessage, openConversationWithMessages, } from '../../../state/ducks/conversations'; -import { isSearching } from '../../../state/selectors/search'; +import { useIsSearching } from '../../../state/selectors/search'; import { getIsMessageSection } from '../../../state/selectors/section'; import { Timestamp } from '../../conversation/Timestamp'; import { SessionIcon } from '../../icon'; @@ -173,7 +173,7 @@ const UnreadCount = ({ convoId }: { convoId: string }) => { export const ConversationListItemHeaderItem = () => { const conversationId = useConvoIdFromContext(); - const isSearchingMode = useSelector(isSearching); + const isSearching = useIsSearching(); const hasUnread = useHasUnread(conversationId); const activeAt = useActiveAt(conversationId); @@ -193,14 +193,14 @@ export const ConversationListItemHeaderItem = () => { - {!isSearchingMode && ( + {!isSearching && (
- +
)} diff --git a/ts/components/leftpane/conversation-list-item/MessageItem.tsx b/ts/components/leftpane/conversation-list-item/MessageItem.tsx index 644138225..6350b5870 100644 --- a/ts/components/leftpane/conversation-list-item/MessageItem.tsx +++ b/ts/components/leftpane/conversation-list-item/MessageItem.tsx @@ -11,7 +11,7 @@ import { useLastMessage, } from '../../../hooks/useParamSelector'; import { LastMessageStatusType } from '../../../state/ducks/types'; -import { isSearching } from '../../../state/selectors/search'; +import { useIsSearching } from '../../../state/selectors/search'; import { getIsMessageRequestOverlayShown } from '../../../state/selectors/section'; import { assertUnreachable } from '../../../types/sqlSharedTypes'; import { TypingAnimation } from '../../conversation/TypingAnimation'; @@ -29,7 +29,7 @@ export const MessageItem = () => { const isMessageRequest = useSelector(getIsMessageRequestOverlayShown); const isOutgoingRequest = useIsOutgoingRequest(conversationId); - const isSearchingMode = useSelector(isSearching); + const isSearching = useIsSearching(); if (isOutgoingRequest) { return null; @@ -63,7 +63,7 @@ export const MessageItem = () => { )} - {!isSearchingMode && lastMessage && lastMessage.status && !isMessageRequest ? ( + {!isSearching && lastMessage && lastMessage.status && !isMessageRequest ? ( ) : null} diff --git a/ts/components/leftpane/conversation-list-item/UserItem.tsx b/ts/components/leftpane/conversation-list-item/UserItem.tsx index ac92de601..b72b22c69 100644 --- a/ts/components/leftpane/conversation-list-item/UserItem.tsx +++ b/ts/components/leftpane/conversation-list-item/UserItem.tsx @@ -1,4 +1,3 @@ -import { useSelector } from 'react-redux'; import { useConvoIdFromContext } from '../../../contexts/ConvoIdContext'; import { useConversationRealName, @@ -7,14 +6,14 @@ import { useIsMe, } from '../../../hooks/useParamSelector'; import { PubKey } from '../../../session/types'; -import { isSearching } from '../../../state/selectors/search'; +import { useIsSearching } from '../../../state/selectors/search'; import { ContactName } from '../../conversation/ContactName'; export const UserItem = () => { const conversationId = useConvoIdFromContext(); // we want to show the nickname in brackets if a nickname is set for search results - const isSearchResultsMode = useSelector(isSearching); + const isSearchResultsMode = useIsSearching(); const shortenedPubkey = PubKey.shorten(conversationId); const isMe = useIsMe(conversationId); diff --git a/ts/components/leftpane/overlay/OverlayClosedGroup.tsx b/ts/components/leftpane/overlay/OverlayClosedGroup.tsx index cfbf8cf43..ff3beb89d 100644 --- a/ts/components/leftpane/overlay/OverlayClosedGroup.tsx +++ b/ts/components/leftpane/overlay/OverlayClosedGroup.tsx @@ -11,13 +11,15 @@ import { SessionSpinner } from '../../loading'; import { useSet } from '../../../hooks/useSet'; import { VALIDATION } from '../../../session/constants'; import { createClosedGroup } from '../../../session/conversations/createClosedGroup'; +import { ToastUtils } from '../../../session/utils'; +import LIBSESSION_CONSTANTS from '../../../session/utils/libsession/libsession_constants'; import { clearSearch } from '../../../state/ducks/search'; import { resetLeftOverlayMode } from '../../../state/ducks/section'; import { getPrivateContactsPubkeys } from '../../../state/selectors/conversations'; import { getSearchResultsContactOnly, getSearchTerm, - isSearching, + useIsSearching, } from '../../../state/selectors/search'; import { MemberListItem } from '../../MemberListItem'; import { SessionSearchInput } from '../../SessionSearchInput'; @@ -25,8 +27,6 @@ import { Flex } from '../../basic/Flex'; import { SpacerLG, SpacerMD } from '../../basic/Text'; import { SessionInput } from '../../inputs'; import { StyledLeftPaneOverlay } from './OverlayMessage'; -import LIBSESSION_CONSTANTS from '../../../session/utils/libsession/libsession_constants'; -import { ToastUtils } from '../../../session/utils'; const StyledMemberListNoContacts = styled.div` text-align: center; @@ -108,7 +108,7 @@ export const OverlayClosedGroup = () => { addTo: addToSelected, removeFrom: removeFromSelected, } = useSet([]); - const isSearch = useSelector(isSearching); + const isSearch = useIsSearching(); const searchTerm = useSelector(getSearchTerm); const searchResultContactsOnly = useSelector(getSearchResultsContactOnly); diff --git a/ts/components/menu/ConversationListItemContextMenu.tsx b/ts/components/menu/ConversationListItemContextMenu.tsx index 0ae6c4ec1..b92f06064 100644 --- a/ts/components/menu/ConversationListItemContextMenu.tsx +++ b/ts/components/menu/ConversationListItemContextMenu.tsx @@ -4,7 +4,7 @@ import { useSelector } from 'react-redux'; import { useConvoIdFromContext } from '../../contexts/ConvoIdContext'; import { useIsPinned, useIsPrivate, useIsPrivateAndFriend } from '../../hooks/useParamSelector'; import { getConversationController } from '../../session/conversations'; -import { isSearching } from '../../state/selectors/search'; +import { useIsSearching } from '../../state/selectors/search'; import { getIsMessageSection } from '../../state/selectors/section'; import { SessionContextMenuContainer } from '../SessionContextMenuContainer'; import { @@ -33,9 +33,9 @@ export type PropsContextConversationItem = { const ConversationListItemContextMenu = (props: PropsContextConversationItem) => { const { triggerId } = props; - const isSearchingMode = useSelector(isSearching); + const isSearching = useIsSearching(); - if (isSearchingMode) { + if (isSearching) { return null; } diff --git a/ts/components/search/MessageSearchResults.tsx b/ts/components/search/MessageSearchResults.tsx index 842c4c956..4044bbab0 100644 --- a/ts/components/search/MessageSearchResults.tsx +++ b/ts/components/search/MessageSearchResults.tsx @@ -237,7 +237,7 @@ export const MessageSearchResult = (props: MessageSearchResultProps) => { diff --git a/ts/hooks/useFormattedDuration.ts b/ts/hooks/useFormattedDuration.ts new file mode 100644 index 000000000..64644c227 --- /dev/null +++ b/ts/hooks/useFormattedDuration.ts @@ -0,0 +1,10 @@ +export function useFormattedDuration(seconds: number, options: { forceHours: boolean }) { + const hoursStr = `${Math.floor(seconds / 3600)}`.padStart(2, '0'); + const minutesStr = `${Math.floor((seconds % 3600) / 60)}`.padStart(2, '0'); + const secondsStr = `${Math.floor(seconds % 60)}`.padStart(2, '0'); + + if (hoursStr === '00' && !options.forceHours) { + return `${minutesStr}:${secondsStr}`; + } + return `${hoursStr}:${minutesStr}:${secondsStr}`; +} diff --git a/ts/state/selectors/search.ts b/ts/state/selectors/search.ts index 78b9ffe35..0517bc1c6 100644 --- a/ts/state/selectors/search.ts +++ b/ts/state/selectors/search.ts @@ -1,6 +1,7 @@ import { createSelector } from '@reduxjs/toolkit'; import { compact, isEmpty, remove, sortBy } from 'lodash'; +import { useSelector } from 'react-redux'; import { StateType } from '../reducer'; import { UserUtils } from '../../session/utils'; @@ -15,7 +16,7 @@ export const getSearch = (state: StateType): SearchStateType => state.search; export const getQuery = (state: StateType): string => getSearch(state).query; -export const isSearching = (state: StateType) => { +const isSearching = (state: StateType) => { return !!getSearch(state)?.query?.trim(); }; @@ -122,3 +123,7 @@ export const getSearchResultsList = createSelector([getSearchResults], searchSta return builtList; }); + +export function useIsSearching() { + return useSelector(isSearching); +} diff --git a/ts/test/components/media-gallery/groupMessagesByDate_test.ts b/ts/test/components/media-gallery/groupMessagesByDate_test.ts deleted file mode 100644 index fc7cd0e06..000000000 --- a/ts/test/components/media-gallery/groupMessagesByDate_test.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { assert } from 'chai'; -import { shuffle } from 'lodash'; - -import { IMAGE_JPEG } from '../../../types/MIME'; -import { - groupMediaItemsByDate, - Section, -} from '../../../components/conversation/media-gallery/groupMediaItemsByDate'; -import { TestUtils } from '../../test-utils'; -import { MediaItemType } from '../../../components/lightbox/LightboxGallery'; - -const generatedMessageSenderKey = TestUtils.generateFakePubKey().key; - -const toMediaItem = (date: Date): MediaItemType => ({ - objectURL: date.toUTCString(), - index: 0, - attachment: { - fileName: 'fileName', - contentType: IMAGE_JPEG, - url: 'url', - fileSize: null, - screenshot: null, - thumbnail: null, - path: '123456', - id: 123456, - }, - contentType: IMAGE_JPEG, - messageSender: generatedMessageSenderKey, - messageTimestamp: date.getTime(), - messageId: '123456', -}); - -describe('groupMediaItemsByDate', () => { - it('should group mediaItems', () => { - const referenceTime = new Date('2018-04-12T18:00Z').getTime(); // Thu - const input: Array = shuffle([ - // Today - toMediaItem(new Date('2018-04-12T12:00Z')), // Thu - toMediaItem(new Date('2018-04-12T00:01Z')), // Thu - // This week - toMediaItem(new Date('2018-04-11T23:59Z')), // Wed - toMediaItem(new Date('2018-04-09T00:01Z')), // Mon - // This month - toMediaItem(new Date('2018-04-08T23:59Z')), // Sun - toMediaItem(new Date('2018-04-01T00:01Z')), - // March 2018 - toMediaItem(new Date('2018-03-31T23:59Z')), - toMediaItem(new Date('2018-03-01T14:00Z')), - // February 2011 - toMediaItem(new Date('2011-02-28T23:59Z')), - toMediaItem(new Date('2011-02-01T10:00Z')), - ]); - - const expected: Array
= [ - { - type: 'today', - mediaItems: [ - { - objectURL: 'Thu, 12 Apr 2018 12:00:00 GMT', - index: 0, - contentType: IMAGE_JPEG, - - attachment: { - fileName: 'fileName', - contentType: IMAGE_JPEG, - url: 'url', - fileSize: null, - screenshot: null, - thumbnail: null, - path: '123456', - id: 123456, - }, - messageSender: generatedMessageSenderKey, - messageTimestamp: 1523534400000, - messageId: '123456', - }, - { - objectURL: 'Thu, 12 Apr 2018 00:01:00 GMT', - index: 0, - contentType: IMAGE_JPEG, - attachment: { - fileName: 'fileName', - contentType: IMAGE_JPEG, - url: 'url', - fileSize: null, - screenshot: null, - thumbnail: null, - path: '123456', - id: 123456, - }, - messageSender: generatedMessageSenderKey, - messageTimestamp: 1523491260000, - messageId: '123456', - }, - ], - }, - { - type: 'yesterday', - mediaItems: [ - { - objectURL: 'Wed, 11 Apr 2018 23:59:00 GMT', - index: 0, - contentType: IMAGE_JPEG, - attachment: { - fileName: 'fileName', - contentType: IMAGE_JPEG, - url: 'url', - fileSize: null, - screenshot: null, - thumbnail: null, - path: '123456', - id: 123456, - }, - messageSender: generatedMessageSenderKey, - messageTimestamp: 1523491140000, - messageId: '123456', - }, - ], - }, - { - type: 'thisWeek', - mediaItems: [ - { - objectURL: 'Mon, 09 Apr 2018 00:01:00 GMT', - index: 0, - contentType: IMAGE_JPEG, - attachment: { - fileName: 'fileName', - contentType: IMAGE_JPEG, - url: 'url', - fileSize: null, - screenshot: null, - thumbnail: null, - path: '123456', - id: 123456, - }, - messageSender: generatedMessageSenderKey, - messageTimestamp: 1523232060000, - messageId: '123456', - }, - ], - }, - { - type: 'thisMonth', - mediaItems: [ - { - objectURL: 'Sun, 08 Apr 2018 23:59:00 GMT', - index: 0, - contentType: IMAGE_JPEG, - attachment: { - fileName: 'fileName', - contentType: IMAGE_JPEG, - url: 'url', - fileSize: null, - screenshot: null, - thumbnail: null, - path: '123456', - id: 123456, - }, - messageSender: generatedMessageSenderKey, - messageTimestamp: 1523231940000, - messageId: '123456', - }, - { - objectURL: 'Sun, 01 Apr 2018 00:01:00 GMT', - index: 0, - contentType: IMAGE_JPEG, - attachment: { - fileName: 'fileName', - contentType: IMAGE_JPEG, - url: 'url', - fileSize: null, - screenshot: null, - thumbnail: null, - path: '123456', - id: 123456, - }, - messageSender: generatedMessageSenderKey, - messageTimestamp: 1522540860000, - messageId: '123456', - }, - ], - }, - { - type: 'yearMonth', - year: 2018, - month: 2, - mediaItems: [ - { - objectURL: 'Sat, 31 Mar 2018 23:59:00 GMT', - index: 0, - contentType: IMAGE_JPEG, - attachment: { - fileName: 'fileName', - contentType: IMAGE_JPEG, - url: 'url', - fileSize: null, - screenshot: null, - thumbnail: null, - path: '123456', - id: 123456, - }, - messageSender: generatedMessageSenderKey, - messageTimestamp: 1522540740000, - messageId: '123456', - }, - { - objectURL: 'Thu, 01 Mar 2018 14:00:00 GMT', - index: 0, - contentType: IMAGE_JPEG, - attachment: { - fileName: 'fileName', - contentType: IMAGE_JPEG, - url: 'url', - fileSize: null, - screenshot: null, - thumbnail: null, - path: '123456', - id: 123456, - }, - messageSender: generatedMessageSenderKey, - messageTimestamp: 1519912800000, - messageId: '123456', - }, - ], - }, - { - type: 'yearMonth', - year: 2011, - month: 1, - mediaItems: [ - { - objectURL: 'Mon, 28 Feb 2011 23:59:00 GMT', - index: 0, - attachment: { - fileName: 'fileName', - contentType: IMAGE_JPEG, - url: 'url', - fileSize: null, - screenshot: null, - thumbnail: null, - path: '123456', - id: 123456, - }, - contentType: IMAGE_JPEG, - messageSender: generatedMessageSenderKey, - messageTimestamp: 1298937540000, - messageId: '123456', - }, - { - objectURL: 'Tue, 01 Feb 2011 10:00:00 GMT', - index: 0, - contentType: IMAGE_JPEG, - messageSender: generatedMessageSenderKey, - messageTimestamp: 1296554400000, - attachment: { - fileName: 'fileName', - contentType: IMAGE_JPEG, - url: 'url', - fileSize: null, - screenshot: null, - thumbnail: null, - path: '123456', - id: 123456, - }, - messageId: '123456', - }, - ], - }, - ]; - - const actual = groupMediaItemsByDate(referenceTime, input); - assert.deepEqual(actual, expected); - }); -}); diff --git a/ts/util/i18n.ts b/ts/util/i18n.ts index e267780c7..a8b70c6a6 100644 --- a/ts/util/i18n.ts +++ b/ts/util/i18n.ts @@ -4,14 +4,22 @@ import { Duration, FormatDistanceStrictOptions, FormatDistanceToNowStrictOptions, + format, formatDistanceStrict, formatDistanceToNow, formatDistanceToNowStrict, formatDuration, + formatRelative, intervalToDuration, + isAfter, + isBefore, + subDays, subMilliseconds, } from 'date-fns'; import timeLocales from 'date-fns/locale'; +import { deSanitizeHtmlTags, sanitizeArgs } from '../components/basic/I18n'; +import { LOCALE_DEFAULTS } from '../localization/constants'; +import { en } from '../localization/locales'; import { GetNetworkTime } from '../session/apis/snode_api/getNetworkTime'; import { DURATION_SECONDS } from '../session/constants'; import { updateLocale } from '../state/ducks/dictionary'; @@ -25,9 +33,6 @@ import { PluralString, SetupI18nReturnType, } from '../types/Localizer'; -import { deSanitizeHtmlTags, sanitizeArgs } from '../components/basic/I18n'; -import { LOCALE_DEFAULTS } from '../localization/constants'; -import { en } from '../localization/locales'; export function loadDictionary(locale: Locale) { return import(`../../_locales/${locale}/messages.json`) as Promise; @@ -103,6 +108,8 @@ const timeLocaleMap = { export type Locale = keyof typeof timeLocaleMap; +let initialLocale: Locale = 'en'; + function getPluralKey(string: PluralString): R { const match = /{(\w+), plural, one \[.+\] other \[.+\]}/g.exec(string); return (match?.[1] ?? undefined) as R; @@ -157,22 +164,29 @@ function i18nLog(message: string) { * @param params - An object containing optional parameters. * @param params.fallback - The fallback locale to use if redux is not available. Defaults to en. */ -export function getLocale(params?: { fallback?: Locale }): Locale { +export function getLocale(): Locale { const locale = window?.inboxStore?.getState().dictionary.locale; if (locale) { return locale; } - if (params?.fallback) { - i18nLog(`getLocale: No locale found in redux store. Using fallback: ${params.fallback}`); - return params.fallback; + if (initialLocale) { + i18nLog( + `getLocale: No locale found in redux store but initialLocale provided: ${initialLocale}` + ); + + return initialLocale; } i18nLog('getLocale: No locale found in redux store. No fallback provided. Using en.'); return 'en'; } +function getLocaleDictionary() { + return timeLocaleMap[getLocale()]; +} + /** * Returns the current dictionary. * @param params - An object containing optional parameters. @@ -206,7 +220,7 @@ export const setupI18n = (params: { initialLocale: Locale; initialDictionary: LocalizerDictionary; }): SetupI18nReturnType => { - let initialLocale = params.initialLocale; + initialLocale = params.initialLocale; let initialDictionary = params.initialDictionary; if (!initialLocale) { @@ -285,7 +299,7 @@ export const setupI18n = (params: { } else { const num = args?.[pluralKey as keyof typeof args] ?? 0; - const currentLocale = getLocale() ?? initialLocale; + const currentLocale = getLocale(); const cardinalRule = new Intl.PluralRules(currentLocale).select(num); const pluralString = getStringForCardinalRule(localizedString, cardinalRule); @@ -439,7 +453,7 @@ export const setupI18n = (params: { getMessage.getRawMessage = getRawMessage; getMessage.formatMessageWithArgs = formatMessageWithArgs; - i18nLog('Setup Complete'); + i18nLog(`Setup Complete with locale: ${initialLocale}`); return getMessage as SetupI18nReturnType; }; @@ -480,10 +494,8 @@ export const formatTimeDuration = ( durationMs: number, options?: Omit ) => { - const locale = getLocale(); - return formatDistanceStrict(new Date(durationMs), new Date(0), { - locale: timeLocaleMap[locale], + locale: getLocaleDictionary(), ...options, }); }; @@ -517,6 +529,28 @@ const secondsToDuration = (seconds: number): Duration => { return duration; }; +export const formatWithLocale = ({ formatStr, date }: { date: Date; formatStr: string }) => { + return format(date, formatStr, { locale: getLocaleDictionary() }); +}; + +/** + * Returns a formatted date like `Wednesday, Jun 12, 2024, 4:29 PM` + */ +export const formatFullDate = (date: Date) => { + return date.toLocaleString(getLocale(), { + year: 'numeric', + month: 'short', + weekday: 'long', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + }); +}; + +export const formatRelativeWithLocale = (timestampMs: number) => { + return formatRelative(timestampMs, Date.now(), { locale: getLocaleDictionary() }); +}; + /** * We decided against localizing the abbreviated durations like 1h, 1m, 1s as most apps don't. * This function just replaces any long form of "seconds?" to "s", "minutes?" to "m", etc. @@ -556,7 +590,7 @@ export const formatAbbreviatedExpireTimer = (timerSeconds: number) => { const duration = secondsToDuration(timerSeconds); const unlocalized = formatDuration(duration, { - locale: timeLocaleMap.en, + locale: timeLocaleMap.en, // we want this forced to english }); return unlocalizedDurationToAbbreviated(unlocalized); @@ -605,7 +639,7 @@ export const formatAbbreviatedExpireDoubleTimer = (timerSeconds: number) => { } const unlocalized = formatDuration(duration, { - locale: timeLocaleMap.en, + locale: timeLocaleMap.en, // we want this forced to english delimiter: '#', format, }); @@ -616,17 +650,69 @@ export const formatTimeDistanceToNow = ( durationSeconds: number, options?: Omit ) => { - const locale = getLocale(); return formatDistanceToNowStrict(durationSeconds * 1000, { - locale: timeLocaleMap[locale], + locale: getLocaleDictionary(), ...options, }); }; export const formatDateDistanceWithOffset = (date: Date): string => { - const locale = getLocale(); const adjustedDate = subMilliseconds(date, GetNetworkTime.getLatestTimestampOffset()); - return formatDistanceToNow(adjustedDate, { addSuffix: true, locale: timeLocaleMap[locale] }); + return formatDistanceToNow(adjustedDate, { addSuffix: true, locale: getLocaleDictionary() }); +}; + +/** + * Returns a localized string like "Aug 7, 2024 10:03 AM" + */ +export const getDateAndTimeShort = (date: Date) => { + return formatWithLocale({ date, formatStr: 'Pp' }); +}; + +const getStartOfToday = () => { + const now = new Date(); + return new Date(now.getFullYear(), now.getMonth(), now.getDate()); +}; + +/** + * Returns + * - hh:mm when less than 24h ago + * - Tue hh:mm when less than 7d ago + * - dd/mm/yy otherwise + * + */ +export const getConversationItemString = (date: Date) => { + const now = new Date(); + + // if this is in the future, or older than 7 days ago we display date+time + if (isAfter(date, now) || isBefore(date, subDays(now, 7))) { + const formatter = new Intl.DateTimeFormat(getLocale(), { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + hour12: true, // This will switch between 12-hour and 24-hour format depending on the locale + }); + return formatter.format(date); + } + + // if since our start of the day, display the hour and minute only, am/pm locale dependent + if (isAfter(date, getStartOfToday())) { + const formatter = new Intl.DateTimeFormat(getLocale(), { + hour: 'numeric', + minute: 'numeric', + hour12: true, // This will switch between 12-hour and 24-hour format depending on the locale + }); + return formatter.format(date); + } + // less than 7 days ago, display the day of the week + time + const formatter = new Intl.DateTimeFormat(getLocale(), { + weekday: 'short', + hour: 'numeric', + minute: 'numeric', + hour12: true, // This will switch between 12-hour and 24-hour format depending on the locale + }); + return formatter.format(date); }; // RTL Support