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:
Scott Nonnenberg 2024-09-05 07:15:30 +10:00 committed by GitHub
parent 6f83043eb4
commit 0d5a480c1b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 844 additions and 887 deletions

View file

@ -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",

View file

@ -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;

View file

@ -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,
},

View file

@ -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>

View file

@ -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'),
};

View file

@ -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}>

View file

@ -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')();
},

View file

@ -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 && (

View file

@ -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 {

View file

@ -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>

View file

@ -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:

View file

@ -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 {

View file

@ -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>
);
}

View file

@ -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(),
},
});

View file

@ -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,
};
};
};

View file

@ -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';
};

View file

@ -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
);
}

View file

@ -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>;

View file

@ -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

View file

@ -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,
},
};

View file

@ -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,

View file

@ -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 dont 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,

View file

@ -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}
/>
);

View file

@ -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}

View file

@ -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}

View file

@ -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);
});
});
});

View file

@ -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');
});
});

View file

@ -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);
});
});

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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",