From 0d5a480c1b754f58460ab41d1fc7211f325b5733 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Thu, 5 Sep 2024 07:15:30 +1000 Subject: [PATCH] Media Gallery: Scroll down and into the past Co-authored-by: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> --- _locales/en/messages.json | 6 +- stylesheets/_modules.scss | 14 + ts/components/Lightbox.stories.tsx | 24 +- ts/components/Lightbox.tsx | 4 +- .../ConversationHeader.stories.tsx | 2 +- .../conversation/ConversationHeader.tsx | 18 +- .../ConversationDetails.stories.tsx | 2 +- .../ConversationDetails.tsx | 6 +- .../ConversationDetailsMediaList.stories.tsx | 2 +- .../ConversationDetailsMediaList.tsx | 21 +- .../media-gallery/AttachmentSection.tsx | 5 +- .../media-gallery/MediaGallery.stories.tsx | 13 +- .../media-gallery/MediaGallery.tsx | 181 ++++-- .../media-gallery/MediaGridItem.stories.tsx | 6 +- .../media-gallery/groupMediaItemsByDate.ts | 36 +- .../media-gallery/types/ItemClickEvent.ts | 4 +- .../conversation/media-gallery/utils/mocks.ts | 8 +- ts/sql/Interface.ts | 9 +- ts/sql/Server.ts | 80 +-- ts/state/ducks/conversations.ts | 12 +- ts/state/ducks/lightbox.ts | 35 +- ts/state/ducks/mediaGallery.ts | 564 +++++++++++++----- ts/state/smart/AllMedia.tsx | 17 +- ts/state/smart/ConversationDetails.tsx | 4 +- ts/state/smart/ConversationHeader.tsx | 4 +- ts/test-electron/sql/allMedia_test.ts | 237 -------- .../media-gallery/groupMediaItemsByDate.ts | 97 +++ .../media-gallery/groupMessagesByDate_test.ts | 271 --------- ts/types/Colors.ts | 2 +- ts/types/MediaItem.ts | 13 +- ts/util/getColorForCallLink.ts | 2 +- ts/util/lint/exceptions.json | 32 + 32 files changed, 844 insertions(+), 887 deletions(-) delete mode 100644 ts/test-electron/sql/allMedia_test.ts create mode 100644 ts/test-node/components/media-gallery/groupMediaItemsByDate.ts delete mode 100644 ts/test-node/components/media-gallery/groupMessagesByDate_test.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 73a07e670835..b78e4b20e0c8 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1252,7 +1252,11 @@ }, "icu:viewRecentMedia": { "messageformat": "View recent media", - "description": "This is a menu item for viewing all media (images + video) in a conversation, using the imperative case, as in a command." + "description": "(Deleted 2024/09/03) This is a menu item for viewing all media (images + video) in a conversation, using the imperative case, as in a command." + }, + "icu:allMediaMenuItem": { + "messageformat": "All media", + "description": "This is a menu item for viewing all media (images + video) in a conversation" }, "icu:back": { "messageformat": "Back", diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 926fdc0f0c17..2a195ed61a29 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -2374,6 +2374,7 @@ button.ConversationDetails__action-button { // Module: Media Gallery .module-media-gallery { + position: relative; display: flex; flex-direction: column; flex-grow: 1; @@ -2389,6 +2390,19 @@ button.ConversationDetails__action-button { padding: 20px; } +.module-media-gallery__scroll-observer { + position: absolute; + bottom: 0; + height: 30px; + width: 100%; + + &::after { + content: ''; + height: 1px; // Always show the element to not mess with the height of the scroll area + display: block; + } +} + .module-media-gallery__sections { display: flex; flex-grow: 1; diff --git a/ts/components/Lightbox.stories.tsx b/ts/components/Lightbox.stories.tsx index c37ff602c03b..8cc03e2bf403 100644 --- a/ts/components/Lightbox.stories.tsx +++ b/ts/components/Lightbox.stories.tsx @@ -46,9 +46,9 @@ function createMediaItem( attachments: [], conversationId: '1234', id: 'image-msg', - received_at: 0, - received_at_ms: Date.now(), - sent_at: Date.now(), + receivedAt: 0, + receivedAtMs: Date.now(), + sentAt: Date.now(), }, objectURL: '', ...overrideProps, @@ -96,9 +96,9 @@ export function Multimedia(): JSX.Element { attachments: [], conversationId: '1234', id: 'image-msg', - received_at: 1, - received_at_ms: Date.now(), - sent_at: Date.now(), + receivedAt: 1, + receivedAtMs: Date.now(), + sentAt: Date.now(), }, objectURL: '/fixtures/tina-rolf-269345-unsplash.jpg', }, @@ -114,9 +114,9 @@ export function Multimedia(): JSX.Element { attachments: [], conversationId: '1234', id: 'video-msg', - received_at: 2, - received_at_ms: Date.now(), - sent_at: Date.now(), + receivedAt: 2, + receivedAtMs: Date.now(), + sentAt: Date.now(), }, objectURL: '/fixtures/pixabay-Soap-Bubble-7141.mp4', }, @@ -153,9 +153,9 @@ export function MissingMedia(): JSX.Element { attachments: [], conversationId: '1234', id: 'image-msg', - received_at: 3, - received_at_ms: Date.now(), - sent_at: Date.now(), + receivedAt: 3, + receivedAtMs: Date.now(), + sentAt: Date.now(), }, objectURL: undefined, }, diff --git a/ts/components/Lightbox.tsx b/ts/components/Lightbox.tsx index 71ff71dc8d69..278aed962b23 100644 --- a/ts/components/Lightbox.tsx +++ b/ts/components/Lightbox.tsx @@ -181,7 +181,7 @@ export function Lightbox({ const mediaItem = media[selectedIndex]; const { attachment, message, index } = mediaItem; - saveAttachment(attachment, message.sent_at, index + 1); + saveAttachment(attachment, message.sentAt, index + 1); }, [isViewOnce, media, saveAttachment, selectedIndex] ); @@ -828,7 +828,7 @@ function LightboxHeader({
{conversation.title}
- {formatDateTimeForAttachment(i18n, message.sent_at ?? now)} + {formatDateTimeForAttachment(i18n, message.sentAt ?? now)}
diff --git a/ts/components/conversation/ConversationHeader.stories.tsx b/ts/components/conversation/ConversationHeader.stories.tsx index c238fab0b358..1aa29342c5f5 100644 --- a/ts/components/conversation/ConversationHeader.stories.tsx +++ b/ts/components/conversation/ConversationHeader.stories.tsx @@ -74,8 +74,8 @@ const commonProps: PropsType = { onSearchInConversation: action('onSearchInConversation'), onSelectModeEnter: action('onSelectModeEnter'), onShowMembers: action('onShowMembers'), + onViewAllMedia: action('onViewAllMedia'), onViewConversationDetails: action('onViewConversationDetails'), - onViewRecentMedia: action('onViewRecentMedia'), onViewUserStories: action('onViewUserStories'), }; diff --git a/ts/components/conversation/ConversationHeader.tsx b/ts/components/conversation/ConversationHeader.tsx index 7ca06fd975d7..24a86d645613 100644 --- a/ts/components/conversation/ConversationHeader.tsx +++ b/ts/components/conversation/ConversationHeader.tsx @@ -130,8 +130,8 @@ export type PropsActionsType = { onSearchInConversation: () => void; onSelectModeEnter: () => void; onShowMembers: () => void; + onViewAllMedia: () => void; onViewConversationDetails: () => void; - onViewRecentMedia: () => void; onViewUserStories: () => void; }; @@ -180,8 +180,8 @@ export const ConversationHeader = memo(function ConversationHeader({ onSearchInConversation, onSelectModeEnter, onShowMembers, + onViewAllMedia, onViewConversationDetails, - onViewRecentMedia, onViewUserStories, outgoingCallButtonStyle, setLocalDeleteWarningShown, @@ -380,7 +380,7 @@ export const ConversationHeader = memo(function ConversationHeader({ setHasCustomDisappearingTimeoutModal(true); }} onShowMembers={onShowMembers} - onViewRecentMedia={onViewRecentMedia} + onViewAllMedia={onViewAllMedia} onViewConversationDetails={onViewConversationDetails} triggerId={triggerId} /> @@ -544,7 +544,7 @@ function HeaderMenu({ onSelectModeEnter, onSetupCustomDisappearingTimeout, onShowMembers, - onViewRecentMedia, + onViewAllMedia, onViewConversationDetails, triggerId, }: { @@ -570,7 +570,7 @@ function HeaderMenu({ onSelectModeEnter: () => void; onSetupCustomDisappearingTimeout: () => void; onShowMembers: () => void; - onViewRecentMedia: () => void; + onViewAllMedia: () => void; onViewConversationDetails: () => void; triggerId: string; }) { @@ -639,8 +639,8 @@ function HeaderMenu({ return ( {i18n('icu:showMembers')} - - {i18n('icu:viewRecentMedia')} + + {i18n('icu:allMediaMenuItem')} {conversation.isArchived ? ( @@ -750,8 +750,8 @@ function HeaderMenu({ : i18n('icu:showConversationDetails--direct')} ) : null} - - {i18n('icu:viewRecentMedia')} + + {i18n('icu:allMediaMenuItem')} diff --git a/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx b/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx index 88d6510a8ea1..b191ec3a97bb 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx @@ -87,7 +87,7 @@ const createProps = ( showContactModal: action('showContactModal'), pushPanelForConversation: action('pushPanelForConversation'), showConversation: action('showConversation'), - showLightboxWithMedia: action('showLightboxWithMedia'), + showLightbox: action('showLightbox'), updateGroupAttributes: async () => { action('updateGroupAttributes')(); }, diff --git a/ts/components/conversation/conversation-details/ConversationDetails.tsx b/ts/components/conversation/conversation-details/ConversationDetails.tsx index 080790662f11..a45e63943c22 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.tsx @@ -144,7 +144,7 @@ type ActionProps = { onFailure?: () => unknown; } ) => unknown; -} & Pick; +} & Pick; export type Props = StateProps & ActionProps; @@ -203,7 +203,7 @@ export function ConversationDetails({ setMuteExpiration, showContactModal, showConversation, - showLightboxWithMedia, + showLightbox, theme, toggleAboutContactModal, toggleSafetyNumberModal, @@ -702,7 +702,7 @@ export function ConversationDetails({ type: PanelType.AllMedia, }) } - showLightboxWithMedia={showLightboxWithMedia} + showLightbox={showLightbox} /> {!isGroup && !conversation.isMe && ( diff --git a/ts/components/conversation/conversation-details/ConversationDetailsMediaList.stories.tsx b/ts/components/conversation/conversation-details/ConversationDetailsMediaList.stories.tsx index 0ba75c510047..1364f4c2510a 100644 --- a/ts/components/conversation/conversation-details/ConversationDetailsMediaList.stories.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetailsMediaList.stories.tsx @@ -28,7 +28,7 @@ const createProps = (mediaItems?: Array): Props => ({ i18n, loadRecentMediaItems: action('loadRecentMediaItems'), showAllMedia: action('showAllMedia'), - showLightboxWithMedia: action('showLightboxWithMedia'), + showLightbox: action('showLightbox'), }); export function Basic(): JSX.Element { diff --git a/ts/components/conversation/conversation-details/ConversationDetailsMediaList.tsx b/ts/components/conversation/conversation-details/ConversationDetailsMediaList.tsx index 11e3868cde2b..2ef3d51f3dd5 100644 --- a/ts/components/conversation/conversation-details/ConversationDetailsMediaList.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetailsMediaList.tsx @@ -3,11 +3,9 @@ import React from 'react'; -import type { ReadonlyDeep } from 'type-fest'; import type { LocalizerType } from '../../../types/Util'; - -import type { MediaItemType } from '../../../types/MediaItem'; import type { ConversationType } from '../../../state/ducks/conversations'; +import type { AttachmentType } from '../../../types/Attachment'; import { PanelSection } from './PanelSection'; import { bemGenerator } from './util'; @@ -18,10 +16,10 @@ export type Props = { i18n: LocalizerType; loadRecentMediaItems: (id: string, limit: number) => void; showAllMedia: () => void; - showLightboxWithMedia: ( - selectedIndex: number, - media: ReadonlyArray> - ) => void; + showLightbox: (options: { + attachment: AttachmentType; + messageId: string; + }) => void; }; const MEDIA_ITEM_LIMIT = 6; @@ -33,7 +31,7 @@ export function ConversationDetailsMediaList({ i18n, loadRecentMediaItems, showAllMedia, - showLightboxWithMedia, + showLightbox, }: Props): JSX.Element | null { const mediaItems = conversation.recentMediaItems || []; @@ -66,7 +64,12 @@ export function ConversationDetailsMediaList({ key={`${mediaItem.message.id}-${mediaItem.index}`} mediaItem={mediaItem} i18n={i18n} - onClick={() => showLightboxWithMedia(mediaItem.index, mediaItems)} + onClick={() => + showLightbox({ + attachment: mediaItem.attachment, + messageId: mediaItem.message.id, + }) + } /> ))} diff --git a/ts/components/conversation/media-gallery/AttachmentSection.tsx b/ts/components/conversation/media-gallery/AttachmentSection.tsx index 22c3de1c4212..ce67ca3d5fc4 100644 --- a/ts/components/conversation/media-gallery/AttachmentSection.tsx +++ b/ts/components/conversation/media-gallery/AttachmentSection.tsx @@ -8,7 +8,6 @@ import type { LocalizerType } from '../../../types/Util'; import type { MediaItemType } from '../../../types/MediaItem'; import { DocumentListItem } from './DocumentListItem'; import { MediaGridItem } from './MediaGridItem'; -import { getMessageTimestamp } from '../../../util/getMessageTimestamp'; import { missingCaseError } from '../../../util/missingCaseError'; export type Props = { @@ -35,7 +34,7 @@ export function AttachmentSection({ const { message, index, attachment } = mediaItem; const onClick = () => { - onItemClick({ type, message, attachment, index: mediaItem.index }); + onItemClick({ type, message, attachment }); }; switch (type) { @@ -56,7 +55,7 @@ export function AttachmentSection({ fileSize={attachment.size} shouldShowSeparator={shouldShowSeparator} onClick={onClick} - timestamp={getMessageTimestamp(message)} + timestamp={message.receivedAtMs || message.receivedAt} /> ); default: diff --git a/ts/components/conversation/media-gallery/MediaGallery.stories.tsx b/ts/components/conversation/media-gallery/MediaGallery.stories.tsx index 2c5c9182ee01..40cd2ca135dc 100644 --- a/ts/components/conversation/media-gallery/MediaGallery.stories.tsx +++ b/ts/components/conversation/media-gallery/MediaGallery.stories.tsx @@ -22,13 +22,20 @@ export default { } satisfies Meta; const createProps = (overrideProps: Partial = {}): Props => ({ + i18n, + conversationId: '123', documents: overrideProps.documents || [], - i18n, - loadMediaItems: action('loadMediaItems'), + haveOldestDocument: overrideProps.haveOldestDocument || false, + haveOldestMedia: overrideProps.haveOldestMedia || false, + loading: overrideProps.loading || false, media: overrideProps.media || [], + + initialLoad: action('initialLoad'), + loadMoreDocuments: action('loadMoreDocuments'), + loadMoreMedia: action('loadMoreMedia'), saveAttachment: action('saveAttachment'), - showLightboxWithMedia: action('showLightboxWithMedia'), + showLightbox: action('showLightbox'), }); export function Populated(): JSX.Element { diff --git a/ts/components/conversation/media-gallery/MediaGallery.tsx b/ts/components/conversation/media-gallery/MediaGallery.tsx index 6b5565bf59d7..95892b68b0d8 100644 --- a/ts/components/conversation/media-gallery/MediaGallery.tsx +++ b/ts/components/conversation/media-gallery/MediaGallery.tsx @@ -12,9 +12,10 @@ import type { SaveAttachmentActionCreatorType } from '../../../state/ducks/conve import { AttachmentSection } from './AttachmentSection'; import { EmptyState } from './EmptyState'; import { Tabs } from '../../Tabs'; -import { getMessageTimestamp } from '../../../util/getMessageTimestamp'; import { groupMediaItemsByDate } from './groupMediaItemsByDate'; import { missingCaseError } from '../../../util/missingCaseError'; +import { usePrevious } from '../../../hooks/usePrevious'; +import type { AttachmentType } from '../../../types/Attachment'; enum TabViews { Media = 'Media', @@ -23,33 +24,43 @@ enum TabViews { export type Props = { conversationId: string; - documents: Array; + documents: ReadonlyArray; i18n: LocalizerType; - loadMediaItems: (id: string) => unknown; - media: Array; + haveOldestMedia: boolean; + haveOldestDocument: boolean; + loading: boolean; + initialLoad: (id: string) => unknown; + loadMoreMedia: (id: string) => unknown; + loadMoreDocuments: (id: string) => unknown; + media: ReadonlyArray; saveAttachment: SaveAttachmentActionCreatorType; - showLightboxWithMedia: ( - selectedIndex: number, - media: Array - ) => void; + showLightbox: (options: { + attachment: AttachmentType; + messageId: string; + }) => void; }; const MONTH_FORMAT = 'MMMM YYYY'; function MediaSection({ - type, - i18n, - media, documents, + i18n, + loading, + media, saveAttachment, - showLightboxWithMedia, + showLightbox, + type, }: Pick< Props, - 'i18n' | 'media' | 'documents' | 'showLightboxWithMedia' | 'saveAttachment' + 'documents' | 'i18n' | 'loading' | 'media' | 'saveAttachment' | 'showLightbox' > & { type: 'media' | 'documents' }): JSX.Element { const mediaItems = type === 'media' ? media : documents; if (!mediaItems || mediaItems.length === 0) { + if (loading) { + return
; + } + const label = (() => { switch (type) { case 'media': @@ -70,7 +81,7 @@ function MediaSection({ const sections = groupMediaItemsByDate(now, mediaItems).map(section => { const first = section.mediaItems[0]; const { message } = first; - const date = moment(getMessageTimestamp(message)); + const date = moment(message.receivedAtMs || message.receivedAt); function getHeader(): string { switch (section.type) { @@ -101,12 +112,15 @@ function MediaSection({ onItemClick={(event: ItemClickEvent) => { switch (event.type) { case 'documents': { - saveAttachment(event.attachment, event.message.sent_at); + saveAttachment(event.attachment, event.message.sentAt); break; } case 'media': { - showLightboxWithMedia(event.index, media); + showLightbox({ + attachment: event.attachment, + messageId: event.message.id, + }); break; } @@ -124,21 +138,87 @@ function MediaSection({ export function MediaGallery({ conversationId, documents, + haveOldestDocument, + haveOldestMedia, i18n, - loadMediaItems, + initialLoad, + loading, + loadMoreDocuments, + loadMoreMedia, media, saveAttachment, - showLightboxWithMedia, + showLightbox, }: Props): JSX.Element { const focusRef = useRef(null); + const scrollObserverRef = useRef(null); + const intersectionObserver = useRef(null); + const loadingRef = useRef(false); + const tabViewRef = useRef(TabViews.Media); useEffect(() => { focusRef.current?.focus(); }, []); useEffect(() => { - loadMediaItems(conversationId); - }, [conversationId, loadMediaItems]); + if (media.length > 0 || documents.length > 0) { + return; + } + initialLoad(conversationId); + loadingRef.current = true; + }, [conversationId, initialLoad, media, documents]); + + const previousLoading = usePrevious(loading, loading); + if (previousLoading && !loading) { + loadingRef.current = false; + } + + useEffect(() => { + if (!scrollObserverRef.current) { + return; + } + + intersectionObserver.current?.disconnect(); + intersectionObserver.current = null; + + intersectionObserver.current = new IntersectionObserver( + (entries: ReadonlyArray) => { + if (loadingRef.current) { + return; + } + + const entry = entries.find( + item => item.target === scrollObserverRef.current + ); + + if (entry && entry.intersectionRatio > 0) { + if (tabViewRef.current === TabViews.Media) { + if (!haveOldestMedia) { + loadMoreMedia(conversationId); + loadingRef.current = true; + } + } else { + // eslint-disable-next-line no-lonely-if + if (!haveOldestDocument) { + loadMoreDocuments(conversationId); + loadingRef.current = true; + } + } + } + } + ); + intersectionObserver.current.observe(scrollObserverRef.current); + + return () => { + intersectionObserver.current?.disconnect(); + intersectionObserver.current = null; + }; + }, [ + conversationId, + haveOldestDocument, + haveOldestMedia, + loadMoreDocuments, + loadMoreMedia, + ]); return (
@@ -155,31 +235,44 @@ export function MediaGallery({ }, ]} > - {({ selectedTab }) => ( -
- {selectedTab === TabViews.Media && ( - - )} - {selectedTab === TabViews.Documents && ( - - )} -
- )} + {({ selectedTab }) => { + tabViewRef.current = + selectedTab === TabViews.Media + ? TabViews.Media + : TabViews.Documents; + + return ( +
+ {selectedTab === TabViews.Media && ( + + )} + {selectedTab === TabViews.Documents && ( + + )} +
+ ); + }} +
); } diff --git a/ts/components/conversation/media-gallery/MediaGridItem.stories.tsx b/ts/components/conversation/media-gallery/MediaGridItem.stories.tsx index 10672f0bfcac..677208c33c45 100644 --- a/ts/components/conversation/media-gallery/MediaGridItem.stories.tsx +++ b/ts/components/conversation/media-gallery/MediaGridItem.stories.tsx @@ -37,9 +37,9 @@ const createMediaItem = ( attachments: [], conversationId: '1234', id: 'id', - received_at: Date.now(), - received_at_ms: Date.now(), - sent_at: Date.now(), + receivedAt: Date.now(), + receivedAtMs: Date.now(), + sentAt: Date.now(), }, }); diff --git a/ts/components/conversation/media-gallery/groupMediaItemsByDate.ts b/ts/components/conversation/media-gallery/groupMediaItemsByDate.ts index ceda3a98b9c0..ad4a811e4070 100644 --- a/ts/components/conversation/media-gallery/groupMediaItemsByDate.ts +++ b/ts/components/conversation/media-gallery/groupMediaItemsByDate.ts @@ -6,7 +6,6 @@ import { compact, groupBy, sortBy } from 'lodash'; import * as log from '../../../logging/log'; import type { MediaItemType } from '../../../types/MediaItem'; -import { getMessageTimestamp } from '../../../util/getMessageTimestamp'; import { missingCaseError } from '../../../util/missingCaseError'; @@ -27,12 +26,12 @@ export const groupMediaItemsByDate = ( timestamp: number, mediaItems: ReadonlyArray ): Array
=> { - const referenceDateTime = moment.utc(timestamp); + const referenceDateTime = moment(timestamp); const sortedMediaItem = sortBy(mediaItems, mediaItem => { const { message } = mediaItem; - return -message.received_at; + return -message.receivedAt; }); const messagesWithSection = sortedMediaItem.map( withSection(referenceDateTime) @@ -105,40 +104,38 @@ 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 withSection = (referenceDateTime: moment.Moment) => { + const today = moment(referenceDateTime).startOf('day'); + const yesterday = moment(referenceDateTime).subtract(1, 'day').startOf('day'); + const thisWeek = moment(referenceDateTime).subtract(7, 'day').startOf('day'); + const thisMonth = moment(referenceDateTime).startOf('month'); + return (mediaItem: MediaItemType): MediaItemWithSection => { const { message } = mediaItem; - const mediaItemReceivedDate = moment.utc(getMessageTimestamp(message)); - if (mediaItemReceivedDate.isAfter(today)) { + const messageTimestamp = moment(message.receivedAtMs || message.receivedAt); + + if (messageTimestamp.isAfter(today)) { return { order: 0, type: 'today', mediaItem, }; } - if (mediaItemReceivedDate.isAfter(yesterday)) { + if (messageTimestamp.isAfter(yesterday)) { return { order: 1, type: 'yesterday', mediaItem, }; } - if (mediaItemReceivedDate.isAfter(thisWeek)) { + if (messageTimestamp.isAfter(thisWeek)) { return { order: 2, type: 'thisWeek', mediaItem, }; } - if (mediaItemReceivedDate.isAfter(thisMonth)) { + if (messageTimestamp.isAfter(thisMonth)) { return { order: 3, type: 'thisMonth', @@ -146,8 +143,8 @@ const withSection = }; } - const month: number = mediaItemReceivedDate.month(); - const year: number = mediaItemReceivedDate.year(); + const month: number = messageTimestamp.month(); + const year: number = messageTimestamp.year(); return { order: year * 100 + month, @@ -157,3 +154,4 @@ const withSection = mediaItem, }; }; +}; diff --git a/ts/components/conversation/media-gallery/types/ItemClickEvent.ts b/ts/components/conversation/media-gallery/types/ItemClickEvent.ts index 1d938f06a445..d890f6702dc2 100644 --- a/ts/components/conversation/media-gallery/types/ItemClickEvent.ts +++ b/ts/components/conversation/media-gallery/types/ItemClickEvent.ts @@ -1,12 +1,10 @@ // Copyright 2018 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import type { ReadonlyMessageAttributesType } from '../../../../model-types.d'; import type { AttachmentType } from '../../../../types/Attachment'; export type ItemClickEvent = { - message: Pick; + message: { id: string; sentAt: number }; attachment: AttachmentType; - index: number; type: 'media' | 'documents'; }; diff --git a/ts/components/conversation/media-gallery/utils/mocks.ts b/ts/components/conversation/media-gallery/utils/mocks.ts index 4894de4ad85b..744eba6545cb 100644 --- a/ts/components/conversation/media-gallery/utils/mocks.ts +++ b/ts/components/conversation/media-gallery/utils/mocks.ts @@ -32,10 +32,10 @@ function createRandomFile( message: { conversationId: '123', id: random(Date.now()).toString(), - received_at: Math.floor(Math.random() * 10), - received_at_ms: random(startTime, startTime + timeWindow), + receivedAt: Math.floor(Math.random() * 10), + receivedAtMs: random(startTime, startTime + timeWindow), attachments: [], - sent_at: Date.now(), + sentAt: Date.now(), }, attachment: { url: '', @@ -86,6 +86,6 @@ export function createPreparedMediaItems( ...fn(now - days(30), days(15)), ...fn(now - days(365), days(300)), ], - (item: MediaItemType) => -item.message.received_at + (item: MediaItemType) => -item.message.receivedAt ); } diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 91702d9d7561..41d941ccb10b 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -55,6 +55,7 @@ export type AdjacentMessagesByConversationOptionsType = Readonly<{ sentAt?: number; storyId: string | undefined; requireVisualMediaAttachments?: boolean; + requireFileAttachments?: boolean; }>; export type GetNearbyMessageFromDeletedSetOptionsType = Readonly<{ @@ -637,14 +638,6 @@ type ReadableInterface = { limit: number, options: { maxVersion: number } ) => Array; - getMessagesWithVisualMediaAttachments: ( - conversationId: string, - options: { limit: number } - ) => Array; - getMessagesWithFileAttachments: ( - conversationId: string, - options: { limit: number } - ) => Array; getMessageServerGuidsForSpam: (conversationId: string) => Array; getJobsInQueue(queueType: string): Array; diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 0b72e22fa0ca..fa6bbd7080aa 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -336,8 +336,6 @@ export const DataReader: ServerReadableInterface = { _getAllStoryReads, getLastStoryReadsForAuthor, getMessagesNeedingUpgrade, - getMessagesWithVisualMediaAttachments, - getMessagesWithFileAttachments, getMessageServerGuidsForSpam, getJobsInQueue, @@ -2872,6 +2870,7 @@ function getAdjacentMessagesByConversation( receivedAt = direction === AdjacentDirection.Older ? Number.MAX_VALUE : 0, sentAt = direction === AdjacentDirection.Older ? Number.MAX_VALUE : 0, requireVisualMediaAttachments, + requireFileAttachments, storyId, }: AdjacentMessagesByConversationOptionsType ): Array { @@ -2893,7 +2892,9 @@ function getAdjacentMessagesByConversation( } const requireDifferentMessage = - direction === AdjacentDirection.Older || requireVisualMediaAttachments; + direction === AdjacentDirection.Older || + requireVisualMediaAttachments || + requireFileAttachments; const createQuery = (timeFilter: QueryFragment): QueryFragment => sqlFragment` SELECT json FROM messages WHERE @@ -2908,6 +2909,11 @@ function getAdjacentMessagesByConversation( ? sqlFragment`hasVisualMediaAttachments IS 1 AND` : sqlFragment`` } + ${ + requireFileAttachments + ? sqlFragment`hasFileAttachments IS 1 AND` + : sqlFragment`` + } isStory IS 0 AND (${_storyIdPredicate(storyId, includeStoryReplies)}) AND ( @@ -2938,6 +2944,20 @@ function getAdjacentMessagesByConversation( ) > 0 LIMIT ${limit}; `; + } else if (requireFileAttachments) { + template = sqlFragment` + SELECT json + FROM (${template}) as messages + WHERE + ( + SELECT COUNT(*) + FROM json_each(messages.json ->> 'attachments') AS attachment + WHERE + attachment.value ->> 'pending' IS NOT 1 AND + attachment.value ->> 'error' IS NULL + ) > 0 + LIMIT ${limit}; + `; } else { template = sqlFragment`${template} LIMIT ${limit}`; } @@ -6515,60 +6535,6 @@ export function incrementMessagesMigrationAttempts( }); } -function getMessagesWithVisualMediaAttachments( - db: ReadableDB, - conversationId: string, - { limit }: { limit: number } -): Array { - const rows: JSONRows = db - .prepare( - ` - SELECT json FROM messages - INDEXED BY messages_hasVisualMediaAttachments - WHERE - isStory IS 0 AND - storyId IS NULL AND - conversationId = $conversationId AND - -- Note that this check has to use 'IS' to utilize - -- 'messages_hasVisualMediaAttachments' INDEX - hasVisualMediaAttachments IS 1 - ORDER BY received_at DESC, sent_at DESC - LIMIT $limit; - ` - ) - .all({ - conversationId, - limit, - }); - - return rows.map(row => jsonToObject(row.json)); -} - -function getMessagesWithFileAttachments( - db: ReadableDB, - conversationId: string, - { limit }: { limit: number } -): Array { - const rows = db - .prepare( - ` - SELECT json FROM messages WHERE - isStory IS 0 AND - storyId IS NULL AND - conversationId = $conversationId AND - hasFileAttachments = 1 - ORDER BY received_at DESC, sent_at DESC - LIMIT $limit; - ` - ) - .all({ - conversationId, - limit, - }); - - return map(rows, row => jsonToObject(row.json)); -} - function getMessageServerGuidsForSpam( db: ReadableDB, conversationId: string diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 1a74ff2fb6f7..d510a139148b 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -3733,8 +3733,12 @@ function loadRecentMediaItems( ): ThunkAction { return async dispatch => { const messages: Array = - await DataReader.getMessagesWithVisualMediaAttachments(conversationId, { + await DataReader.getOlderMessagesByConversation({ + conversationId, limit, + requireVisualMediaAttachments: true, + storyId: undefined, + includeStoryReplies: false, }); // Cache these messages in memory to ensure Lightbox can find them @@ -3772,9 +3776,9 @@ function loadRecentMediaItems( window.ConversationController.get(message.sourceServiceId) ?.id || message.conversationId, id: message.id, - received_at: message.received_at, - received_at_ms: Number(message.received_at_ms), - sent_at: message.sent_at, + receivedAt: message.received_at, + receivedAtMs: Number(message.received_at_ms), + sentAt: message.sent_at, }, }; diff --git a/ts/state/ducks/lightbox.ts b/ts/state/ducks/lightbox.ts index b494614a5b97..9d4ed4d54305 100644 --- a/ts/state/ducks/lightbox.ts +++ b/ts/state/ducks/lightbox.ts @@ -150,22 +150,6 @@ function setPlaybackDisabled( }; } -function showLightboxWithMedia( - selectedIndex: number | undefined, - media: ReadonlyArray> -): ShowLightboxActionType { - return { - type: SHOW_LIGHTBOX, - payload: { - isViewOnce: false, - media, - selectedIndex, - hasPrevMessage: false, - hasNextMessage: false, - }, - }; -} - function showLightboxForViewOnceMedia( messageId: string ): ThunkAction { @@ -224,9 +208,9 @@ function showLightboxForViewOnceMedia( attachments: message.get('attachments') || [], id: message.get('id'), conversationId: message.get('conversationId'), - received_at: message.get('received_at'), - received_at_ms: Number(message.get('received_at_ms')), - sent_at: message.get('sent_at'), + receivedAt: message.get('received_at'), + receivedAtMs: Number(message.get('received_at_ms')), + sentAt: message.get('sent_at'), }, }, ]; @@ -313,9 +297,9 @@ function showLightbox(opts: { attachments: message.get('attachments') || [], id: messageId, conversationId: authorId, - received_at: receivedAt, - received_at_ms: Number(message.get('received_at_ms')), - sent_at: sentAt, + receivedAt, + receivedAtMs: Number(message.get('received_at_ms')), + sentAt, }, attachment: item, thumbnailObjectUrl: @@ -401,11 +385,7 @@ function showLightboxForAdjacentMessage( } const [media] = lightbox.media; - const { - id: messageId, - received_at: receivedAt, - sent_at: sentAt, - } = media.message; + const { id: messageId, receivedAt, sentAt } = media.message; const message = await __DEPRECATED$getMessageById(messageId); if (!message) { @@ -511,7 +491,6 @@ export const actions = { closeLightbox, showLightbox, showLightboxForViewOnceMedia, - showLightboxWithMedia, showLightboxForPrevMessage, showLightboxForNextMessage, setSelectedLightboxIndex, diff --git a/ts/state/ducks/mediaGallery.ts b/ts/state/ducks/mediaGallery.ts index 457b798ece3b..9d65b90f542c 100644 --- a/ts/state/ducks/mediaGallery.ts +++ b/ts/state/ducks/mediaGallery.ts @@ -2,19 +2,10 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { ThunkAction } from 'redux-thunk'; +import type { ReadonlyDeep } from 'type-fest'; -import type { AttachmentType } from '../../types/Attachment'; -import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions'; -import type { - ConversationUnloadedActionType, - MessageChangedActionType, - MessageDeletedActionType, - MessageExpiredActionType, -} from './conversations'; -import type { MIMEType } from '../../types/MIME'; -import type { MediaItemType } from '../../types/MediaItem'; -import type { StateType as RootStateType } from '../reducer'; - +import * as log from '../../logging/log'; +import * as Errors from '../../types/errors'; import { DataReader, DataWriter } from '../../sql/Client'; import { CONVERSATION_UNLOADED, @@ -28,165 +19,250 @@ import { isNotNil } from '../../util/isNotNil'; import { getLocalAttachmentUrl } from '../../util/getLocalAttachmentUrl'; import { useBoundActions } from '../../hooks/useBoundActions'; -// eslint-disable-next-line local-rules/type-alias-readonlydeep -type MediaType = { +import type { AttachmentType } from '../../types/Attachment'; +import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions'; +import type { + ConversationUnloadedActionType, + MessageChangedActionType, + MessageDeletedActionType, + MessageExpiredActionType, +} from './conversations'; +import type { MIMEType } from '../../types/MIME'; +import type { MediaItemType } from '../../types/MediaItem'; +import type { StateType as RootStateType } from '../reducer'; +import type { MessageAttributesType } from '../../model-types'; + +type MediaItemMessage = ReadonlyDeep<{ + attachments: Array; + conversationId: string; + id: string; + receivedAt: number; + receivedAtMs: number; + sentAt: number; +}>; +type MediaType = ReadonlyDeep<{ path: string; objectURL: string; thumbnailObjectUrl?: string; contentType: MIMEType; index: number; attachment: AttachmentType; - message: { - attachments: Array; - conversationId: string; - id: string; - received_at: number; - received_at_ms: number; - sent_at: number; - }; -}; + message: MediaItemMessage; +}>; -// eslint-disable-next-line local-rules/type-alias-readonlydeep -export type MediaGalleryStateType = { - documents: Array; - media: Array; -}; +export type MediaGalleryStateType = ReadonlyDeep<{ + conversationId: string | undefined; + documents: ReadonlyArray; + haveOldestDocument: boolean; + haveOldestMedia: boolean; + loading: boolean; + media: ReadonlyArray; +}>; -const LOAD_MEDIA_ITEMS = 'mediaGallery/LOAD_MEDIA_ITEMS'; +const FETCH_CHUNK_COUNT = 50; -// eslint-disable-next-line local-rules/type-alias-readonlydeep -type LoadMediaItemslActionType = { - type: typeof LOAD_MEDIA_ITEMS; +const INITIAL_LOAD = 'mediaGallery/INITIAL_LOAD'; +const LOAD_MORE_MEDIA = 'mediaGallery/LOAD_MORE_MEDIA'; +const LOAD_MORE_DOCUMENTS = 'mediaGallery/LOAD_MORE_DOCUMENTS'; +const SET_LOADING = 'mediaGallery/SET_LOADING'; + +type InitialLoadActionType = ReadonlyDeep<{ + type: typeof INITIAL_LOAD; payload: { - documents: Array; - media: Array; + conversationId: string; + documents: ReadonlyArray; + media: ReadonlyArray; }; -}; +}>; +type LoadMoreMediaActionType = ReadonlyDeep<{ + type: typeof LOAD_MORE_MEDIA; + payload: { + conversationId: string; + media: ReadonlyArray; + }; +}>; +type LoadMoreDocumentsActionType = ReadonlyDeep<{ + type: typeof LOAD_MORE_DOCUMENTS; + payload: { + conversationId: string; + documents: ReadonlyArray; + }; +}>; +type SetLoadingActionType = ReadonlyDeep<{ + type: typeof SET_LOADING; + payload: { + loading: boolean; + }; +}>; -// eslint-disable-next-line local-rules/type-alias-readonlydeep -type MediaGalleryActionType = +type MediaGalleryActionType = ReadonlyDeep< | ConversationUnloadedActionType - | LoadMediaItemslActionType + | InitialLoadActionType + | LoadMoreDocumentsActionType + | LoadMoreMediaActionType | MessageChangedActionType | MessageDeletedActionType - | MessageExpiredActionType; + | MessageExpiredActionType + | SetLoadingActionType +>; -function loadMediaItems( - conversationId: string -): ThunkAction { - return async dispatch => { - const { upgradeMessageSchema } = window.Signal.Migrations; +function _getMediaItemMessage( + message: ReadonlyDeep +): MediaItemMessage { + return { + attachments: message.attachments || [], + conversationId: + window.ConversationController.lookupOrCreate({ + serviceId: message.sourceServiceId, + e164: message.source, + reason: 'conversation_view.showAllMedia', + })?.id || message.conversationId, + id: message.id, + receivedAt: message.received_at, + receivedAtMs: Number(message.received_at_ms), + sentAt: message.sent_at, + }; +} - // We fetch more documents than media as they don’t require to be loaded - // into memory right away. Revisit this once we have infinite scrolling: - const DEFAULT_MEDIA_FETCH_COUNT = 50; - const DEFAULT_DOCUMENTS_FETCH_COUNT = 150; +function _cleanVisualAttachments( + rawMedia: ReadonlyDeep> +): ReadonlyArray { + let index = 0; - const ourAci = window.textsecure.storage.user.getCheckedAci(); + return rawMedia + .flatMap(message => { + return (message.attachments || []).map( + (attachment: AttachmentType): MediaType | undefined => { + if ( + !attachment.path || + !attachment.thumbnail || + isDownloading(attachment) || + hasFailed(attachment) + ) { + return; + } - const rawMedia = await DataReader.getMessagesWithVisualMediaAttachments( - conversationId, - { - limit: DEFAULT_MEDIA_FETCH_COUNT, + const { thumbnail } = attachment; + const result = { + path: attachment.path, + objectURL: getLocalAttachmentUrl(attachment), + thumbnailObjectUrl: thumbnail?.path + ? getLocalAttachmentUrl(thumbnail) + : undefined, + contentType: attachment.contentType, + index, + attachment, + message: _getMediaItemMessage(message), + }; + + index += 1; + + return result; + } + ); + }) + .filter(isNotNil); +} + +function _cleanFileAttachments( + rawDocuments: ReadonlyDeep> +): ReadonlyArray { + return rawDocuments + .map(message => { + const attachments = message.attachments || []; + const attachment = attachments[0]; + if (!attachment) { + return; } - ); - const rawDocuments = await DataReader.getMessagesWithFileAttachments( - conversationId, - { - limit: DEFAULT_DOCUMENTS_FETCH_COUNT, - } - ); - // First we upgrade these messages to ensure that they have thumbnails - await Promise.all( - rawMedia.map(async message => { - const { schemaVersion } = message; - const model = window.MessageCache.__DEPRECATED$register( - message.id, - message, - 'loadMediaItems' - ); + return { + contentType: attachment.contentType, + index: 0, + attachment, + message: { + ..._getMediaItemMessage(message), + attachments: [attachment], + }, + }; + }) + .filter(isNotNil); +} +async function _upgradeMessages( + messages: ReadonlyArray +): Promise> { + const { upgradeMessageSchema } = window.Signal.Migrations; + const ourAci = window.textsecure.storage.user.getCheckedAci(); + + // We upgrade these messages so they are sure to have thumbnails + const upgraded = await Promise.all( + messages.map(async message => { + const { schemaVersion } = message; + const model = window.MessageCache.__DEPRECATED$register( + message.id, + message, + 'loadMediaItems' + ); + + try { if (schemaVersion && schemaVersion < VERSION_NEEDED_FOR_DISPLAY) { const upgradedMsgAttributes = await upgradeMessageSchema(message); model.set(upgradedMsgAttributes); await DataWriter.saveMessage(upgradedMsgAttributes, { ourAci }); } - }) - ); - - let index = 0; - const media: Array = rawMedia - .flatMap(message => { - return (message.attachments || []).map( - (attachment: AttachmentType): MediaType | undefined => { - if ( - !attachment.path || - !attachment.thumbnail || - isDownloading(attachment) || - hasFailed(attachment) - ) { - return; - } - - const { thumbnail } = attachment; - const result = { - path: attachment.path, - objectURL: getLocalAttachmentUrl(attachment), - thumbnailObjectUrl: thumbnail?.path - ? getLocalAttachmentUrl(thumbnail) - : undefined, - contentType: attachment.contentType, - index, - attachment, - message: { - attachments: message.attachments || [], - conversationId: - window.ConversationController.lookupOrCreate({ - serviceId: message.sourceServiceId, - e164: message.source, - reason: 'conversation_view.showAllMedia', - })?.id || message.conversationId, - id: message.id, - received_at: message.received_at, - received_at_ms: Number(message.received_at_ms), - sent_at: message.sent_at, - }, - }; - - index += 1; - - return result; - } + } catch (error) { + log.warn( + `_upgradeMessages: Failed to upgrade message ${model.idForLogging()}: ${Errors.toLogFormat(error)}` ); - }) - .filter(isNotNil); + return undefined; + } - // Unlike visual media, only one non-image attachment is supported - const documents: Array = rawDocuments - .map(message => { - const attachments = message.attachments || []; - const attachment = attachments[0]; - if (!attachment) { - return; - } + return model.attributes; + }) + ); - return { - contentType: attachment.contentType, - index: 0, - attachment, - message: { - ...message, - attachments: [attachment], - }, - }; - }) - .filter(isNotNil); + return upgraded.filter(isNotNil); +} + +function initialLoad( + conversationId: string +): ThunkAction< + void, + RootStateType, + unknown, + InitialLoadActionType | SetLoadingActionType +> { + return async dispatch => { + dispatch({ + type: SET_LOADING, + payload: { loading: true }, + }); + + const rawMedia = await DataReader.getOlderMessagesByConversation({ + conversationId, + includeStoryReplies: false, + limit: FETCH_CHUNK_COUNT, + requireVisualMediaAttachments: true, + storyId: undefined, + }); + const rawDocuments = await DataReader.getOlderMessagesByConversation({ + conversationId, + includeStoryReplies: false, + limit: FETCH_CHUNK_COUNT, + requireFileAttachments: true, + storyId: undefined, + }); + + const upgraded = await _upgradeMessages(rawMedia); + const media = _cleanVisualAttachments(upgraded); + + const documents = _cleanFileAttachments(rawDocuments); dispatch({ - type: LOAD_MEDIA_ITEMS, + type: INITIAL_LOAD, payload: { + conversationId, documents, media, }, @@ -194,8 +270,127 @@ function loadMediaItems( }; } +function loadMoreMedia( + conversationId: string +): ThunkAction< + void, + RootStateType, + unknown, + InitialLoadActionType | LoadMoreMediaActionType | SetLoadingActionType +> { + return async (dispatch, getState) => { + const { conversationId: previousConversationId, media: previousMedia } = + getState().mediaGallery; + + if (conversationId !== previousConversationId) { + log.warn('loadMoreMedia: conversationId mismatch; calling initialLoad()'); + initialLoad(conversationId)(dispatch, getState, {}); + return; + } + + const firstMedia = previousMedia[0]; + if (!firstMedia) { + log.warn('loadMoreMedia: no previous media; calling initialLoad()'); + initialLoad(conversationId)(dispatch, getState, {}); + return; + } + + dispatch({ + type: SET_LOADING, + payload: { loading: true }, + }); + + const { sentAt, receivedAt, id: messageId } = firstMedia.message; + + const rawMedia = await DataReader.getOlderMessagesByConversation({ + conversationId, + includeStoryReplies: false, + limit: FETCH_CHUNK_COUNT, + messageId, + receivedAt, + requireVisualMediaAttachments: true, + sentAt, + storyId: undefined, + }); + + const upgraded = await _upgradeMessages(rawMedia); + const media = _cleanVisualAttachments(upgraded); + + dispatch({ + type: LOAD_MORE_MEDIA, + payload: { + conversationId, + media, + }, + }); + }; +} + +function loadMoreDocuments( + conversationId: string +): ThunkAction< + void, + RootStateType, + unknown, + InitialLoadActionType | LoadMoreDocumentsActionType | SetLoadingActionType +> { + return async (dispatch, getState) => { + const { + conversationId: previousConversationId, + documents: previousDocuments, + } = getState().mediaGallery; + + if (conversationId !== previousConversationId) { + log.warn( + 'loadMoreDocuments: conversationId mismatch; calling initialLoad()' + ); + initialLoad(conversationId)(dispatch, getState, {}); + return; + } + + const firstDocument = previousDocuments[0]; + if (!firstDocument) { + log.warn( + 'loadMoreDocuments: no previous documents; calling initialLoad()' + ); + initialLoad(conversationId)(dispatch, getState, {}); + return; + } + + dispatch({ + type: SET_LOADING, + payload: { loading: true }, + }); + + const { sentAt, receivedAt, id: messageId } = firstDocument.message; + + const rawDocuments = await DataReader.getOlderMessagesByConversation({ + conversationId, + includeStoryReplies: false, + limit: FETCH_CHUNK_COUNT, + messageId, + receivedAt, + requireFileAttachments: true, + sentAt, + storyId: undefined, + }); + + const documents = _cleanFileAttachments(rawDocuments); + + dispatch({ + type: LOAD_MORE_DOCUMENTS, + payload: { + conversationId, + documents, + }, + }); + }; +} + export const actions = { - loadMediaItems, + initialLoad, + loadMoreMedia, + loadMoreDocuments, }; export const useMediaGalleryActions = (): BoundActionCreatorsMapObject< @@ -204,7 +399,11 @@ export const useMediaGalleryActions = (): BoundActionCreatorsMapObject< export function getEmptyState(): MediaGalleryStateType { return { + conversationId: undefined, documents: [], + haveOldestDocument: false, + haveOldestMedia: false, + loading: true, media: [], }; } @@ -213,27 +412,100 @@ export function reducer( state: Readonly = getEmptyState(), action: Readonly ): MediaGalleryStateType { - if (action.type === LOAD_MEDIA_ITEMS) { + if (action.type === SET_LOADING) { + const { loading } = action.payload; + return { ...state, - ...action.payload, + loading, }; } - if (action.type === MESSAGE_CHANGED) { - if (!action.payload.data.deletedForEveryone) { + if (action.type === INITIAL_LOAD) { + return { + ...state, + loading: false, + ...action.payload, + haveOldestDocument: false, + haveOldestMedia: false, + }; + } + + if (action.type === LOAD_MORE_MEDIA) { + const { conversationId, media } = action.payload; + if (state.conversationId !== conversationId) { return state; } return { ...state, - media: state.media.filter(item => item.message.id !== action.payload.id), - documents: state.documents.filter( - item => item.message.id !== action.payload.id - ), + loading: false, + haveOldestMedia: media.length === 0, + media: media.concat(state.media), }; } + if (action.type === LOAD_MORE_DOCUMENTS) { + const { conversationId, documents } = action.payload; + if (state.conversationId !== conversationId) { + return state; + } + + return { + ...state, + loading: false, + haveOldestDocument: documents.length === 0, + documents: documents.concat(state.documents), + }; + } + + // We don't capture the initial message add, but we do capture the moment when its + // attachments have been downloaded + if (action.type === MESSAGE_CHANGED) { + const { payload } = action; + const { conversationId, data: message } = payload; + + if (conversationId !== state.conversationId) { + return state; + } + + if (!message.attachments || message.attachments.length === 0) { + return state; + } + + const mediaWithout = state.media.filter( + item => item.message.id !== message.id + ); + const documentsWithout = state.documents.filter( + item => item.message.id !== message.id + ); + + if (message.deletedForEveryone) { + return { + ...state, + media: mediaWithout, + documents: documentsWithout, + }; + } + + // Check whether we have new downloaded media, or an attachment has been deleted + const mediaCount = state.media.length - mediaWithout.length; + const documentCount = state.documents.length - mediaWithout.length; + + const media = _cleanVisualAttachments([message]); + const documents = _cleanFileAttachments([message]); + + if (mediaCount !== media.length || documentCount !== documents.length) { + return { + ...state, + media: mediaWithout.concat(media), + documents: documentsWithout.concat(documents), + }; + } + + return state; + } + if (action.type === MESSAGE_DELETED || action.type === MESSAGE_EXPIRED) { return { ...state, diff --git a/ts/state/smart/AllMedia.tsx b/ts/state/smart/AllMedia.tsx index 5bc55093273a..02ac06768eb7 100644 --- a/ts/state/smart/AllMedia.tsx +++ b/ts/state/smart/AllMedia.tsx @@ -15,19 +15,26 @@ export type PropsType = { export const SmartAllMedia = memo(function SmartAllMedia({ conversationId, }: PropsType) { - const { media, documents } = useSelector(getMediaGalleryState); - const { loadMediaItems } = useMediaGalleryActions(); + const { media, documents, haveOldestDocument, haveOldestMedia, loading } = + useSelector(getMediaGalleryState); + const { initialLoad, loadMoreMedia, loadMoreDocuments } = + useMediaGalleryActions(); const { saveAttachment } = useConversationsActions(); - const { showLightboxWithMedia } = useLightboxActions(); + const { showLightbox } = useLightboxActions(); return ( ); diff --git a/ts/state/smart/ConversationDetails.tsx b/ts/state/smart/ConversationDetails.tsx index 6355c4b70479..edbca885eb0a 100644 --- a/ts/state/smart/ConversationDetails.tsx +++ b/ts/state/smart/ConversationDetails.tsx @@ -128,7 +128,7 @@ export const SmartConversationDetails = memo(function SmartConversationDetails({ toggleEditNicknameAndNoteModal, toggleSafetyNumberModal, } = useGlobalModalActions(); - const { showLightboxWithMedia } = useLightboxActions(); + const { showLightbox } = useLightboxActions(); const conversation = conversationSelector(conversationId); assertDev( @@ -215,7 +215,7 @@ export const SmartConversationDetails = memo(function SmartConversationDetails({ setMuteExpiration={setMuteExpiration} showContactModal={showContactModal} showConversation={showConversation} - showLightboxWithMedia={showLightboxWithMedia} + showLightbox={showLightbox} theme={theme} toggleAboutContactModal={toggleAboutContactModal} toggleAddUserToAnotherGroupModal={toggleAddUserToAnotherGroupModal} diff --git a/ts/state/smart/ConversationHeader.tsx b/ts/state/smart/ConversationHeader.tsx index d83bf0c152d5..176613a1de61 100644 --- a/ts/state/smart/ConversationHeader.tsx +++ b/ts/state/smart/ConversationHeader.tsx @@ -241,7 +241,7 @@ export const SmartConversationHeader = memo(function SmartConversationHeader({ pushPanelForConversation({ type: PanelType.ConversationDetails }); }, [pushPanelForConversation]); - const onViewRecentMedia = useCallback(() => { + const onViewAllMedia = useCallback(() => { pushPanelForConversation({ type: PanelType.AllMedia }); }, [pushPanelForConversation]); @@ -298,7 +298,7 @@ export const SmartConversationHeader = memo(function SmartConversationHeader({ onSelectModeEnter={onSelectModeEnter} onShowMembers={onShowMembers} onViewConversationDetails={onViewConversationDetails} - onViewRecentMedia={onViewRecentMedia} + onViewAllMedia={onViewAllMedia} onViewUserStories={onViewUserStories} outgoingCallButtonStyle={outgoingCallButtonStyle} setLocalDeleteWarningShown={setLocalDeleteWarningShown} diff --git a/ts/test-electron/sql/allMedia_test.ts b/ts/test-electron/sql/allMedia_test.ts deleted file mode 100644 index ead09f5413ef..000000000000 --- a/ts/test-electron/sql/allMedia_test.ts +++ /dev/null @@ -1,237 +0,0 @@ -// Copyright 2021 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import { assert } from 'chai'; -import { v4 as generateUuid } from 'uuid'; - -import { DataReader, DataWriter } from '../../sql/Client'; -import { generateAci } from '../../types/ServiceId'; - -import type { MessageAttributesType } from '../../model-types.d'; - -const { - _getAllMessages, - getMessagesWithVisualMediaAttachments, - getMessagesWithFileAttachments, -} = DataReader; -const { removeAll, saveMessages } = DataWriter; - -describe('sql/allMedia', () => { - beforeEach(async () => { - await removeAll(); - }); - - describe('getMessagesWithVisualMediaAttachments', () => { - it('returns messages matching with visual attachments', async () => { - assert.lengthOf(await _getAllMessages(), 0); - - const now = Date.now(); - const conversationId = generateUuid(); - const ourAci = generateAci(); - const message1: MessageAttributesType = { - id: generateUuid(), - body: 'message 1', - type: 'outgoing', - conversationId, - sent_at: now - 20, - received_at: now - 20, - timestamp: now - 20, - hasVisualMediaAttachments: true, - }; - const message2: MessageAttributesType = { - id: generateUuid(), - body: 'message 2', - type: 'outgoing', - conversationId, - sent_at: now - 10, - received_at: now - 10, - timestamp: now - 10, - }; - const message3: MessageAttributesType = { - id: generateUuid(), - body: 'message 3', - type: 'outgoing', - conversationId: generateUuid(), - sent_at: now, - received_at: now, - timestamp: now, - hasVisualMediaAttachments: true, - }; - - await saveMessages([message1, message2, message3], { - forceSave: true, - ourAci, - }); - - assert.lengthOf(await _getAllMessages(), 3); - - const searchResults = await getMessagesWithVisualMediaAttachments( - conversationId, - { limit: 5 } - ); - assert.lengthOf(searchResults, 1); - assert.strictEqual(searchResults[0].id, message1.id); - }); - - it('excludes stories and story replies', async () => { - assert.lengthOf(await _getAllMessages(), 0); - - const now = Date.now(); - const conversationId = generateUuid(); - const ourAci = generateAci(); - const message1: MessageAttributesType = { - id: generateUuid(), - body: 'message 1', - type: 'outgoing', - conversationId, - sent_at: now - 20, - received_at: now - 20, - timestamp: now - 20, - hasVisualMediaAttachments: true, - }; - const message2: MessageAttributesType = { - id: generateUuid(), - body: 'message 2', - type: 'outgoing', - conversationId, - sent_at: now - 10, - received_at: now - 10, - timestamp: now - 10, - storyId: generateUuid(), - hasVisualMediaAttachments: true, - }; - const message3: MessageAttributesType = { - id: generateUuid(), - body: 'message 3', - type: 'story', - conversationId, - sent_at: now, - received_at: now, - timestamp: now, - storyId: generateUuid(), - hasVisualMediaAttachments: true, - }; - - await saveMessages([message1, message2, message3], { - forceSave: true, - ourAci, - }); - - assert.lengthOf(await _getAllMessages(), 3); - - const searchResults = await getMessagesWithVisualMediaAttachments( - conversationId, - { limit: 5 } - ); - assert.lengthOf(searchResults, 1); - assert.strictEqual(searchResults[0].id, message1.id); - }); - }); - - describe('getMessagesWithFileAttachments', () => { - it('returns messages matching with visual attachments', async () => { - assert.lengthOf(await _getAllMessages(), 0); - - const now = Date.now(); - const conversationId = generateUuid(); - const ourAci = generateAci(); - const message1: MessageAttributesType = { - id: generateUuid(), - body: 'message 1', - type: 'outgoing', - conversationId, - sent_at: now - 20, - received_at: now - 20, - timestamp: now - 20, - hasFileAttachments: true, - }; - const message2: MessageAttributesType = { - id: generateUuid(), - body: 'message 2', - type: 'outgoing', - conversationId, - sent_at: now - 10, - received_at: now - 10, - timestamp: now - 10, - }; - const message3: MessageAttributesType = { - id: generateUuid(), - body: 'message 3', - type: 'outgoing', - conversationId: generateUuid(), - sent_at: now, - received_at: now, - timestamp: now, - hasFileAttachments: true, - }; - - await saveMessages([message1, message2, message3], { - forceSave: true, - ourAci, - }); - - assert.lengthOf(await _getAllMessages(), 3); - - const searchResults = await getMessagesWithFileAttachments( - conversationId, - { limit: 5 } - ); - assert.lengthOf(searchResults, 1); - assert.strictEqual(searchResults[0].id, message1.id); - }); - - it('excludes stories and story replies', async () => { - assert.lengthOf(await _getAllMessages(), 0); - - const now = Date.now(); - const conversationId = generateUuid(); - const ourAci = generateAci(); - const message1: MessageAttributesType = { - id: generateUuid(), - body: 'message 1', - type: 'outgoing', - conversationId, - sent_at: now - 20, - received_at: now - 20, - timestamp: now - 20, - hasFileAttachments: true, - }; - const message2: MessageAttributesType = { - id: generateUuid(), - body: 'message 2', - type: 'outgoing', - conversationId, - sent_at: now - 10, - received_at: now - 10, - timestamp: now - 10, - storyId: generateUuid(), - hasFileAttachments: true, - }; - const message3: MessageAttributesType = { - id: generateUuid(), - body: 'message 3', - type: 'story', - conversationId, - sent_at: now, - received_at: now, - timestamp: now, - storyId: generateUuid(), - hasFileAttachments: true, - }; - - await saveMessages([message1, message2, message3], { - forceSave: true, - ourAci, - }); - - assert.lengthOf(await _getAllMessages(), 3); - - const searchResults = await getMessagesWithFileAttachments( - conversationId, - { limit: 5 } - ); - assert.lengthOf(searchResults, 1); - assert.strictEqual(searchResults[0].id, message1.id); - }); - }); -}); diff --git a/ts/test-node/components/media-gallery/groupMediaItemsByDate.ts b/ts/test-node/components/media-gallery/groupMediaItemsByDate.ts new file mode 100644 index 000000000000..0ada15e6f7c5 --- /dev/null +++ b/ts/test-node/components/media-gallery/groupMediaItemsByDate.ts @@ -0,0 +1,97 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import { shuffle } from 'lodash'; + +import { IMAGE_JPEG } from '../../../types/MIME'; +import { groupMediaItemsByDate } from '../../../components/conversation/media-gallery/groupMediaItemsByDate'; +import type { MediaItemType } from '../../../types/MediaItem'; +import { fakeAttachment } from '../../../test-both/helpers/fakeAttachment'; + +const testDate = ( + year: number, + month: number, + day: number, + hour: number, + minute: number, + second = 0 +): Date => new Date(year, month - 1, day, hour, minute, second, 0); + +const toMediaItem = (id: string, date: Date): MediaItemType => { + return { + objectURL: id, + index: 0, + message: { + conversationId: '1234', + id: 'id', + receivedAt: date.getTime(), + receivedAtMs: date.getTime(), + attachments: [], + sentAt: date.getTime(), + }, + attachment: fakeAttachment({ + fileName: 'fileName', + contentType: IMAGE_JPEG, + url: 'url', + }), + }; +}; + +describe('groupMediaItemsByDate', () => { + it('should group mediaItems', () => { + const referenceTime = testDate(2024, 4, 12, 18, 0, 0).getTime(); // Friday + const input: Array = shuffle([ + toMediaItem('today-1', testDate(2024, 4, 12, 17, 59)), // Friday, one minute ago + toMediaItem('today-2', testDate(2024, 4, 12, 0, 1)), // Friday early morning + toMediaItem('yesterday-1', testDate(2024, 4, 11, 18, 0)), // Thursday + toMediaItem('yesterday-2', testDate(2024, 4, 11, 0, 1)), // Thursday early morning + toMediaItem('thisWeek-1', testDate(2024, 4, 10, 18, 0)), // Wednesday + toMediaItem('thisWeek-2', testDate(2024, 4, 8, 18, 0)), // Monday + toMediaItem('thisWeek-3', testDate(2024, 4, 5, 18, 0)), // Last Friday + toMediaItem('thisWeek-4', testDate(2024, 4, 5, 0, 1)), // Last Friday early morning + toMediaItem('thisMonth-1', testDate(2024, 4, 2, 18, 0)), // Second day of moth + toMediaItem('thisMonth-2', testDate(2024, 4, 1, 18, 0)), // First day of month + toMediaItem('mar2024-1', testDate(2024, 3, 31, 23, 59)), + toMediaItem('mar2024-2', testDate(2024, 3, 1, 0, 1)), + toMediaItem('feb2011-1', testDate(2011, 2, 28, 23, 59)), + toMediaItem('feb2011-2', testDate(2011, 2, 1, 0, 1)), + ]); + + const actual = groupMediaItemsByDate(referenceTime, input); + + assert.strictEqual(actual[0].type, 'today'); + assert.strictEqual(actual[0].mediaItems.length, 2, 'today'); + assert.strictEqual(actual[0].mediaItems[0].objectURL, 'today-1'); + assert.strictEqual(actual[0].mediaItems[1].objectURL, 'today-2'); + + assert.strictEqual(actual[1].type, 'yesterday'); + assert.strictEqual(actual[1].mediaItems.length, 2, 'yesterday'); + assert.strictEqual(actual[1].mediaItems[0].objectURL, 'yesterday-1'); + assert.strictEqual(actual[1].mediaItems[1].objectURL, 'yesterday-2'); + + assert.strictEqual(actual[2].type, 'thisWeek'); + assert.strictEqual(actual[2].mediaItems.length, 4, 'thisWeek'); + assert.strictEqual(actual[2].mediaItems[0].objectURL, 'thisWeek-1'); + assert.strictEqual(actual[2].mediaItems[1].objectURL, 'thisWeek-2'); + assert.strictEqual(actual[2].mediaItems[2].objectURL, 'thisWeek-3'); + assert.strictEqual(actual[2].mediaItems[3].objectURL, 'thisWeek-4'); + + assert.strictEqual(actual[3].type, 'thisMonth'); + assert.strictEqual(actual[3].mediaItems.length, 2, 'thisMonth'); + assert.strictEqual(actual[3].mediaItems[0].objectURL, 'thisMonth-1'); + assert.strictEqual(actual[3].mediaItems[1].objectURL, 'thisMonth-2'); + + assert.strictEqual(actual[4].type, 'yearMonth'); + assert.strictEqual(actual[4].mediaItems.length, 2, 'mar2024'); + assert.strictEqual(actual[4].mediaItems[0].objectURL, 'mar2024-1'); + assert.strictEqual(actual[4].mediaItems[1].objectURL, 'mar2024-2'); + + assert.strictEqual(actual[5].type, 'yearMonth'); + assert.strictEqual(actual[5].mediaItems.length, 2, 'feb2011'); + assert.strictEqual(actual[5].mediaItems[0].objectURL, 'feb2011-1'); + assert.strictEqual(actual[5].mediaItems[1].objectURL, 'feb2011-2'); + + assert.strictEqual(actual.length, 6, 'total sections'); + }); +}); diff --git a/ts/test-node/components/media-gallery/groupMessagesByDate_test.ts b/ts/test-node/components/media-gallery/groupMessagesByDate_test.ts deleted file mode 100644 index 0a20c1a976ee..000000000000 --- a/ts/test-node/components/media-gallery/groupMessagesByDate_test.ts +++ /dev/null @@ -1,271 +0,0 @@ -// Copyright 2018 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import { assert } from 'chai'; -import { shuffle } from 'lodash'; - -import { IMAGE_JPEG } from '../../../types/MIME'; -import type { Section } from '../../../components/conversation/media-gallery/groupMediaItemsByDate'; -import { groupMediaItemsByDate } from '../../../components/conversation/media-gallery/groupMediaItemsByDate'; -import type { MediaItemType } from '../../../types/MediaItem'; -import { fakeAttachment } from '../../../test-both/helpers/fakeAttachment'; - -const testDate = ( - year: number, - month: number, - day: number, - hour: number, - minute: number, - second = 0 -): Date => new Date(Date.UTC(year, month - 1, day, hour, minute, second, 0)); - -const toMediaItem = (date: Date): MediaItemType => ({ - objectURL: date.toUTCString(), - index: 0, - message: { - conversationId: '1234', - id: 'id', - received_at: date.getTime(), - received_at_ms: date.getTime(), - attachments: [], - sent_at: date.getTime(), - }, - attachment: fakeAttachment({ - fileName: 'fileName', - contentType: IMAGE_JPEG, - url: 'url', - }), -}); - -describe('groupMediaItemsByDate', () => { - it('should group mediaItems', () => { - const referenceTime = testDate(2018, 4, 12, 18, 0, 0).getTime(); // Thu - const input: Array = shuffle([ - // Today - toMediaItem(testDate(2018, 4, 12, 12, 0)), // Thu - toMediaItem(testDate(2018, 4, 12, 0, 1)), // Thu - // This week - toMediaItem(testDate(2018, 4, 11, 23, 59)), // Wed - toMediaItem(testDate(2018, 4, 9, 0, 1)), // Mon - // This month - toMediaItem(testDate(2018, 4, 8, 23, 59)), // Sun - toMediaItem(testDate(2018, 4, 1, 0, 1)), - // March 2018 - toMediaItem(testDate(2018, 3, 31, 23, 59)), - toMediaItem(testDate(2018, 3, 1, 14, 0)), - // February 2011 - toMediaItem(testDate(2011, 2, 28, 23, 59)), - toMediaItem(testDate(2011, 2, 1, 10, 0)), - ]); - - const expected: Array
= [ - { - type: 'today', - mediaItems: [ - { - objectURL: 'Thu, 12 Apr 2018 12:00:00 GMT', - index: 0, - message: { - conversationId: '1234', - id: 'id', - received_at: 1523534400000, - received_at_ms: 1523534400000, - attachments: [], - sent_at: 1523534400000, - }, - attachment: fakeAttachment({ - fileName: 'fileName', - contentType: IMAGE_JPEG, - url: 'url', - }), - }, - { - objectURL: 'Thu, 12 Apr 2018 00:01:00 GMT', - index: 0, - message: { - conversationId: '1234', - id: 'id', - received_at: 1523491260000, - received_at_ms: 1523491260000, - attachments: [], - sent_at: 1523491260000, - }, - attachment: fakeAttachment({ - fileName: 'fileName', - contentType: IMAGE_JPEG, - url: 'url', - }), - }, - ], - }, - { - type: 'yesterday', - mediaItems: [ - { - objectURL: 'Wed, 11 Apr 2018 23:59:00 GMT', - index: 0, - message: { - conversationId: '1234', - id: 'id', - received_at: 1523491140000, - received_at_ms: 1523491140000, - attachments: [], - sent_at: 1523491140000, - }, - attachment: fakeAttachment({ - fileName: 'fileName', - contentType: IMAGE_JPEG, - url: 'url', - }), - }, - ], - }, - { - type: 'thisWeek', - mediaItems: [ - { - objectURL: 'Mon, 09 Apr 2018 00:01:00 GMT', - index: 0, - message: { - conversationId: '1234', - id: 'id', - received_at: 1523232060000, - received_at_ms: 1523232060000, - attachments: [], - sent_at: 1523232060000, - }, - attachment: fakeAttachment({ - fileName: 'fileName', - contentType: IMAGE_JPEG, - url: 'url', - }), - }, - ], - }, - { - type: 'thisMonth', - mediaItems: [ - { - objectURL: 'Sun, 08 Apr 2018 23:59:00 GMT', - index: 0, - message: { - conversationId: '1234', - id: 'id', - received_at: 1523231940000, - received_at_ms: 1523231940000, - attachments: [], - sent_at: 1523231940000, - }, - attachment: fakeAttachment({ - fileName: 'fileName', - contentType: IMAGE_JPEG, - url: 'url', - }), - }, - { - objectURL: 'Sun, 01 Apr 2018 00:01:00 GMT', - index: 0, - message: { - conversationId: '1234', - id: 'id', - received_at: 1522540860000, - received_at_ms: 1522540860000, - attachments: [], - sent_at: 1522540860000, - }, - attachment: fakeAttachment({ - fileName: 'fileName', - contentType: IMAGE_JPEG, - url: 'url', - }), - }, - ], - }, - { - type: 'yearMonth', - year: 2018, - month: 2, - mediaItems: [ - { - objectURL: 'Sat, 31 Mar 2018 23:59:00 GMT', - index: 0, - message: { - conversationId: '1234', - id: 'id', - received_at: 1522540740000, - received_at_ms: 1522540740000, - attachments: [], - sent_at: 1522540740000, - }, - attachment: fakeAttachment({ - fileName: 'fileName', - contentType: IMAGE_JPEG, - url: 'url', - }), - }, - { - objectURL: 'Thu, 01 Mar 2018 14:00:00 GMT', - index: 0, - message: { - conversationId: '1234', - id: 'id', - received_at: 1519912800000, - received_at_ms: 1519912800000, - attachments: [], - sent_at: 1519912800000, - }, - attachment: fakeAttachment({ - fileName: 'fileName', - contentType: IMAGE_JPEG, - url: 'url', - }), - }, - ], - }, - { - type: 'yearMonth', - year: 2011, - month: 1, - mediaItems: [ - { - objectURL: 'Mon, 28 Feb 2011 23:59:00 GMT', - index: 0, - message: { - conversationId: '1234', - id: 'id', - received_at: 1298937540000, - received_at_ms: 1298937540000, - attachments: [], - sent_at: 1298937540000, - }, - attachment: fakeAttachment({ - fileName: 'fileName', - contentType: IMAGE_JPEG, - url: 'url', - }), - }, - { - objectURL: 'Tue, 01 Feb 2011 10:00:00 GMT', - index: 0, - message: { - conversationId: '1234', - id: 'id', - received_at: 1296554400000, - received_at_ms: 1296554400000, - attachments: [], - sent_at: 1296554400000, - }, - attachment: fakeAttachment({ - fileName: 'fileName', - contentType: IMAGE_JPEG, - url: 'url', - }), - }, - ], - }, - ]; - - const actual = groupMediaItemsByDate(referenceTime, input); - assert.deepEqual(actual, expected); - }); -}); diff --git a/ts/types/Colors.ts b/ts/types/Colors.ts index 563e4aa5a51b..2e741a3dec82 100644 --- a/ts/types/Colors.ts +++ b/ts/types/Colors.ts @@ -88,7 +88,7 @@ export const AvatarColorMap = new Map([ ], ]); -export const AvatarColors = Array.from(AvatarColorMap.keys()); +export const AvatarColors = Array.from(AvatarColorMap.keys()).sort(); export const AVATAR_COLOR_COUNT = AvatarColors.length; diff --git a/ts/types/MediaItem.ts b/ts/types/MediaItem.ts index 15d8438f516e..2f5be6952324 100644 --- a/ts/types/MediaItem.ts +++ b/ts/types/MediaItem.ts @@ -7,13 +7,12 @@ import type { MIMEType } from './MIME'; export type MediaItemMessageType = Pick< ReadonlyMessageAttributesType, - | 'attachments' - | 'conversationId' - | 'id' - | 'received_at' - | 'received_at_ms' - | 'sent_at' ->; + 'attachments' | 'conversationId' | 'id' +> & { + receivedAt: number; + receivedAtMs?: number; + sentAt: number; +}; export type MediaItemType = { attachment: AttachmentType; diff --git a/ts/util/getColorForCallLink.ts b/ts/util/getColorForCallLink.ts index 105afa1f159d..330f8a42d036 100644 --- a/ts/util/getColorForCallLink.ts +++ b/ts/util/getColorForCallLink.ts @@ -10,7 +10,7 @@ const BASE_16_CONSONANT_ALPHABET = 'bcdfghkmnpqrstxz'; export function getColorForCallLink(rootKey: string): string { const rootKeyStart = rootKey.slice(0, 2); - const upper = BASE_16_CONSONANT_ALPHABET.indexOf(rootKeyStart[0]) || 0 * 16; + const upper = (BASE_16_CONSONANT_ALPHABET.indexOf(rootKeyStart[0]) || 0) * 16; const lower = BASE_16_CONSONANT_ALPHABET.indexOf(rootKeyStart[1]) || 0; const firstByte = upper + lower; diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index cae84b0733e0..b932b0f9f2f5 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -2877,6 +2877,38 @@ "updated": "2019-11-01T22:46:33.013Z", "reasonDetail": "Used for setting focus only" }, + { + "rule": "React-useRef", + "path": "ts/components/conversation/media-gallery/MediaGallery.tsx", + "line": " const loadingRef = useRef(false);", + "reasonCategory": "usageTrusted", + "updated": "2024-09-03T00:45:23.978Z", + "reasonDetail": "A boolean to help us avoid making too many 'load more' requests" + }, + { + "rule": "React-useRef", + "path": "ts/components/conversation/media-gallery/MediaGallery.tsx", + "line": " const intersectionObserver = useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2024-09-03T00:45:23.978Z", + "reasonDetail": "A non-modifying reference to IntersectionObserver" + }, + { + "rule": "React-useRef", + "path": "ts/components/conversation/media-gallery/MediaGallery.tsx", + "line": " const scrollObserverRef = useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2024-09-03T00:45:23.978Z", + "reasonDetail": "A non-modifying reference to the DOM" + }, + { + "rule": "React-useRef", + "path": "ts/components/conversation/media-gallery/MediaGallery.tsx", + "line": " const tabViewRef = useRef(TabViews.Media);", + "reasonCategory": "usageTrusted", + "updated": "2024-09-03T00:45:23.978Z", + "reasonDetail": "Because we need the current tab value outside the callback" + }, { "rule": "React-useRef", "path": "ts/components/emoji/EmojiButton.tsx",