Add option to sort by file size in Media Gallery

This commit is contained in:
Fedor Indutny
2026-01-08 20:59:44 +01:00
committed by GitHub
parent c36c329645
commit 4431d0cc7b
17 changed files with 531 additions and 225 deletions
+16
View File
@@ -6872,6 +6872,22 @@
"messageformat": "Links",
"description": "Header of the links pane in the media gallery, showing links"
},
"icu:MediaGallery__sort": {
"messageformat": "Sort by",
"description": "Accessible label of the drop down trigger for sorting media gallery"
},
"icu:MediaGallery__sort--header": {
"messageformat": "Sort by",
"description": "Header displayed on top of the drop down menu for sorting media gallery"
},
"icu:MediaGallery__sort__date": {
"messageformat": "Date",
"description": "Sort by date option of drop down menu in media gallery"
},
"icu:MediaGallery__sort__size": {
"messageformat": "Size",
"description": "Sort by file size option of drop down menu in media gallery"
},
"icu:MediaGallery__EmptyState__title--media": {
"messageformat": "No Media",
"description": "Title of the empty state view of media gallery for media tab"
@@ -88,9 +88,11 @@ export function AttachmentSection({
case 'media':
return (
<section className={tw('@container px-5')}>
<h2 className={tw('ps-1 pt-4 pb-2 type-body-medium font-semibold')}>
{header}
</h2>
{header != null && (
<h2 className={tw('ps-1 pt-4 pb-2 type-body-medium font-semibold')}>
{header}
</h2>
)}
<div
className={tw(
'grid gap-1',
@@ -118,9 +120,13 @@ export function AttachmentSection({
case 'link':
return (
<section>
<h2 className={tw('px-6 pt-1.5 pb-2 type-body-medium font-semibold')}>
{header}
</h2>
{header != null && (
<h2
className={tw('px-6 pt-1.5 pb-2 type-body-medium font-semibold')}
>
{header}
</h2>
)}
<div>
{verified.entries.map(mediaItem => {
return (
@@ -4,7 +4,10 @@
import React, { useState, useCallback } from 'react';
import { action } from '@storybook/addon-actions';
import type { Meta } from '@storybook/react';
import type { MediaTabType } from '../../../types/MediaItem.std.js';
import type {
MediaTabType,
MediaSortOrderType,
} from '../../../types/MediaItem.std.js';
import type { Props } from './MediaGallery.dom.js';
import { MediaGallery } from './MediaGallery.dom.js';
import { PanelHeader } from './PanelHeader.dom.js';
@@ -39,6 +42,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
links: overrideProps.links || [],
documents: overrideProps.documents || [],
tab: overrideProps.tab || 'media',
sortOrder: overrideProps.sortOrder || 'date',
initialLoad: action('initialLoad'),
loadMore: action('loadMore'),
@@ -54,6 +58,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
function Panel(props: Props): React.JSX.Element {
const [tab, setTab] = useState<MediaTabType>(props.tab);
const [sortOrder, setSortOrder] = useState<MediaSortOrderType>('date');
const [loading, setLoading] = useState(false);
@@ -71,14 +76,30 @@ function Panel(props: Props): React.JSX.Element {
);
return (
<div>
<PanelHeader i18n={i18n} tab={tab} setTab={setTab} />
<MediaGallery
{...props}
tab={tab}
loading={loading}
loadMore={loadMore}
/>
<div className="ConversationPanel">
<div className="ConversationPanel__header">
<button
aria-label={i18n('icu:goBack')}
className="ConversationPanel__header__back-button"
type="button"
/>
<PanelHeader
i18n={i18n}
tab={tab}
setTab={setTab}
sortOrder={sortOrder}
setSortOrder={setSortOrder}
/>
</div>
<div className="ConversationPanel__body">
<MediaGallery
{...props}
tab={tab}
loading={loading}
loadMore={loadMore}
sortOrder={sortOrder}
/>
</div>
</div>
);
}
@@ -7,6 +7,7 @@ import React, {
useRef,
useCallback,
useState,
useMemo,
} from 'react';
import moment from 'moment';
@@ -15,6 +16,7 @@ import type { ItemClickEvent } from './types/ItemClickEvent.std.js';
import type { LocalizerType } from '../../../types/Util.std.js';
import type {
MediaTabType,
MediaSortOrderType,
LinkPreviewMediaItemType,
ContactMediaItemType,
MediaItemType,
@@ -49,6 +51,7 @@ export type Props = {
links: ReadonlyArray<LinkPreviewMediaItemType>;
documents: ReadonlyArray<MediaItemType | ContactMediaItemType>;
tab: MediaTabType;
sortOrder: MediaSortOrderType;
saveAttachment: SaveAttachmentActionCreatorType;
pushPanelForConversation: PushPanelForConversationActionType;
kickOffAttachmentDownload: (options: { messageId: string }) => void;
@@ -71,6 +74,7 @@ function MediaSection({
i18n,
loading,
tab,
sortOrder,
mediaItems,
saveAttachment,
pushPanelForConversation,
@@ -92,6 +96,7 @@ function MediaSection({
| 'renderMediaItem'
> & {
tab: MediaTabType;
sortOrder: MediaSortOrderType;
mediaItems: ReadonlyArray<GenericMediaItemType>;
}): React.JSX.Element {
const onItemClick = useCallback(
@@ -142,6 +147,10 @@ function MediaSection({
]
);
const reversedMediaItems = useMemo(() => {
return mediaItems.toReversed();
}, [mediaItems]);
if (mediaItems.length === 0) {
if (loading) {
return <div />;
@@ -155,6 +164,20 @@ function MediaSection({
const isGrid = mediaItems.at(0)?.type === 'media';
if (sortOrder === 'size') {
return (
<div className={tw('grow', 'mx-auto', 'max-w-[660px] min-w-[360px]')}>
<div className={tw('flex flex-col')}>
<AttachmentSection
mediaItems={reversedMediaItems}
onItemClick={onItemClick}
renderMediaItem={renderMediaItem}
/>
</div>
</div>
);
}
const sections = groupedItems.map((section, index) => {
const isLast = index === groupedItems.length - 1;
const first = section.mediaItems[0];
@@ -219,6 +242,7 @@ export function MediaGallery({
links,
documents,
tab,
sortOrder,
saveAttachment,
pushPanelForConversation,
kickOffAttachmentDownload,
@@ -266,6 +290,7 @@ export function MediaGallery({
audio.length,
links.length,
documents.length,
sortOrder,
]);
const [setObserverRef, observerEntry] = useIntersectionObserver();
@@ -339,6 +364,7 @@ export function MediaGallery({
i18n={i18n}
loading={loading}
tab={tab}
sortOrder={sortOrder}
mediaItems={mediaItems}
saveAttachment={saveAttachment}
pushPanelForConversation={pushPanelForConversation}
@@ -32,6 +32,7 @@ const createProps = (
i18n,
theme,
mediaItem: overrideProps.mediaItem,
showSize: false,
onClick: action('onClick'),
};
};
@@ -28,6 +28,7 @@ import {
export type Props = Readonly<{
mediaItem: ReadonlyDeep<MediaItemType>;
showSize: boolean;
onClick?: (attachmentState: AttachmentStatusType['state']) => void;
i18n: LocalizerType;
theme?: ThemeType;
@@ -36,6 +37,7 @@ export type Props = Readonly<{
export function MediaGridItem(props: Props): React.JSX.Element {
const {
mediaItem: { attachment },
showSize,
i18n,
theme,
onClick,
@@ -93,7 +95,12 @@ export function MediaGridItem(props: Props): React.JSX.Element {
>
{imageOrBlurHash}
<MetadataOverlay i18n={i18n} status={status} attachment={attachment} />
<MetadataOverlay
i18n={i18n}
status={status}
attachment={attachment}
showSize={showSize}
/>
<SpinnerOverlay status={status} />
</button>
);
@@ -145,25 +152,31 @@ type MetadataOverlayProps = Readonly<{
i18n: LocalizerType;
status: AttachmentStatusType;
attachment: AttachmentForUIType;
showSize: boolean;
}>;
function MetadataOverlay(
props: MetadataOverlayProps
): React.JSX.Element | undefined {
const { i18n, status, attachment } = props;
const { i18n, status, attachment, showSize } = props;
if (
status.state === 'ReadyToShow' &&
!isGIF([attachment]) &&
!isVideoAttachment(attachment)
!isVideoAttachment(attachment) &&
!showSize
) {
return undefined;
}
let text: string;
if (isGIF([attachment]) && status.state === 'ReadyToShow') {
if (!showSize && isGIF([attachment]) && status.state === 'ReadyToShow') {
text = i18n('icu:message--getNotificationText--gif');
} else if (isVideoAttachment(attachment) && attachment.duration != null) {
} else if (
!showSize &&
isVideoAttachment(attachment) &&
attachment.duration != null
) {
text = formatDuration(attachment.duration);
} else {
text = formatFileSize(attachment.size);
@@ -6,17 +6,30 @@ import React, { useCallback } from 'react';
import { tw } from '../../../axo/tw.dom.js';
import { ExperimentalAxoSegmentedControl } from '../../../axo/AxoSegmentedControl.dom.js';
import { AxoSelect } from '../../../axo/AxoSelect.dom.js';
import { AxoDropdownMenu } from '../../../axo/AxoDropdownMenu.dom.js';
import { AxoIconButton } from '../../../axo/AxoIconButton.dom.js';
import type { LocalizerType } from '../../../types/Util.std.js';
import type { MediaTabType } from '../../../types/MediaItem.std.js';
import type {
MediaTabType,
MediaSortOrderType,
} from '../../../types/MediaItem.std.js';
// Provided by smart layer
export type Props = Readonly<{
i18n: LocalizerType;
tab: MediaTabType;
setTab: (newTab: MediaTabType) => void;
sortOrder: MediaSortOrderType;
setSortOrder: (newOrder: MediaSortOrderType) => void;
}>;
export function PanelHeader({ i18n, tab, setTab }: Props): React.JSX.Element {
export function PanelHeader({
i18n,
tab,
setTab,
sortOrder,
setSortOrder,
}: Props): React.JSX.Element {
const setSelectedTabWithDefault = useCallback(
(value: string | null) => {
switch (value) {
@@ -36,16 +49,14 @@ export function PanelHeader({ i18n, tab, setTab }: Props): React.JSX.Element {
[setTab]
);
const isNonDefaultSorting = sortOrder !== 'date';
return (
<div
className={tw(
'@container',
'grow',
'flex flex-row justify-center-safe',
// This matches the width of back button so that tabs are centered
'pe-[50px]'
)}
className={tw('@container', 'grow', 'flex flex-row justify-center-safe')}
>
<div className={tw('grow')} />
<div className={tw('hidden max-w-[320px] grow @min-[260px]:block')}>
<ExperimentalAxoSegmentedControl.Root
variant="no-track"
@@ -107,6 +118,38 @@ export function PanelHeader({ i18n, tab, setTab }: Props): React.JSX.Element {
</AxoSelect.Content>
</AxoSelect.Root>
</div>
<div className={tw('grow')} />
<AxoDropdownMenu.Root>
<AxoDropdownMenu.Trigger>
<AxoIconButton.Root
variant={isNonDefaultSorting ? 'primary' : 'borderless-secondary'}
size="md"
symbol="sort-vertical"
aria-label={i18n('icu:MediaGallery__sort')}
/>
</AxoDropdownMenu.Trigger>
<AxoDropdownMenu.Content>
<AxoDropdownMenu.Label>
{i18n('icu:MediaGallery__sort--header')}
</AxoDropdownMenu.Label>
<AxoDropdownMenu.CheckboxItem
checked={sortOrder === 'date'}
onCheckedChange={() => setSortOrder('date')}
>
{i18n('icu:MediaGallery__sort__date')}
</AxoDropdownMenu.CheckboxItem>
<AxoDropdownMenu.CheckboxItem
checked={sortOrder === 'size'}
onCheckedChange={() => setSortOrder('size')}
>
{i18n('icu:MediaGallery__sort__size')}
</AxoDropdownMenu.CheckboxItem>
</AxoDropdownMenu.Content>
</AxoDropdownMenu.Root>
<div className={tw('min-w-4.5')} />
</div>
);
}
@@ -44,7 +44,12 @@ export function MediaItem({
);
case 'media':
return (
<MediaGridItem mediaItem={mediaItem} onClick={onClick} i18n={i18n} />
<MediaGridItem
mediaItem={mediaItem}
onClick={onClick}
i18n={i18n}
showSize={false}
/>
);
case 'document':
return (
+12 -7
View File
@@ -610,24 +610,29 @@ export type GetSortedMediaOptionsType = Readonly<{
messageId?: string;
receivedAt?: number;
sentAt?: number;
order: 'older' | 'newer';
size?: number;
order: 'older' | 'newer' | 'bigger';
type: 'media' | 'audio' | 'documents';
}>;
export type GetOlderDocumentsOptionsType = Readonly<{
export type GetSortedDocumentsOptionsType = Readonly<{
conversationId: string;
limit: number;
messageId?: string;
receivedAt?: number;
sentAt?: number;
size?: number;
order: 'older' | 'bigger';
}>;
export type GetOlderNonAttachmentMediaOptionsType = Readonly<{
export type GetSortedNonAttachmentMediaOptionsType = Readonly<{
conversationId: string;
limit: number;
messageId?: string;
receivedAt?: number;
sentAt?: number;
size?: number;
order: 'older' | 'bigger';
type: 'links' | 'contacts';
}>;
@@ -886,11 +891,11 @@ type ReadableInterface = {
getSortedMedia: (
options: GetSortedMediaOptionsType
) => Array<MediaItemDBType>;
getOlderNonAttachmentMedia: (
options: GetOlderNonAttachmentMediaOptionsType
getSortedNonAttachmentMedia: (
options: GetSortedNonAttachmentMediaOptionsType
) => Array<NonAttachmentMediaItemDBType>;
getOlderDocuments: (
options: GetOlderDocumentsOptionsType
getSortedDocuments: (
options: GetSortedDocumentsOptionsType
) => Array<MediaItemDBType | ContactMediaItemDBType>;
getAllStories: (options: {
conversationId?: string;
+82 -29
View File
@@ -49,7 +49,7 @@ import { isNormalNumber } from '../util/isNormalNumber.std.js';
import { isNotNil } from '../util/isNotNil.std.js';
import { parseIntOrThrow } from '../util/parseIntOrThrow.std.js';
import { updateSchema } from './migrations/index.node.js';
import type { JSONRows, QueryFragment } from './util.std.js';
import type { JSONRows, QueryFragment, QueryTemplate } from './util.std.js';
import {
batchMultiVarQuery,
bulkAdd,
@@ -136,8 +136,8 @@ import type {
GetKnownMessageAttachmentsResultType,
GetNearbyMessageFromDeletedSetOptionsType,
GetSortedMediaOptionsType,
GetOlderNonAttachmentMediaOptionsType,
GetOlderDocumentsOptionsType,
GetSortedNonAttachmentMediaOptionsType,
GetSortedDocumentsOptionsType,
GetRecentStoryRepliesOptionsType,
GetUnreadByConversationAndMarkReadResultType,
IdentityKeyIdType,
@@ -479,8 +479,8 @@ export const DataReader: ServerReadableInterface = {
hasMedia,
getSortedMedia,
getOlderNonAttachmentMedia,
getOlderDocuments,
getSortedNonAttachmentMedia,
getSortedDocuments,
getAllNotificationProfiles,
getNotificationProfileById,
@@ -5332,19 +5332,23 @@ function getSortedMedia(
messageId,
receivedAt: givenReceivedAt,
sentAt: givenSentAt,
size: givenSize,
type,
}: GetSortedMediaOptionsType
): Array<MediaItemDBType> {
let timeFilters: {
let index: QueryFragment;
let sortFilters: {
first: QueryFragment;
second: QueryFragment;
third?: QueryFragment;
};
let timeOrder: QueryFragment;
let orderFragment: QueryFragment;
if (order === 'older') {
const maxReceivedAt = givenReceivedAt ?? Number.MAX_VALUE;
const maxSentAt = givenSentAt ?? Number.MAX_VALUE;
timeFilters = {
index = sqlFragment`message_attachments_getOlderMedia`;
sortFilters = {
first: sqlFragment`
message_attachments.receivedAt = ${maxReceivedAt}
AND
@@ -5352,12 +5356,16 @@ function getSortedMedia(
`,
second: sqlFragment`message_attachments.receivedAt < ${maxReceivedAt}`,
};
timeOrder = sqlFragment`DESC`;
orderFragment = sqlFragment`
message_attachments.receivedAt DESC,
message_attachments.sentAt DESC
`;
} else if (order === 'newer') {
const minReceivedAt = givenReceivedAt ?? Number.MIN_VALUE;
const minSentAt = givenSentAt ?? Number.MIN_VALUE;
timeFilters = {
index = sqlFragment`message_attachments_getOlderMedia`;
sortFilters = {
first: sqlFragment`
message_attachments.receivedAt = ${minReceivedAt}
AND
@@ -5365,7 +5373,38 @@ function getSortedMedia(
`,
second: sqlFragment`message_attachments.receivedAt > ${minReceivedAt}`,
};
timeOrder = sqlFragment`ASC`;
orderFragment = sqlFragment`
message_attachments.receivedAt ASC,
message_attachments.sentAt ASC
`;
} else if (order === 'bigger') {
const maxSize = givenSize ?? Number.MAX_VALUE;
const maxReceivedAt = givenReceivedAt ?? Number.MAX_VALUE;
const maxSentAt = givenSentAt ?? Number.MAX_VALUE;
index = sqlFragment`message_attachments_sortBiggerMedia`;
sortFilters = {
first: sqlFragment`
message_attachments.size = ${maxSize}
AND
message_attachments.receivedAt = ${maxReceivedAt}
AND
message_attachments.sentAt < ${maxSentAt}
`,
second: sqlFragment`
message_attachments.size = ${maxSize}
AND
message_attachments.receivedAt < ${maxReceivedAt}
`,
third: sqlFragment`
message_attachments.size < ${maxSize}
`,
};
orderFragment = sqlFragment`
message_attachments.size DESC,
message_attachments.receivedAt DESC,
message_attachments.sentAt DESC
`;
} else {
throw missingCaseError(order);
}
@@ -5401,7 +5440,7 @@ function getSortedMedia(
throw missingCaseError(type);
}
const createQuery = (timeFilter: QueryFragment): QueryFragment => sqlFragment`
const createQuery = (sortFilter: QueryFragment): QueryFragment => sqlFragment`
SELECT
message_attachments.*,
messages.json -> '$.sendStateByConversationId' AS messageSendState,
@@ -5411,7 +5450,7 @@ function getSortedMedia(
messages.source AS messageSource,
messages.sourceServiceId AS messageSourceServiceId
FROM message_attachments
INDEXED BY message_attachments_getOlderMedia
INDEXED BY ${index}
INNER JOIN messages ON
messages.id = message_attachments.messageId
WHERE
@@ -5419,23 +5458,37 @@ function getSortedMedia(
message_attachments.editHistoryIndex IS -1 AND
message_attachments.attachmentType IS 'attachment' AND
(
${timeFilter}
${sortFilter}
) AND
(${contentFilter}) AND
message_attachments.isViewOnce IS NOT 1 AND
message_attachments.messageType IN ('incoming', 'outgoing') AND
(${messageId ?? null} IS NULL OR message_attachments.messageId IS NOT ${messageId ?? null})
ORDER BY
message_attachments.receivedAt ${timeOrder},
message_attachments.sentAt ${timeOrder}
ORDER BY ${orderFragment}
LIMIT ${limit}
`;
const [query, params] = sql`
SELECT first.* FROM (${createQuery(timeFilters.first)}) as first
UNION ALL
SELECT second.* FROM (${createQuery(timeFilters.second)}) as second
`;
let template: QueryTemplate;
if (order === 'older' || order === 'newer') {
template = sql`
SELECT first.* FROM (${createQuery(sortFilters.first)}) as first
UNION ALL
SELECT second.* FROM (${createQuery(sortFilters.second)}) as second
`;
} else if (order === 'bigger') {
strictAssert(sortFilters.third != null, 'file size filter is required');
template = sql`
SELECT first.* FROM (${createQuery(sortFilters.first)}) as first
UNION ALL
SELECT second.* FROM (${createQuery(sortFilters.second)}) as second
UNION ALL
SELECT third.* FROM (${createQuery(sortFilters.third)}) as third
`;
} else {
throw missingCaseError(order);
}
const [query, params] = template;
const results: Array<
MessageAttachmentDBType & {
@@ -5486,7 +5539,7 @@ function getSortedMedia(
});
}
function getOlderNonAttachmentMedia(
function getSortedNonAttachmentMedia(
db: ReadableDB,
{
conversationId,
@@ -5495,7 +5548,7 @@ function getOlderNonAttachmentMedia(
receivedAt: maxReceivedAt = Number.MAX_VALUE,
sentAt: maxSentAt = Number.MAX_VALUE,
type,
}: GetOlderNonAttachmentMediaOptionsType
}: GetSortedNonAttachmentMediaOptionsType
): Array<NonAttachmentMediaItemDBType> {
const timeFilters = {
first: sqlFragment`received_at = ${maxReceivedAt} AND sent_at < ${maxSentAt}`,
@@ -5557,7 +5610,7 @@ function getOlderNonAttachmentMedia(
if (type === 'links') {
strictAssert(
row.preview != null && row.preview.length >= 1,
`getOlderNonAttachmentMedia: got message without preview ${row.id}`
`getSortedNonAttachmentMedia: got message without preview ${row.id}`
);
return {
@@ -5570,7 +5623,7 @@ function getOlderNonAttachmentMedia(
if (type === 'contacts') {
strictAssert(
row.contact != null && row.contact.length >= 1,
`getOlderNonAttachmentMedia: got message without contact ${row.id}`
`getSortedNonAttachmentMedia: got message without contact ${row.id}`
);
return {
@@ -5584,9 +5637,9 @@ function getOlderNonAttachmentMedia(
});
}
function getOlderDocuments(
function getSortedDocuments(
db: ReadableDB,
options: GetOlderDocumentsOptionsType
options: GetSortedDocumentsOptionsType
): Array<MediaItemDBType | ContactMediaItemDBType> {
return db.transaction(() => {
const documents = getSortedMedia(db, {
@@ -5594,7 +5647,7 @@ function getOlderDocuments(
order: 'older',
type: 'documents',
});
const contacts = getOlderNonAttachmentMedia(db, {
const contacts = getSortedNonAttachmentMedia(db, {
...options,
type: 'contacts',
}) as Array<ContactMediaItemDBType>;
@@ -0,0 +1,15 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { WritableDB } from '../Interface.std.js';
export default function updateToSchemaVersion1620(db: WritableDB): void {
db.exec(`
CREATE INDEX message_attachments_sortBiggerMedia ON message_attachments
(conversationId, attachmentType, size DESC, receivedAt DESC, sentAt DESC)
WHERE
editHistoryIndex IS -1 AND
messageType IN ('incoming', 'outgoing') AND
isViewOnce IS NOT 1;
`);
}
+2
View File
@@ -138,6 +138,7 @@ import updateToSchemaVersion1580 from './1580-expired-group-replies.std.js';
import updateToSchemaVersion1590 from './1590-megaphones.std.js';
import updateToSchemaVersion1600 from './1600-deduplicate-usernames.std.js';
import updateToSchemaVersion1610 from './1610-has-contacts.std.js';
import updateToSchemaVersion1620 from './1620-sort-bigger-media.std.js';
import { DataWriter } from '../Server.node.js';
@@ -1636,6 +1637,7 @@ export const SCHEMA_VERSIONS: ReadonlyArray<SchemaUpdateType> = [
{ version: 1600, update: updateToSchemaVersion1600 },
{ version: 1610, update: updateToSchemaVersion1610 },
{ version: 1620, update: updateToSchemaVersion1620 },
];
export class DBVersionFromFutureError extends Error {
+239 -156
View File
@@ -30,11 +30,14 @@ import type {
} from './conversations.preload.js';
import type {
MediaTabType,
MediaSortOrderType,
MediaItemMessageType,
MediaItemType,
LinkPreviewMediaItemType,
ContactMediaItemType,
GenericMediaItemType,
} from '../../types/MediaItem.std.js';
import type { AttachmentForUIType } from '../../types/Attachment.std.js';
import {
isFile,
isVisualMedia,
@@ -52,6 +55,7 @@ const log = createLogger('mediaGallery');
export type MediaGalleryStateType = ReadonlyDeep<{
tab: MediaTabType;
sortOrder: MediaSortOrderType;
conversationId: string | undefined;
haveOldestMedia: boolean;
haveOldestAudio: boolean;
@@ -70,6 +74,7 @@ const INITIAL_LOAD = 'mediaGallery/INITIAL_LOAD';
const LOAD_MORE = 'mediaGallery/LOAD_MORE';
const SET_LOADING = 'mediaGallery/SET_LOADING';
const SET_TAB = 'mediaGallery/SET_TAB';
const SET_SORT_ORDER = 'mediaGallery/SET_SORT_ORDER';
type InitialLoadActionType = ReadonlyDeep<{
type: typeof INITIAL_LOAD;
@@ -103,6 +108,12 @@ type SetTabActionType = ReadonlyDeep<{
tab: MediaGalleryStateType['tab'];
};
}>;
type SetSortOrderActionType = ReadonlyDeep<{
type: typeof SET_SORT_ORDER;
payload: {
sortOrder: MediaGalleryStateType['sortOrder'];
};
}>;
type MediaGalleryActionType = ReadonlyDeep<
| ConversationUnloadedActionType
@@ -113,16 +124,107 @@ type MediaGalleryActionType = ReadonlyDeep<
| MessageExpiredActionType
| SetLoadingActionType
| SetTabActionType
| SetSortOrderActionType
>;
function getMediaItemSize(item: GenericMediaItemType): number {
switch (item.type) {
case 'media':
case 'audio':
case 'document':
return item.attachment.size;
case 'link':
case 'contact':
return 0;
default:
throw missingCaseError(item);
}
}
function _updateMedia<ItemType extends GenericMediaItemType>({
message,
haveOldest,
media,
newMedia,
sortOrder,
}: {
message: ReadonlyMessageAttributesType;
haveOldest: boolean;
media: ReadonlyArray<ItemType>;
newMedia: ReadonlyArray<ItemType>;
sortOrder: MediaSortOrderType;
}): [ReadonlyArray<ItemType>, boolean] {
const mediaWithout = media.filter(item => item.message.id !== message.id);
const difference = media.length - mediaWithout.length;
if (message.deletedForEveryone || message.isErased) {
// If message is erased and there was media from this message - update state
if (difference > 0) {
return [mediaWithout, haveOldest];
}
return [media, haveOldest];
}
const oldest = media[0];
let inMediaTimeRange: boolean;
if (oldest == null) {
inMediaTimeRange = true;
} else if (sortOrder === 'date') {
inMediaTimeRange =
message.received_at >= oldest.message.receivedAt &&
message.sent_at >= oldest.message.sentAt;
} else if (sortOrder === 'size') {
const messageLatest = _sortItems(newMedia, sortOrder).at(-1);
inMediaTimeRange =
messageLatest == null ||
(getMediaItemSize(messageLatest) >= getMediaItemSize(oldest) &&
message.received_at >= oldest.message.receivedAt &&
message.sent_at >= oldest.message.sentAt);
} else {
throw missingCaseError(sortOrder);
}
// If message is updated out of current range - it means that the oldest
// message in the view might no longer be the oldest in the database.
if (!inMediaTimeRange) {
return [media, false];
}
// If the message is in the view and attachments might have changed - update
if (difference > 0 || newMedia.length > 0) {
return [_sortItems(mediaWithout.concat(newMedia), sortOrder), haveOldest];
}
return [media, haveOldest];
}
function _sortItems<
Item extends ReadonlyDeep<{ message: MediaItemMessageType }>,
>(items: ReadonlyArray<Item>): ReadonlyArray<Item> {
return orderBy(items, [
'message.receivedAt',
'message.sentAt',
'message.index',
]);
Item extends ReadonlyDeep<{
attachment?: AttachmentForUIType;
message: MediaItemMessageType;
}>,
>(
items: ReadonlyArray<Item>,
sortOrder: MediaSortOrderType
): ReadonlyArray<Item> {
if (sortOrder === 'date') {
return orderBy(items, [
'message.receivedAt',
'message.sentAt',
'message.index',
]);
}
if (sortOrder === 'size') {
return orderBy(items, [
'attachment.size',
'message.receivedAt',
'message.sentAt',
'message.index',
]);
}
throw missingCaseError(sortOrder);
}
function _cleanMessage(
@@ -163,6 +265,28 @@ function _cleanAttachments(
return rawMedia.map(media => _cleanAttachment(type, media));
}
function _cleanContact(raw: ContactMediaItemDBType): ContactMediaItemType {
const { message, contact } = raw;
return {
type: 'contact',
contact: {
...contact,
avatar:
contact.avatar?.avatar == null
? undefined
: {
...contact.avatar,
avatar: getPropsForAttachment(
contact.avatar.avatar,
'contact',
message
),
},
},
message,
};
}
function _cleanDocuments(
rawDocuments: ReadonlyArray<MediaItemDBType | ContactMediaItemDBType>
): ReadonlyArray<MediaItemType | ContactMediaItemType> {
@@ -175,26 +299,7 @@ function _cleanDocuments(
rawDocument.type === 'contact',
`Unexpected documen type ${rawDocument.type}`
);
const { message, contact } = rawDocument;
return {
type: 'contact',
contact: {
...contact,
avatar:
contact.avatar?.avatar == null
? undefined
: {
...contact.avatar,
avatar: getPropsForAttachment(
contact.avatar.avatar,
'contact',
message
),
},
},
message,
};
return _cleanContact(rawDocument);
});
}
@@ -219,6 +324,17 @@ function _cleanLinkPreviews(
});
}
function sortOrderToOrder(sortOrder: MediaSortOrderType): 'older' | 'bigger' {
switch (sortOrder) {
case 'date':
return 'older';
case 'size':
return 'bigger';
default:
throw missingCaseError(sortOrder);
}
}
function initialLoad(
conversationId: string
): ThunkAction<
@@ -227,35 +343,42 @@ function initialLoad(
unknown,
InitialLoadActionType | SetLoadingActionType
> {
return async dispatch => {
return async (dispatch, getState) => {
dispatch({
type: SET_LOADING,
payload: { loading: true },
});
const {
mediaGallery: { sortOrder },
} = getState();
const order = sortOrderToOrder(sortOrder);
const [rawMedia, rawAudio, rawDocuments, rawLinkPreviews] =
await Promise.all([
DataReader.getSortedMedia({
conversationId,
limit: FETCH_CHUNK_COUNT,
type: 'media',
order: 'older',
order,
}),
DataReader.getSortedMedia({
conversationId,
limit: FETCH_CHUNK_COUNT,
type: 'audio',
order: 'older',
order,
}),
// Note: `getOlderDocuments` mixes in contacts
DataReader.getOlderDocuments({
// Note: `getSortedDocuments` mixes in contacts
DataReader.getSortedDocuments({
conversationId,
limit: FETCH_CHUNK_COUNT,
order,
}),
DataReader.getOlderNonAttachmentMedia({
DataReader.getSortedNonAttachmentMedia({
conversationId,
limit: FETCH_CHUNK_COUNT,
type: 'links',
order,
}),
]);
@@ -288,7 +411,7 @@ function loadMore(
> {
return async (dispatch, getState) => {
const { mediaGallery } = getState();
const { conversationId: previousConversationId } = mediaGallery;
const { conversationId: previousConversationId, sortOrder } = mediaGallery;
if (conversationId !== previousConversationId) {
log.warn('loadMore: conversationId mismatch; calling initialLoad()');
@@ -331,6 +454,8 @@ function loadMore(
messageId,
receivedAt,
sentAt,
size: getMediaItemSize(oldestLoadedItem),
order: sortOrderToOrder(sortOrder),
};
let media: ReadonlyArray<MediaItemType> = [];
@@ -338,9 +463,10 @@ function loadMore(
let documents: ReadonlyArray<MediaItemType | ContactMediaItemType> = [];
let links: ReadonlyArray<LinkPreviewMediaItemType> = [];
if (type === 'media' || type === 'audio') {
strictAssert(oldestLoadedItem.type === type, 'must be a media item');
const rawMedia = await DataReader.getSortedMedia({
...sharedOptions,
order: 'older',
type,
});
@@ -353,12 +479,12 @@ function loadMore(
throw missingCaseError(type);
}
} else if (type === 'documents') {
// Note: `getOlderDocuments` mixes in contacts
const rawDocuments = await DataReader.getOlderDocuments(sharedOptions);
// Note: `getSortedDocuments` mixes in contacts
const rawDocuments = await DataReader.getSortedDocuments(sharedOptions);
documents = _cleanDocuments(rawDocuments);
} else if (type === 'links') {
const rawPreviews = await DataReader.getOlderNonAttachmentMedia({
const rawPreviews = await DataReader.getSortedNonAttachmentMedia({
...sharedOptions,
type,
});
@@ -389,10 +515,22 @@ function setTab(tab: MediaGalleryStateType['tab']): SetTabActionType {
};
}
function setSortOrder(
sortOrder: MediaGalleryStateType['sortOrder']
): SetSortOrderActionType {
return {
type: SET_SORT_ORDER,
payload: {
sortOrder,
},
};
}
export const actions = {
initialLoad,
loadMore,
setTab,
setSortOrder,
};
export const useMediaGalleryActions = (): BoundActionCreatorsMapObject<
@@ -402,6 +540,7 @@ export const useMediaGalleryActions = (): BoundActionCreatorsMapObject<
export function getEmptyState(): MediaGalleryStateType {
return {
tab: 'media',
sortOrder: 'date',
conversationId: undefined,
haveOldestDocument: false,
haveOldestMedia: false,
@@ -433,16 +572,17 @@ export function reducer(
return {
tab: 'media',
sortOrder: state.sortOrder,
loading: false,
conversationId: payload.conversationId,
haveOldestMedia: payload.media.length === 0,
haveOldestAudio: payload.audio.length === 0,
haveOldestLink: payload.links.length === 0,
haveOldestDocument: payload.documents.length === 0,
media: _sortItems(payload.media),
audio: _sortItems(payload.audio),
links: _sortItems(payload.links),
documents: _sortItems(payload.documents),
media: _sortItems(payload.media, state.sortOrder),
audio: _sortItems(payload.audio, state.sortOrder),
links: _sortItems(payload.links, 'date'),
documents: _sortItems(payload.documents, state.sortOrder),
};
}
@@ -459,10 +599,10 @@ export function reducer(
haveOldestAudio: audio.length === 0,
haveOldestDocument: documents.length === 0,
haveOldestLink: links.length === 0,
media: _sortItems(media.concat(state.media)),
audio: _sortItems(audio.concat(state.audio)),
links: _sortItems(links.concat(state.links)),
documents: _sortItems(documents.concat(state.documents)),
media: _sortItems(media.concat(state.media), state.sortOrder),
audio: _sortItems(audio.concat(state.audio), state.sortOrder),
links: _sortItems(links.concat(state.links), 'date'),
documents: _sortItems(documents.concat(state.documents), state.sortOrder),
};
}
@@ -475,6 +615,17 @@ export function reducer(
};
}
if (action.type === SET_SORT_ORDER) {
const { sortOrder } = action.payload;
return {
...getEmptyState(),
loading: true,
tab: state.tab,
sortOrder,
};
}
// A time-ordered subset of all conversation media is loaded at once.
// When a message changes, check that the changed message falls within this time range,
// and if so insert it into the loaded media.
@@ -486,46 +637,6 @@ export function reducer(
return state;
}
const mediaWithout = state.media.filter(
item => item.message.id !== message.id
);
const audioWithout = state.audio.filter(
item => item.message.id !== message.id
);
const documentsWithout = state.documents.filter(
item => item.message.id !== message.id
);
const linksWithout = state.links.filter(
item => item.message.id !== message.id
);
const mediaDifference = state.media.length - mediaWithout.length;
const audioDifference = state.audio.length - audioWithout.length;
const documentDifference = state.documents.length - documentsWithout.length;
const linkDifference = state.links.length - linksWithout.length;
if (message.deletedForEveryone || message.isErased) {
if (
mediaDifference > 0 ||
audioDifference > 0 ||
documentDifference > 0 ||
linkDifference > 0
) {
return {
...state,
media: mediaWithout,
audio: audioWithout,
documents: documentsWithout,
links: linksWithout,
};
}
return state;
}
const oldestLoadedMedia = state.media[0];
const oldestLoadedAudio = state.audio[0];
const oldestLoadedDocument = state.documents[0];
const oldestLoadedLink = state.links[0];
const messageMediaItems: Array<MediaItemDBType> = (
message.attachments ?? []
).map((attachment, index) => {
@@ -547,10 +658,6 @@ export function reducer(
({ attachment }) => isVoiceMessage(attachment) || isAudio([attachment])
)
);
const newDocuments = _cleanAttachments(
'documents',
messageMediaItems.filter(({ attachment }) => isFile(attachment))
);
const newLinks = _cleanLinkPreviews(
message.preview != null && message.preview.length > 0
? [
@@ -562,75 +669,51 @@ export function reducer(
]
: []
);
const newContacts = _cleanDocuments(
message.contact != null && message.contact.length > 0
? [
{
type: 'contact',
contact: message.contact[0],
message: _cleanMessage(message),
},
]
: []
);
let {
media,
audio,
links,
documents,
haveOldestMedia,
haveOldestAudio,
haveOldestLink,
haveOldestDocument,
} = state;
const inMediaTimeRange =
!oldestLoadedMedia ||
(message.received_at >= oldestLoadedMedia.message.receivedAt &&
message.sent_at >= oldestLoadedMedia.message.sentAt);
if ((mediaDifference > 0 || newMedia.length > 0) && inMediaTimeRange) {
media = _sortItems(mediaWithout.concat(newMedia));
} else if (!inMediaTimeRange) {
haveOldestMedia = false;
}
const inAudioTimeRange =
!oldestLoadedAudio ||
(message.received_at >= oldestLoadedAudio.message.receivedAt &&
message.sent_at >= oldestLoadedAudio.message.sentAt);
if ((audioDifference > 0 || newAudio.length > 0) && inAudioTimeRange) {
audio = _sortItems(audioWithout.concat(newAudio));
} else if (!inAudioTimeRange) {
haveOldestAudio = false;
}
const inDocumentTimeRange =
!oldestLoadedDocument ||
(message.received_at >= oldestLoadedDocument.message.receivedAt &&
message.sent_at >= oldestLoadedDocument.message.sentAt);
if (
(documentDifference > 0 ||
newDocuments.length > 0 ||
newContacts.length > 0) &&
inDocumentTimeRange
) {
documents = _sortItems(
documentsWithout.concat(newDocuments, newContacts)
let newDocuments: ReadonlyArray<MediaItemType | ContactMediaItemType> =
_cleanAttachments(
'documents',
messageMediaItems.filter(({ attachment }) => isFile(attachment))
);
if (message.contact != null && message.contact.length > 0) {
newDocuments = newDocuments.concat(
_cleanContact({
type: 'contact',
contact: message.contact[0],
message: _cleanMessage(message),
})
);
} else if (!inDocumentTimeRange) {
haveOldestDocument = false;
}
const inLinkTimeRange =
!oldestLoadedLink ||
(message.received_at >= oldestLoadedLink.message.receivedAt &&
message.sent_at >= oldestLoadedLink.message.sentAt);
if ((linkDifference > 0 || newLinks.length > 0) && inLinkTimeRange) {
links = _sortItems(linksWithout.concat(newLinks));
} else if (!inLinkTimeRange) {
haveOldestLink = false;
}
const { sortOrder } = state;
const [media, haveOldestMedia] = _updateMedia({
message,
haveOldest: state.haveOldestMedia,
media: state.media,
newMedia,
sortOrder,
});
const [audio, haveOldestAudio] = _updateMedia({
message,
haveOldest: state.haveOldestAudio,
media: state.audio,
newMedia: newAudio,
sortOrder,
});
const [documents, haveOldestDocument] = _updateMedia({
message,
haveOldest: state.haveOldestDocument,
media: state.documents,
newMedia: newDocuments,
sortOrder,
});
const [links, haveOldestLink] = _updateMedia({
message,
haveOldest: state.haveOldestLink,
media: state.links,
newMedia: newLinks,
sortOrder: 'date',
});
if (
state.haveOldestMedia !== haveOldestMedia ||
+2
View File
@@ -42,6 +42,7 @@ export const SmartAllMedia = memo(function SmartAllMedia({
haveOldestDocument,
loading,
tab,
sortOrder,
} = useSelector(getMediaGalleryState);
const { initialLoad, loadMore } = useMediaGalleryActions();
const {
@@ -107,6 +108,7 @@ export const SmartAllMedia = memo(function SmartAllMedia({
links={links}
documents={documents}
tab={tab}
sortOrder={sortOrder}
showLightbox={showLightbox}
playAudio={playAudio}
kickOffAttachmentDownload={kickOffAttachmentDownload}
+11 -3
View File
@@ -8,9 +8,17 @@ import { getIntl } from '../selectors/user.std.js';
import { useMediaGalleryActions } from '../ducks/mediaGallery.preload.js';
export const SmartAllMediaHeader = memo(function SmartAllMediaHeader() {
const { tab } = useSelector(getMediaGalleryState);
const { setTab } = useMediaGalleryActions();
const { tab, sortOrder } = useSelector(getMediaGalleryState);
const { setTab, setSortOrder } = useMediaGalleryActions();
const i18n = useSelector(getIntl);
return <PanelHeader i18n={i18n} tab={tab} setTab={setTab} />;
return (
<PanelHeader
i18n={i18n}
tab={tab}
setTab={setTab}
sortOrder={sortOrder}
setSortOrder={setSortOrder}
/>
);
});
+5
View File
@@ -19,6 +19,7 @@ import {
getUserConversationId,
} from '../selectors/user.std.js';
import { getConversationSelector } from '../selectors/conversations.dom.js';
import { getMediaGalleryState } from '../selectors/mediaGallery.std.js';
import { useConversationsActions } from '../ducks/conversations.preload.js';
export type PropsType = Readonly<{
@@ -34,6 +35,7 @@ export const MediaItem = memo(function MediaItem({
const theme = useSelector(getTheme);
const ourConversationId = useSelector(getUserConversationId);
const getConversation = useSelector(getConversationSelector);
const { sortOrder } = useSelector(getMediaGalleryState);
const { showConversation } = useConversationsActions();
@@ -58,6 +60,8 @@ export const MediaItem = memo(function MediaItem({
});
}, [message.conversationId, message.id, showConversation]);
const showSize = sortOrder === 'size';
switch (mediaItem.type) {
case 'audio':
return (
@@ -74,6 +78,7 @@ export const MediaItem = memo(function MediaItem({
return (
<MediaGridItem
mediaItem={mediaItem}
showSize={showSize}
onClick={onClick}
i18n={i18n}
theme={theme}
+2
View File
@@ -11,6 +11,8 @@ import type { EmbeddedContactForUIType } from './EmbeddedContact.std.js';
export type MediaTabType = 'media' | 'audio' | 'links' | 'documents';
export type MediaSortOrderType = 'date' | 'size';
export type MediaItemMessageType = Readonly<{
id: string;
type: MessageAttributesType['type'];