Update tabs UI in MediaGallery

This commit is contained in:
Fedor Indutny
2025-12-06 05:39:40 -08:00
committed by GitHub
parent 62cd5cdd63
commit f78b36c46a
15 changed files with 340 additions and 235 deletions

View File

@@ -74,7 +74,7 @@ export function AttachmentSection({
switch (verified.type) { switch (verified.type) {
case 'media': case 'media':
return ( return (
<section className={tw('@container ps-5')}> <section className={tw('@container px-5')}>
<h2 className={tw('ps-1 pt-4 pb-2 type-body-medium')}>{header}</h2> <h2 className={tw('ps-1 pt-4 pb-2 type-body-medium')}>{header}</h2>
<div <div
className={tw( className={tw(

View File

@@ -5,7 +5,6 @@ import * as React from 'react';
import type { Meta } from '@storybook/react'; import type { Meta } from '@storybook/react';
import type { Props } from './EmptyState.dom.js'; import type { Props } from './EmptyState.dom.js';
import { EmptyState } from './EmptyState.dom.js'; import { EmptyState } from './EmptyState.dom.js';
import { TabViews } from './types/TabViews.std.js';
const { i18n } = window.SignalContext; const { i18n } = window.SignalContext;
@@ -14,12 +13,12 @@ export default {
argTypes: { argTypes: {
tab: { tab: {
control: { type: 'select' }, control: { type: 'select' },
options: [TabViews.Media, TabViews.Documents, TabViews.Links], options: ['media', 'audio', 'links', 'documents'],
}, },
}, },
args: { args: {
i18n, i18n,
tab: TabViews.Media, tab: 'media',
}, },
} satisfies Meta<Props>; } satisfies Meta<Props>;
@@ -28,13 +27,17 @@ export function Default(args: Props): JSX.Element {
} }
export function Media(args: Props): JSX.Element { export function Media(args: Props): JSX.Element {
return <EmptyState {...args} tab={TabViews.Media} />; return <EmptyState {...args} tab="media" />;
} }
export function Documents(args: Props): JSX.Element { export function Audio(args: Props): JSX.Element {
return <EmptyState {...args} tab={TabViews.Documents} />; return <EmptyState {...args} tab="audio" />;
} }
export function Links(args: Props): JSX.Element { export function Links(args: Props): JSX.Element {
return <EmptyState {...args} tab={TabViews.Links} />; return <EmptyState {...args} tab="links" />;
}
export function Documents(args: Props): JSX.Element {
return <EmptyState {...args} tab="documents" />;
} }

View File

@@ -4,13 +4,13 @@
import React from 'react'; import React from 'react';
import type { LocalizerType } from '../../../types/Util.std.js'; import type { LocalizerType } from '../../../types/Util.std.js';
import type { MediaTabType } from '../../../types/MediaItem.std.js';
import { tw } from '../../../axo/tw.dom.js'; import { tw } from '../../../axo/tw.dom.js';
import { missingCaseError } from '../../../util/missingCaseError.std.js'; import { missingCaseError } from '../../../util/missingCaseError.std.js';
import { TabViews } from './types/TabViews.std.js';
export type Props = { export type Props = {
i18n: LocalizerType; i18n: LocalizerType;
tab: TabViews; tab: MediaTabType;
}; };
export function EmptyState({ i18n, tab }: Props): JSX.Element { export function EmptyState({ i18n, tab }: Props): JSX.Element {
@@ -18,21 +18,21 @@ export function EmptyState({ i18n, tab }: Props): JSX.Element {
let description: string; let description: string;
switch (tab) { switch (tab) {
case TabViews.Media: case 'media':
title = i18n('icu:MediaGallery__EmptyState__title--media'); title = i18n('icu:MediaGallery__EmptyState__title--media');
description = i18n('icu:MediaGallery__EmptyState__description--media'); description = i18n('icu:MediaGallery__EmptyState__description--media');
break; break;
case TabViews.Audio: case 'audio':
title = i18n('icu:MediaGallery__EmptyState__title--audio'); title = i18n('icu:MediaGallery__EmptyState__title--audio');
description = i18n('icu:MediaGallery__EmptyState__description--audio'); description = i18n('icu:MediaGallery__EmptyState__description--audio');
break; break;
case TabViews.Documents: case 'documents':
title = i18n('icu:MediaGallery__EmptyState__title--documents'); title = i18n('icu:MediaGallery__EmptyState__title--documents');
description = i18n( description = i18n(
'icu:MediaGallery__EmptyState__description--documents-2' 'icu:MediaGallery__EmptyState__description--documents-2'
); );
break; break;
case TabViews.Links: case 'links':
title = i18n('icu:MediaGallery__EmptyState__title--links'); title = i18n('icu:MediaGallery__EmptyState__title--links');
description = i18n('icu:MediaGallery__EmptyState__description--links-2'); description = i18n('icu:MediaGallery__EmptyState__description--links-2');
break; break;

View File

@@ -1,11 +1,13 @@
// Copyright 2020 Signal Messenger, LLC // Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react'; import React, { useState, useCallback } from 'react';
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
import type { Meta } from '@storybook/react'; import type { Meta } from '@storybook/react';
import type { MediaTabType } from '../../../types/MediaItem.std.js';
import type { Props } from './MediaGallery.dom.js'; import type { Props } from './MediaGallery.dom.js';
import { MediaGallery } from './MediaGallery.dom.js'; import { MediaGallery } from './MediaGallery.dom.js';
import { PanelHeader } from './PanelHeader.dom.js';
import { import {
createPreparedMediaItems, createPreparedMediaItems,
createRandomDocuments, createRandomDocuments,
@@ -36,6 +38,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
audio: overrideProps.audio || [], audio: overrideProps.audio || [],
links: overrideProps.links || [], links: overrideProps.links || [],
documents: overrideProps.documents || [], documents: overrideProps.documents || [],
tab: overrideProps.tab || 'media',
initialLoad: action('initialLoad'), initialLoad: action('initialLoad'),
loadMore: action('loadMore'), loadMore: action('loadMore'),
@@ -46,29 +49,59 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
cancelAttachmentDownload: action('cancelAttachmentDownload'), cancelAttachmentDownload: action('cancelAttachmentDownload'),
renderMediaItem: props => <MediaItem {...props} />, renderMediaItem: props => <MediaItem {...props} />,
renderMiniPlayer: () => <div />,
}); });
function Panel(props: Props): JSX.Element {
const [tab, setTab] = useState<MediaTabType>(props.tab);
const [loading, setLoading] = useState(false);
const { loadMore: givenLoadMore } = props;
const loadMore = useCallback(
(...args: Parameters<typeof givenLoadMore>) => {
setLoading(true);
setTimeout(() => {
setLoading(false);
}, 250);
return givenLoadMore(...args);
},
[givenLoadMore]
);
return (
<div>
<PanelHeader i18n={i18n} tab={tab} setTab={setTab} />
<MediaGallery
{...props}
tab={tab}
loading={loading}
loadMore={loadMore}
/>
</div>
);
}
export function Populated(): JSX.Element { export function Populated(): JSX.Element {
const documents = createRandomDocuments(Date.now() - days(5), days(5)); const documents = createRandomDocuments(Date.now() - days(5), days(5));
const media = createPreparedMediaItems(createRandomMedia); const media = createPreparedMediaItems(createRandomMedia);
const props = createProps({ documents, media }); const props = createProps({ documents, media });
return <MediaGallery {...props} />; return <Panel {...props} />;
} }
export function NoDocuments(): JSX.Element { export function NoDocuments(): JSX.Element {
const media = createPreparedMediaItems(createRandomMedia); const media = createPreparedMediaItems(createRandomMedia);
const props = createProps({ media }); const props = createProps({ media, haveOldestDocument: true });
return <MediaGallery {...props} />; return <Panel {...props} />;
} }
export function NoMedia(): JSX.Element { export function NoMedia(): JSX.Element {
const documents = createPreparedMediaItems(createRandomDocuments); const documents = createPreparedMediaItems(createRandomDocuments);
const props = createProps({ documents }); const props = createProps({ documents, haveOldestMedia: true });
return <MediaGallery {...props} />; return <Panel {...props} />;
} }
export function OneEach(): JSX.Element { export function OneEach(): JSX.Element {
@@ -79,11 +112,16 @@ export function OneEach(): JSX.Element {
const props = createProps({ documents, audio, media, links }); const props = createProps({ documents, audio, media, links });
return <MediaGallery {...props} />; return <Panel {...props} />;
} }
export function Empty(): JSX.Element { export function Empty(): JSX.Element {
const props = createProps(); const props = createProps({
haveOldestMedia: true,
haveOldestAudio: true,
haveOldestDocument: true,
haveOldestLink: true,
});
return <MediaGallery {...props} />; return <Panel {...props} />;
} }

View File

@@ -1,13 +1,20 @@
// Copyright 2018 Signal Messenger, LLC // Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React, { Fragment, useEffect, useRef, useCallback } from 'react'; import React, {
Fragment,
useEffect,
useRef,
useCallback,
useState,
} from 'react';
import moment from 'moment'; import moment from 'moment';
import type { ItemClickEvent } from './types/ItemClickEvent.std.js'; import type { ItemClickEvent } from './types/ItemClickEvent.std.js';
import type { LocalizerType } from '../../../types/Util.std.js'; import type { LocalizerType } from '../../../types/Util.std.js';
import type { import type {
MediaTabType,
LinkPreviewMediaItemType, LinkPreviewMediaItemType,
MediaItemType, MediaItemType,
GenericMediaItemType, GenericMediaItemType,
@@ -15,12 +22,10 @@ import type {
import type { SaveAttachmentActionCreatorType } from '../../../state/ducks/conversations.preload.js'; import type { SaveAttachmentActionCreatorType } from '../../../state/ducks/conversations.preload.js';
import { AttachmentSection } from './AttachmentSection.dom.js'; import { AttachmentSection } from './AttachmentSection.dom.js';
import { EmptyState } from './EmptyState.dom.js'; import { EmptyState } from './EmptyState.dom.js';
import { Tabs } from '../../Tabs.dom.js';
import { TabViews } from './types/TabViews.std.js';
import { groupMediaItemsByDate } from './groupMediaItemsByDate.std.js'; import { groupMediaItemsByDate } from './groupMediaItemsByDate.std.js';
import { missingCaseError } from '../../../util/missingCaseError.std.js'; import { missingCaseError } from '../../../util/missingCaseError.std.js';
import { openLinkInWebBrowser } from '../../../util/openLinkInWebBrowser.dom.js'; import { openLinkInWebBrowser } from '../../../util/openLinkInWebBrowser.dom.js';
import { usePrevious } from '../../../hooks/usePrevious.std.js'; import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver.std.js';
import type { AttachmentForUIType } from '../../../types/Attachment.std.js'; import type { AttachmentForUIType } from '../../../types/Attachment.std.js';
import { tw } from '../../../axo/tw.dom.js'; import { tw } from '../../../axo/tw.dom.js';
@@ -33,14 +38,12 @@ export type Props = {
haveOldestDocument: boolean; haveOldestDocument: boolean;
loading: boolean; loading: boolean;
initialLoad: (id: string) => unknown; initialLoad: (id: string) => unknown;
loadMore: ( loadMore: (id: string, type: MediaTabType) => unknown;
id: string,
type: 'media' | 'audio' | 'documents' | 'links'
) => unknown;
media: ReadonlyArray<MediaItemType>; media: ReadonlyArray<MediaItemType>;
audio: ReadonlyArray<MediaItemType>; audio: ReadonlyArray<MediaItemType>;
documents: ReadonlyArray<MediaItemType>;
links: ReadonlyArray<LinkPreviewMediaItemType>; links: ReadonlyArray<LinkPreviewMediaItemType>;
documents: ReadonlyArray<MediaItemType>;
tab: MediaTabType;
saveAttachment: SaveAttachmentActionCreatorType; saveAttachment: SaveAttachmentActionCreatorType;
kickOffAttachmentDownload: (options: { messageId: string }) => void; kickOffAttachmentDownload: (options: { messageId: string }) => void;
cancelAttachmentDownload: (options: { messageId: string }) => void; cancelAttachmentDownload: (options: { messageId: string }) => void;
@@ -50,7 +53,6 @@ export type Props = {
messageId: string; messageId: string;
}) => void; }) => void;
renderMiniPlayer: () => JSX.Element;
renderMediaItem: (props: { renderMediaItem: (props: {
onItemClick: (event: ItemClickEvent) => unknown; onItemClick: (event: ItemClickEvent) => unknown;
mediaItem: GenericMediaItemType; mediaItem: GenericMediaItemType;
@@ -81,7 +83,7 @@ function MediaSection({
| 'playAudio' | 'playAudio'
| 'renderMediaItem' | 'renderMediaItem'
> & { > & {
tab: TabViews; tab: MediaTabType;
mediaItems: ReadonlyArray<GenericMediaItemType>; mediaItems: ReadonlyArray<GenericMediaItemType>;
}): JSX.Element { }): JSX.Element {
const onItemClick = useCallback( const onItemClick = useCallback(
@@ -194,25 +196,29 @@ export function MediaGallery({
haveOldestDocument, haveOldestDocument,
i18n, i18n,
initialLoad, initialLoad,
loading, loading: reduxLoading,
loadMore, loadMore,
media, media,
audio, audio,
links, links,
documents, documents,
tab,
saveAttachment, saveAttachment,
kickOffAttachmentDownload, kickOffAttachmentDownload,
cancelAttachmentDownload, cancelAttachmentDownload,
playAudio, playAudio,
showLightbox, showLightbox,
renderMediaItem, renderMediaItem,
renderMiniPlayer,
}: Props): JSX.Element { }: Props): JSX.Element {
const focusRef = useRef<HTMLDivElement | null>(null); const focusRef = useRef<HTMLDivElement | null>(null);
const scrollObserverRef = useRef<HTMLDivElement | null>(null); const [loading, setLoading] = useState(reduxLoading);
const intersectionObserver = useRef<IntersectionObserver | null>(null);
const loadingRef = useRef<boolean>(false); // Reset local state when redux finishes loading
const tabViewRef = useRef<TabViews>(TabViews.Media); useEffect(() => {
if (reduxLoading === false) {
setLoading(false);
}
}, [reduxLoading]);
useEffect(() => { useEffect(() => {
focusRef.current?.focus(); focusRef.current?.focus();
@@ -232,7 +238,6 @@ export function MediaGallery({
return; return;
} }
initialLoad(conversationId); initialLoad(conversationId);
loadingRef.current = true;
}, [ }, [
conversationId, conversationId,
haveOldestMedia, haveOldestMedia,
@@ -246,63 +251,42 @@ export function MediaGallery({
documents.length, documents.length,
]); ]);
const previousLoading = usePrevious(loading, loading); const [setObserverRef, observerEntry] = useIntersectionObserver();
if (previousLoading && !loading) {
loadingRef.current = false;
}
useEffect(() => { useEffect(() => {
if (loading || !scrollObserverRef.current) { if (loading) {
return; return;
} }
intersectionObserver.current?.disconnect(); if (!observerEntry?.isIntersecting) {
intersectionObserver.current = null;
intersectionObserver.current = new IntersectionObserver(
(entries: ReadonlyArray<IntersectionObserverEntry>) => {
if (loadingRef.current) {
return; return;
} }
const entry = entries.find( if (tab === 'media') {
item => item.target === scrollObserverRef.current if (haveOldestMedia) {
); return;
}
if (entry && entry.intersectionRatio > 0) {
if (tabViewRef.current === TabViews.Media) {
if (!haveOldestMedia) {
loadMore(conversationId, 'media'); loadMore(conversationId, 'media');
loadingRef.current = true; } else if (tab === 'audio') {
if (haveOldestAudio) {
return;
} }
} else if (tabViewRef.current === TabViews.Audio) {
if (!haveOldestAudio) {
loadMore(conversationId, 'audio'); loadMore(conversationId, 'audio');
loadingRef.current = true; } else if (tab === 'documents') {
if (haveOldestDocument) {
return;
} }
} else if (tabViewRef.current === TabViews.Documents) {
if (!haveOldestDocument) {
loadMore(conversationId, 'documents'); loadMore(conversationId, 'documents');
loadingRef.current = true; } else if (tab === 'links') {
if (haveOldestLink) {
return;
} }
} else if (tabViewRef.current === TabViews.Links) {
if (!haveOldestLink) {
loadMore(conversationId, 'links'); loadMore(conversationId, 'links');
loadingRef.current = true;
}
} else { } else {
throw missingCaseError(tabViewRef.current); throw missingCaseError(tab);
} }
} setLoading(true);
}
);
intersectionObserver.current.observe(scrollObserverRef.current);
return () => {
intersectionObserver.current?.disconnect();
intersectionObserver.current = null;
};
}, [ }, [
observerEntry,
conversationId, conversationId,
haveOldestDocument, haveOldestDocument,
haveOldestMedia, haveOldestMedia,
@@ -310,68 +294,34 @@ export function MediaGallery({
haveOldestLink, haveOldestLink,
loading, loading,
loadMore, loadMore,
tab,
]); ]);
let mediaItems: ReadonlyArray<GenericMediaItemType>;
if (tab === 'media') {
mediaItems = media;
} else if (tab === 'audio') {
mediaItems = audio;
} else if (tab === 'documents') {
mediaItems = documents;
} else if (tab === 'links') {
mediaItems = links;
} else {
throw new Error(`Unexpected select tab: ${tab}`);
}
return ( return (
<div <div
className={tw('flex size-full grow flex-col outline-none')} className={tw('flex size-full grow flex-col outline-none')}
tabIndex={-1} tabIndex={-1}
ref={focusRef} ref={focusRef}
> >
<Tabs <div className={tw('grow overflow-y-auto')}>
initialSelectedTab={TabViews.Media}
tabs={[
{
id: TabViews.Media,
label: i18n('icu:media'),
},
{
id: TabViews.Audio,
label: i18n('icu:MediaGallery__tab__audio'),
},
{
id: TabViews.Links,
label: i18n('icu:MediaGallery__tab__links'),
},
{
id: TabViews.Documents,
label: i18n('icu:MediaGallery__tab__files'),
},
]}
>
{({ selectedTab }) => {
let mediaItems: ReadonlyArray<GenericMediaItemType>;
if (selectedTab === TabViews.Media) {
tabViewRef.current = TabViews.Media;
mediaItems = media;
} else if (selectedTab === TabViews.Audio) {
tabViewRef.current = TabViews.Audio;
mediaItems = audio;
} else if (selectedTab === TabViews.Documents) {
tabViewRef.current = TabViews.Documents;
mediaItems = documents;
} else if (selectedTab === TabViews.Links) {
tabViewRef.current = TabViews.Links;
mediaItems = links;
} else {
throw new Error(`Unexpected select tab: ${selectedTab}`);
}
return (
<>
{renderMiniPlayer()}
<div
className={tw(
'grow',
'overflow-x-hidden overflow-y-auto',
'p-5'
)}
>
<MediaSection <MediaSection
i18n={i18n} i18n={i18n}
loading={loading} loading={loading}
tab={tabViewRef.current} tab={tab}
mediaItems={mediaItems} mediaItems={mediaItems}
saveAttachment={saveAttachment} saveAttachment={saveAttachment}
showLightbox={showLightbox} showLightbox={showLightbox}
@@ -380,12 +330,8 @@ export function MediaGallery({
playAudio={playAudio} playAudio={playAudio}
renderMediaItem={renderMediaItem} renderMediaItem={renderMediaItem}
/> />
<div ref={scrollObserverRef} className={tw('h-px')} /> <div ref={setObserverRef} className={tw('h-px')} />
</div> </div>
</>
);
}}
</Tabs>
</div> </div>
); );
} }

View File

@@ -0,0 +1,112 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
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 type { LocalizerType } from '../../../types/Util.std.js';
import type { MediaTabType } from '../../../types/MediaItem.std.js';
// Provided by smart layer
export type Props = Readonly<{
i18n: LocalizerType;
tab: MediaTabType;
setTab: (newTab: MediaTabType) => void;
}>;
export function PanelHeader({ i18n, tab, setTab }: Props): JSX.Element {
const setSelectedTabWithDefault = useCallback(
(value: string | null) => {
switch (value) {
case 'media':
case 'audio':
case 'documents':
case 'links':
setTab(value);
break;
case null:
break;
default:
setTab('media');
break;
}
},
[setTab]
);
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]'
)}
>
<div className={tw('hidden max-w-[320px] grow @min-[260px]:block')}>
<ExperimentalAxoSegmentedControl.Root
variant="no-track"
width="full"
itemWidth="equal"
value={tab}
onValueChange={setSelectedTabWithDefault}
>
<ExperimentalAxoSegmentedControl.Item value="media">
<ExperimentalAxoSegmentedControl.ItemText>
{i18n('icu:media')}
</ExperimentalAxoSegmentedControl.ItemText>
</ExperimentalAxoSegmentedControl.Item>
<ExperimentalAxoSegmentedControl.Item value="audio">
<ExperimentalAxoSegmentedControl.ItemText>
{i18n('icu:MediaGallery__tab__audio')}
</ExperimentalAxoSegmentedControl.ItemText>
</ExperimentalAxoSegmentedControl.Item>
<ExperimentalAxoSegmentedControl.Item value="links">
<ExperimentalAxoSegmentedControl.ItemText>
{i18n('icu:MediaGallery__tab__links')}
</ExperimentalAxoSegmentedControl.ItemText>
</ExperimentalAxoSegmentedControl.Item>
<ExperimentalAxoSegmentedControl.Item value="documents">
<ExperimentalAxoSegmentedControl.ItemText>
{i18n('icu:MediaGallery__tab__files')}
</ExperimentalAxoSegmentedControl.ItemText>
</ExperimentalAxoSegmentedControl.Item>
</ExperimentalAxoSegmentedControl.Root>
</div>
<div className={tw('block @min-[260px]:hidden')}>
<AxoSelect.Root value={tab} onValueChange={setSelectedTabWithDefault}>
<AxoSelect.Trigger
variant="floating"
width="fit"
placeholder=""
chevron="always"
/>
<AxoSelect.Content position="dropdown">
<AxoSelect.Item value="media">
<AxoSelect.ItemText>{i18n('icu:media')}</AxoSelect.ItemText>
</AxoSelect.Item>
<AxoSelect.Item value="audio">
<AxoSelect.ItemText>
{i18n('icu:MediaGallery__tab__audio')}
</AxoSelect.ItemText>
</AxoSelect.Item>
<AxoSelect.Item value="links">
<AxoSelect.ItemText>
{i18n('icu:MediaGallery__tab__links')}
</AxoSelect.ItemText>
</AxoSelect.Item>
<AxoSelect.Item value="documents">
<AxoSelect.ItemText>
{i18n('icu:MediaGallery__tab__files')}
</AxoSelect.ItemText>
</AxoSelect.Item>
</AxoSelect.Content>
</AxoSelect.Root>
</div>
</div>
);
}

View File

@@ -1,9 +0,0 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export enum TabViews {
Media = 'Media',
Audio = 'Audio',
Documents = 'Documents',
Links = 'Links',
}

View File

@@ -28,6 +28,7 @@ import type {
MessageExpiredActionType, MessageExpiredActionType,
} from './conversations.preload.js'; } from './conversations.preload.js';
import type { import type {
MediaTabType,
MediaItemMessageType, MediaItemMessageType,
MediaItemType, MediaItemType,
LinkPreviewMediaItemType, LinkPreviewMediaItemType,
@@ -47,16 +48,17 @@ const { orderBy } = lodash;
const log = createLogger('mediaGallery'); const log = createLogger('mediaGallery');
export type MediaGalleryStateType = ReadonlyDeep<{ export type MediaGalleryStateType = ReadonlyDeep<{
tab: MediaTabType;
conversationId: string | undefined; conversationId: string | undefined;
haveOldestDocument: boolean;
haveOldestMedia: boolean; haveOldestMedia: boolean;
haveOldestAudio: boolean; haveOldestAudio: boolean;
haveOldestLink: boolean; haveOldestLink: boolean;
haveOldestDocument: boolean;
loading: boolean; loading: boolean;
media: ReadonlyArray<MediaItemType>; media: ReadonlyArray<MediaItemType>;
audio: ReadonlyArray<MediaItemType>; audio: ReadonlyArray<MediaItemType>;
documents: ReadonlyArray<MediaItemType>;
links: ReadonlyArray<LinkPreviewMediaItemType>; links: ReadonlyArray<LinkPreviewMediaItemType>;
documents: ReadonlyArray<MediaItemType>;
}>; }>;
const FETCH_CHUNK_COUNT = 50; const FETCH_CHUNK_COUNT = 50;
@@ -64,15 +66,16 @@ const FETCH_CHUNK_COUNT = 50;
const INITIAL_LOAD = 'mediaGallery/INITIAL_LOAD'; const INITIAL_LOAD = 'mediaGallery/INITIAL_LOAD';
const LOAD_MORE = 'mediaGallery/LOAD_MORE'; const LOAD_MORE = 'mediaGallery/LOAD_MORE';
const SET_LOADING = 'mediaGallery/SET_LOADING'; const SET_LOADING = 'mediaGallery/SET_LOADING';
const SET_TAB = 'mediaGallery/SET_TAB';
type InitialLoadActionType = ReadonlyDeep<{ type InitialLoadActionType = ReadonlyDeep<{
type: typeof INITIAL_LOAD; type: typeof INITIAL_LOAD;
payload: { payload: {
conversationId: string; conversationId: string;
documents: ReadonlyArray<MediaItemType>;
media: ReadonlyArray<MediaItemType>; media: ReadonlyArray<MediaItemType>;
audio: ReadonlyArray<MediaItemType>; audio: ReadonlyArray<MediaItemType>;
links: ReadonlyArray<LinkPreviewMediaItemType>; links: ReadonlyArray<LinkPreviewMediaItemType>;
documents: ReadonlyArray<MediaItemType>;
}; };
}>; }>;
type LoadMoreActionType = ReadonlyDeep<{ type LoadMoreActionType = ReadonlyDeep<{
@@ -81,8 +84,8 @@ type LoadMoreActionType = ReadonlyDeep<{
conversationId: string; conversationId: string;
media: ReadonlyArray<MediaItemType>; media: ReadonlyArray<MediaItemType>;
audio: ReadonlyArray<MediaItemType>; audio: ReadonlyArray<MediaItemType>;
documents: ReadonlyArray<MediaItemType>;
links: ReadonlyArray<LinkPreviewMediaItemType>; links: ReadonlyArray<LinkPreviewMediaItemType>;
documents: ReadonlyArray<MediaItemType>;
}; };
}>; }>;
type SetLoadingActionType = ReadonlyDeep<{ type SetLoadingActionType = ReadonlyDeep<{
@@ -91,6 +94,12 @@ type SetLoadingActionType = ReadonlyDeep<{
loading: boolean; loading: boolean;
}; };
}>; }>;
type SetTabActionType = ReadonlyDeep<{
type: typeof SET_TAB;
payload: {
tab: MediaGalleryStateType['tab'];
};
}>;
type MediaGalleryActionType = ReadonlyDeep< type MediaGalleryActionType = ReadonlyDeep<
| ConversationUnloadedActionType | ConversationUnloadedActionType
@@ -100,6 +109,7 @@ type MediaGalleryActionType = ReadonlyDeep<
| MessageDeletedActionType | MessageDeletedActionType
| MessageExpiredActionType | MessageExpiredActionType
| SetLoadingActionType | SetLoadingActionType
| SetTabActionType
>; >;
function _sortItems< function _sortItems<
@@ -223,7 +233,7 @@ function initialLoad(
function loadMore( function loadMore(
conversationId: string, conversationId: string,
type: 'media' | 'audio' | 'documents' | 'links' type: MediaTabType
): ThunkAction< ): ThunkAction<
void, void,
RootStateType, RootStateType,
@@ -316,9 +326,19 @@ function loadMore(
}; };
} }
function setTab(tab: MediaGalleryStateType['tab']): SetTabActionType {
return {
type: SET_TAB,
payload: {
tab,
},
};
}
export const actions = { export const actions = {
initialLoad, initialLoad,
loadMore, loadMore,
setTab,
}; };
export const useMediaGalleryActions = (): BoundActionCreatorsMapObject< export const useMediaGalleryActions = (): BoundActionCreatorsMapObject<
@@ -327,6 +347,7 @@ export const useMediaGalleryActions = (): BoundActionCreatorsMapObject<
export function getEmptyState(): MediaGalleryStateType { export function getEmptyState(): MediaGalleryStateType {
return { return {
tab: 'media',
conversationId: undefined, conversationId: undefined,
haveOldestDocument: false, haveOldestDocument: false,
haveOldestMedia: false, haveOldestMedia: false,
@@ -357,7 +378,7 @@ export function reducer(
const { payload } = action; const { payload } = action;
return { return {
...state, tab: 'media',
loading: false, loading: false,
conversationId: payload.conversationId, conversationId: payload.conversationId,
haveOldestMedia: payload.media.length === 0, haveOldestMedia: payload.media.length === 0,
@@ -391,6 +412,15 @@ export function reducer(
}; };
} }
if (action.type === SET_TAB) {
const { tab } = action.payload;
return {
...state,
tab,
};
}
// A time-ordered subset of all conversation media is loaded at once. // 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, // When a message changes, check that the changed message falls within this time range,
// and if so insert it into the loaded media. // and if so insert it into the loaded media.

View File

@@ -63,7 +63,6 @@ import { createLogger } from '../../logging/log.std.js';
import { TimelineMessageLoadingState } from '../../util/timelineUtil.std.js'; import { TimelineMessageLoadingState } from '../../util/timelineUtil.std.js';
import { isSignalConversation } from '../../util/isSignalConversation.dom.js'; import { isSignalConversation } from '../../util/isSignalConversation.dom.js';
import { reduce } from '../../util/iterables.std.js'; import { reduce } from '../../util/iterables.std.js';
import { getConversationTitleForPanelType } from '../../util/getConversationTitleForPanelType.std.js';
import type { PanelRenderType } from '../../types/Panels.std.js'; import type { PanelRenderType } from '../../types/Panels.std.js';
import type { HasStories } from '../../types/Stories.std.js'; import type { HasStories } from '../../types/Stories.std.js';
import { getHasStoriesSelector } from './stories2.dom.js'; import { getHasStoriesSelector } from './stories2.dom.js';
@@ -1439,13 +1438,6 @@ export const getWasPanelAnimated = createSelector(
} }
); );
export const getConversationTitle = createSelector(
getIntl,
getActivePanel,
(i18n, panel): string | undefined =>
getConversationTitleForPanelType(i18n, panel?.type)
);
// Note that this doesn't take into account max edit count. See canEditMessage. // Note that this doesn't take into account max edit count. See canEditMessage.
export const getLastEditableMessageId = createSelector( export const getLastEditableMessageId = createSelector(
getConversationMessages, getConversationMessages,

View File

@@ -17,7 +17,6 @@ import {
MediaItem, MediaItem,
type PropsType as MediaItemPropsType, type PropsType as MediaItemPropsType,
} from './MediaItem.preload.js'; } from './MediaItem.preload.js';
import { SmartMiniPlayer } from './MiniPlayer.preload.js';
const log = createLogger('AllMedia'); const log = createLogger('AllMedia');
@@ -25,10 +24,6 @@ export type PropsType = {
conversationId: string; conversationId: string;
}; };
function renderMiniPlayer(): JSX.Element {
return <SmartMiniPlayer shouldFlow />;
}
function renderMediaItem(props: MediaItemPropsType): JSX.Element { function renderMediaItem(props: MediaItemPropsType): JSX.Element {
return <MediaItem {...props} />; return <MediaItem {...props} />;
} }
@@ -46,6 +41,7 @@ export const SmartAllMedia = memo(function SmartAllMedia({
haveOldestLink, haveOldestLink,
haveOldestDocument, haveOldestDocument,
loading, loading,
tab,
} = useSelector(getMediaGalleryState); } = useSelector(getMediaGalleryState);
const { initialLoad, loadMore } = useMediaGalleryActions(); const { initialLoad, loadMore } = useMediaGalleryActions();
const { const {
@@ -109,13 +105,13 @@ export const SmartAllMedia = memo(function SmartAllMedia({
audio={audio} audio={audio}
links={links} links={links}
documents={documents} documents={documents}
tab={tab}
showLightbox={showLightbox} showLightbox={showLightbox}
playAudio={playAudio} playAudio={playAudio}
kickOffAttachmentDownload={kickOffAttachmentDownload} kickOffAttachmentDownload={kickOffAttachmentDownload}
cancelAttachmentDownload={cancelAttachmentDownload} cancelAttachmentDownload={cancelAttachmentDownload}
saveAttachment={saveAttachment} saveAttachment={saveAttachment}
renderMediaItem={renderMediaItem} renderMediaItem={renderMediaItem}
renderMiniPlayer={renderMiniPlayer}
/> />
); );
}); });

View File

@@ -0,0 +1,16 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { memo } from 'react';
import { useSelector } from 'react-redux';
import { PanelHeader } from '../../components/conversation/media-gallery/PanelHeader.dom.js';
import { getMediaGalleryState } from '../selectors/mediaGallery.std.js';
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 i18n = useSelector(getIntl);
return <PanelHeader i18n={i18n} tab={tab} setTab={setTab} />;
});

View File

@@ -17,6 +17,7 @@ import { createLogger } from '../../logging/log.std.js';
import { PanelType } from '../../types/Panels.std.js'; import { PanelType } from '../../types/Panels.std.js';
import { toLogFormat } from '../../types/errors.std.js'; import { toLogFormat } from '../../types/errors.std.js';
import { SmartAllMedia } from './AllMedia.preload.js'; import { SmartAllMedia } from './AllMedia.preload.js';
import { SmartAllMediaHeader } from './AllMediaHeader.preload.js';
import { SmartChatColorPicker } from './ChatColorPicker.preload.js'; import { SmartChatColorPicker } from './ChatColorPicker.preload.js';
import { SmartContactDetail } from './ContactDetail.preload.js'; import { SmartContactDetail } from './ContactDetail.preload.js';
import { SmartConversationDetails } from './ConversationDetails.preload.js'; import { SmartConversationDetails } from './ConversationDetails.preload.js';
@@ -39,6 +40,7 @@ import { useConversationsActions } from '../ducks/conversations.preload.js';
import { useReducedMotion } from '../../hooks/useReducedMotion.dom.js'; import { useReducedMotion } from '../../hooks/useReducedMotion.dom.js';
import { itemStorage } from '../../textsecure/Storage.preload.js'; import { itemStorage } from '../../textsecure/Storage.preload.js';
import { SmartPinnedMessagesPanel } from './PinnedMessagesPanel.preload.js'; import { SmartPinnedMessagesPanel } from './PinnedMessagesPanel.preload.js';
import { SmartMiniPlayer } from './MiniPlayer.preload.js';
const log = createLogger('ConversationPanel'); const log = createLogger('ConversationPanel');
@@ -288,6 +290,19 @@ const PanelContainer = forwardRef<
const { popPanelForConversation } = useConversationsActions(); const { popPanelForConversation } = useConversationsActions();
const conversationTitle = getConversationTitleForPanelType(i18n, panel.type); const conversationTitle = getConversationTitleForPanelType(i18n, panel.type);
let info: JSX.Element | undefined;
if (panel.type === PanelType.AllMedia) {
info = <SmartAllMediaHeader />;
} else if (conversationTitle != null) {
info = (
<div className="ConversationPanel__header__info">
<div className="ConversationPanel__header__info__title">
{conversationTitle}
</div>
</div>
);
}
const focusRef = useRef<HTMLDivElement | null>(null); const focusRef = useRef<HTMLDivElement | null>(null);
useEffect(() => { useEffect(() => {
if (!isActive) { if (!isActive) {
@@ -315,18 +330,14 @@ const PanelContainer = forwardRef<
onClick={popPanelForConversation} onClick={popPanelForConversation}
type="button" type="button"
/> />
{conversationTitle && ( {info}
<div className="ConversationPanel__header__info">
<div className="ConversationPanel__header__info__title">
{conversationTitle}
</div>
</div>
)}
</div> </div>
<SmartMiniPlayer shouldFlow />
<div <div
className={classNames( className={classNames(
'ConversationPanel__body', 'ConversationPanel__body',
panel.type !== PanelType.PinnedMessages && panel.type !== PanelType.PinnedMessages &&
panel.type !== PanelType.AllMedia &&
'ConversationPanel__body--padding' 'ConversationPanel__body--padding'
)} )}
ref={focusRef} ref={focusRef}

View File

@@ -8,6 +8,8 @@ import type { AttachmentForUIType } from './Attachment.std.js';
import type { LinkPreviewForUIType } from './message/LinkPreviews.std.js'; import type { LinkPreviewForUIType } from './message/LinkPreviews.std.js';
import type { ServiceIdString } from './ServiceId.std.js'; import type { ServiceIdString } from './ServiceId.std.js';
export type MediaTabType = 'media' | 'audio' | 'links' | 'documents';
export type MediaItemMessageType = Readonly<{ export type MediaItemMessageType = Readonly<{
id: string; id: string;
type: MessageAttributesType['type']; type: MessageAttributesType['type'];

View File

@@ -16,7 +16,7 @@ export function getConversationTitleForPanelType(
} }
if (panelType === PanelType.AllMedia) { if (panelType === PanelType.AllMedia) {
return i18n('icu:allMedia'); return undefined;
} }
if (panelType === PanelType.ChatColorEditor) { if (panelType === PanelType.ChatColorEditor) {
@@ -24,11 +24,11 @@ export function getConversationTitleForPanelType(
} }
if (panelType === PanelType.ContactDetails) { if (panelType === PanelType.ContactDetails) {
return ''; return undefined;
} }
if (panelType === PanelType.ConversationDetails) { if (panelType === PanelType.ConversationDetails) {
return ''; return undefined;
} }
if (panelType === PanelType.GroupInvites) { if (panelType === PanelType.GroupInvites) {
@@ -52,7 +52,7 @@ export function getConversationTitleForPanelType(
} }
if (panelType === PanelType.StickerManager) { if (panelType === PanelType.StickerManager) {
return ''; return undefined;
} }
if ( if (

View File

@@ -1912,35 +1912,10 @@
}, },
{ {
"rule": "React-useRef", "rule": "React-useRef",
"path": "ts/components/conversation/media-gallery/MediaGallery.dom.tsx", "path": "ts/components/conversation/pinned-messages/PinnedMessagesPanel.dom.tsx",
"line": " const loadingRef = useRef<boolean>(false);", "line": " const containerElementRef = useRef<HTMLDivElement>(null);",
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2024-09-03T00:45:23.978Z", "updated": "2025-11-20T18:33:59.075Z"
"reasonDetail": "A boolean to help us avoid making too many 'load more' requests"
},
{
"rule": "React-useRef",
"path": "ts/components/conversation/media-gallery/MediaGallery.dom.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.dom.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.dom.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", "rule": "React-useRef",
@@ -1950,13 +1925,6 @@
"updated": "2025-11-06T20:28:00.760Z", "updated": "2025-11-06T20:28:00.760Z",
"reasonDetail": "Ref for timer" "reasonDetail": "Ref for timer"
}, },
{
"rule": "React-useRef",
"path": "ts/components/conversation/pinned-messages/PinnedMessagesPanel.dom.tsx",
"line": " const containerElementRef = useRef<HTMLDivElement>(null);",
"reasonCategory": "usageTrusted",
"updated": "2025-11-20T18:33:59.075Z"
},
{ {
"rule": "React-useRef", "rule": "React-useRef",
"path": "ts/components/fun/FunGif.dom.tsx", "path": "ts/components/fun/FunGif.dom.tsx",