Media Gallery: Scroll down and into the past
Co-authored-by: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com>
This commit is contained in:
parent
6f83043eb4
commit
0d5a480c1b
32 changed files with 844 additions and 887 deletions
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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({
|
|||
<div className="Lightbox__header--content">
|
||||
<div className="Lightbox__header--name">{conversation.title}</div>
|
||||
<div className="Lightbox__header--timestamp">
|
||||
{formatDateTimeForAttachment(i18n, message.sent_at ?? now)}
|
||||
{formatDateTimeForAttachment(i18n, message.sentAt ?? now)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -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'),
|
||||
};
|
||||
|
||||
|
|
|
@ -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 (
|
||||
<ContextMenu id={triggerId}>
|
||||
<MenuItem onClick={onShowMembers}>{i18n('icu:showMembers')}</MenuItem>
|
||||
<MenuItem onClick={onViewRecentMedia}>
|
||||
{i18n('icu:viewRecentMedia')}
|
||||
<MenuItem onClick={onViewAllMedia}>
|
||||
{i18n('icu:allMediaMenuItem')}
|
||||
</MenuItem>
|
||||
<MenuItem divider />
|
||||
{conversation.isArchived ? (
|
||||
|
@ -750,8 +750,8 @@ function HeaderMenu({
|
|||
: i18n('icu:showConversationDetails--direct')}
|
||||
</MenuItem>
|
||||
) : null}
|
||||
<MenuItem onClick={onViewRecentMedia}>
|
||||
{i18n('icu:viewRecentMedia')}
|
||||
<MenuItem onClick={onViewAllMedia}>
|
||||
{i18n('icu:allMediaMenuItem')}
|
||||
</MenuItem>
|
||||
<MenuItem divider />
|
||||
<MenuItem onClick={onSelectModeEnter}>
|
||||
|
|
|
@ -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')();
|
||||
},
|
||||
|
|
|
@ -144,7 +144,7 @@ type ActionProps = {
|
|||
onFailure?: () => unknown;
|
||||
}
|
||||
) => unknown;
|
||||
} & Pick<ConversationDetailsMediaListPropsType, 'showLightboxWithMedia'>;
|
||||
} & Pick<ConversationDetailsMediaListPropsType, 'showLightbox'>;
|
||||
|
||||
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 && (
|
||||
|
|
|
@ -28,7 +28,7 @@ const createProps = (mediaItems?: Array<MediaItemType>): Props => ({
|
|||
i18n,
|
||||
loadRecentMediaItems: action('loadRecentMediaItems'),
|
||||
showAllMedia: action('showAllMedia'),
|
||||
showLightboxWithMedia: action('showLightboxWithMedia'),
|
||||
showLightbox: action('showLightbox'),
|
||||
});
|
||||
|
||||
export function Basic(): JSX.Element {
|
||||
|
|
|
@ -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<ReadonlyDeep<MediaItemType>>
|
||||
) => 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,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -22,13 +22,20 @@ export default {
|
|||
} satisfies Meta<Props>;
|
||||
|
||||
const createProps = (overrideProps: Partial<Props> = {}): 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 {
|
||||
|
|
|
@ -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<MediaItemType>;
|
||||
documents: ReadonlyArray<MediaItemType>;
|
||||
i18n: LocalizerType;
|
||||
loadMediaItems: (id: string) => unknown;
|
||||
media: Array<MediaItemType>;
|
||||
haveOldestMedia: boolean;
|
||||
haveOldestDocument: boolean;
|
||||
loading: boolean;
|
||||
initialLoad: (id: string) => unknown;
|
||||
loadMoreMedia: (id: string) => unknown;
|
||||
loadMoreDocuments: (id: string) => unknown;
|
||||
media: ReadonlyArray<MediaItemType>;
|
||||
saveAttachment: SaveAttachmentActionCreatorType;
|
||||
showLightboxWithMedia: (
|
||||
selectedIndex: number,
|
||||
media: Array<MediaItemType>
|
||||
) => 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 <div />;
|
||||
}
|
||||
|
||||
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<HTMLDivElement | null>(null);
|
||||
const scrollObserverRef = useRef<HTMLDivElement | null>(null);
|
||||
const intersectionObserver = useRef<IntersectionObserver | null>(null);
|
||||
const loadingRef = useRef<boolean>(false);
|
||||
const tabViewRef = useRef<TabViews>(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<IntersectionObserverEntry>) => {
|
||||
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 (
|
||||
<div className="module-media-gallery" tabIndex={-1} ref={focusRef}>
|
||||
|
@ -155,31 +235,44 @@ export function MediaGallery({
|
|||
},
|
||||
]}
|
||||
>
|
||||
{({ selectedTab }) => (
|
||||
<div className="module-media-gallery__content">
|
||||
{selectedTab === TabViews.Media && (
|
||||
<MediaSection
|
||||
documents={documents}
|
||||
i18n={i18n}
|
||||
media={media}
|
||||
saveAttachment={saveAttachment}
|
||||
showLightboxWithMedia={showLightboxWithMedia}
|
||||
type="media"
|
||||
/>
|
||||
)}
|
||||
{selectedTab === TabViews.Documents && (
|
||||
<MediaSection
|
||||
documents={documents}
|
||||
i18n={i18n}
|
||||
media={media}
|
||||
saveAttachment={saveAttachment}
|
||||
showLightboxWithMedia={showLightboxWithMedia}
|
||||
type="documents"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{({ selectedTab }) => {
|
||||
tabViewRef.current =
|
||||
selectedTab === TabViews.Media
|
||||
? TabViews.Media
|
||||
: TabViews.Documents;
|
||||
|
||||
return (
|
||||
<div className="module-media-gallery__content">
|
||||
{selectedTab === TabViews.Media && (
|
||||
<MediaSection
|
||||
documents={documents}
|
||||
i18n={i18n}
|
||||
loading={loading}
|
||||
media={media}
|
||||
saveAttachment={saveAttachment}
|
||||
showLightbox={showLightbox}
|
||||
type="media"
|
||||
/>
|
||||
)}
|
||||
{selectedTab === TabViews.Documents && (
|
||||
<MediaSection
|
||||
documents={documents}
|
||||
i18n={i18n}
|
||||
loading={loading}
|
||||
media={media}
|
||||
saveAttachment={saveAttachment}
|
||||
showLightbox={showLightbox}
|
||||
type="documents"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Tabs>
|
||||
<div
|
||||
ref={scrollObserverRef}
|
||||
className="module-media-gallery__scroll-observer"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -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<MediaItemType>
|
||||
): Array<Section> => {
|
||||
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,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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<ReadonlyMessageAttributesType, 'sent_at'>;
|
||||
message: { id: string; sentAt: number };
|
||||
attachment: AttachmentType;
|
||||
index: number;
|
||||
type: 'media' | 'documents';
|
||||
};
|
||||
|
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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<MessageType>;
|
||||
getMessagesWithVisualMediaAttachments: (
|
||||
conversationId: string,
|
||||
options: { limit: number }
|
||||
) => Array<MessageType>;
|
||||
getMessagesWithFileAttachments: (
|
||||
conversationId: string,
|
||||
options: { limit: number }
|
||||
) => Array<MessageType>;
|
||||
getMessageServerGuidsForSpam: (conversationId: string) => Array<string>;
|
||||
|
||||
getJobsInQueue(queueType: string): Array<StoredJob>;
|
||||
|
|
|
@ -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<MessageTypeUnhydrated> {
|
||||
|
@ -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<MessageType> {
|
||||
const rows: JSONRows = db
|
||||
.prepare<Query>(
|
||||
`
|
||||
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<MessageType> {
|
||||
const rows = db
|
||||
.prepare<Query>(
|
||||
`
|
||||
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
|
||||
|
|
|
@ -3733,8 +3733,12 @@ function loadRecentMediaItems(
|
|||
): ThunkAction<void, RootStateType, unknown, SetRecentMediaItemsActionType> {
|
||||
return async dispatch => {
|
||||
const messages: Array<MessageAttributesType> =
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -150,22 +150,6 @@ function setPlaybackDisabled(
|
|||
};
|
||||
}
|
||||
|
||||
function showLightboxWithMedia(
|
||||
selectedIndex: number | undefined,
|
||||
media: ReadonlyArray<ReadonlyDeep<MediaItemType>>
|
||||
): ShowLightboxActionType {
|
||||
return {
|
||||
type: SHOW_LIGHTBOX,
|
||||
payload: {
|
||||
isViewOnce: false,
|
||||
media,
|
||||
selectedIndex,
|
||||
hasPrevMessage: false,
|
||||
hasNextMessage: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function showLightboxForViewOnceMedia(
|
||||
messageId: string
|
||||
): ThunkAction<void, RootStateType, unknown, ShowLightboxActionType> {
|
||||
|
@ -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,
|
||||
|
|
|
@ -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<AttachmentType>;
|
||||
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<AttachmentType>;
|
||||
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<MediaItemType>;
|
||||
media: Array<MediaType>;
|
||||
};
|
||||
export type MediaGalleryStateType = ReadonlyDeep<{
|
||||
conversationId: string | undefined;
|
||||
documents: ReadonlyArray<MediaItemType>;
|
||||
haveOldestDocument: boolean;
|
||||
haveOldestMedia: boolean;
|
||||
loading: boolean;
|
||||
media: ReadonlyArray<MediaType>;
|
||||
}>;
|
||||
|
||||
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<MediaItemType>;
|
||||
media: Array<MediaType>;
|
||||
conversationId: string;
|
||||
documents: ReadonlyArray<MediaItemType>;
|
||||
media: ReadonlyArray<MediaType>;
|
||||
};
|
||||
};
|
||||
}>;
|
||||
type LoadMoreMediaActionType = ReadonlyDeep<{
|
||||
type: typeof LOAD_MORE_MEDIA;
|
||||
payload: {
|
||||
conversationId: string;
|
||||
media: ReadonlyArray<MediaType>;
|
||||
};
|
||||
}>;
|
||||
type LoadMoreDocumentsActionType = ReadonlyDeep<{
|
||||
type: typeof LOAD_MORE_DOCUMENTS;
|
||||
payload: {
|
||||
conversationId: string;
|
||||
documents: ReadonlyArray<MediaItemType>;
|
||||
};
|
||||
}>;
|
||||
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<void, RootStateType, unknown, LoadMediaItemslActionType> {
|
||||
return async dispatch => {
|
||||
const { upgradeMessageSchema } = window.Signal.Migrations;
|
||||
function _getMediaItemMessage(
|
||||
message: ReadonlyDeep<MessageAttributesType>
|
||||
): 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<MessageAttributesType>>
|
||||
): ReadonlyArray<MediaType> {
|
||||
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<MessageAttributesType>>
|
||||
): ReadonlyArray<MediaItemType> {
|
||||
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<MessageAttributesType>
|
||||
): Promise<ReadonlyArray<MessageAttributesType>> {
|
||||
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<MediaType> = 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<MediaItemType> = 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<MediaGalleryStateType> = getEmptyState(),
|
||||
action: Readonly<MediaGalleryActionType>
|
||||
): 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,
|
||||
|
|
|
@ -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 (
|
||||
<MediaGallery
|
||||
conversationId={conversationId}
|
||||
haveOldestDocument={haveOldestDocument}
|
||||
haveOldestMedia={haveOldestMedia}
|
||||
i18n={window.i18n}
|
||||
loadMediaItems={loadMediaItems}
|
||||
initialLoad={initialLoad}
|
||||
loading={loading}
|
||||
loadMoreMedia={loadMoreMedia}
|
||||
loadMoreDocuments={loadMoreDocuments}
|
||||
media={media}
|
||||
documents={documents}
|
||||
showLightboxWithMedia={showLightboxWithMedia}
|
||||
showLightbox={showLightbox}
|
||||
saveAttachment={saveAttachment}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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<MediaItemType> = 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');
|
||||
});
|
||||
});
|
|
@ -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<MediaItemType> = 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<Section> = [
|
||||
{
|
||||
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);
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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<boolean>(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<IntersectionObserver | null>(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<HTMLDivElement | null>(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>(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",
|
||||
|
|
Loading…
Reference in a new issue