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) {
case 'media':
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>
<div
className={tw(

View File

@@ -5,7 +5,6 @@ import * as React from 'react';
import type { Meta } from '@storybook/react';
import type { Props } from './EmptyState.dom.js';
import { EmptyState } from './EmptyState.dom.js';
import { TabViews } from './types/TabViews.std.js';
const { i18n } = window.SignalContext;
@@ -14,12 +13,12 @@ export default {
argTypes: {
tab: {
control: { type: 'select' },
options: [TabViews.Media, TabViews.Documents, TabViews.Links],
options: ['media', 'audio', 'links', 'documents'],
},
},
args: {
i18n,
tab: TabViews.Media,
tab: 'media',
},
} satisfies Meta<Props>;
@@ -28,13 +27,17 @@ export function Default(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 {
return <EmptyState {...args} tab={TabViews.Documents} />;
export function Audio(args: Props): JSX.Element {
return <EmptyState {...args} tab="audio" />;
}
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 type { LocalizerType } from '../../../types/Util.std.js';
import type { MediaTabType } from '../../../types/MediaItem.std.js';
import { tw } from '../../../axo/tw.dom.js';
import { missingCaseError } from '../../../util/missingCaseError.std.js';
import { TabViews } from './types/TabViews.std.js';
export type Props = {
i18n: LocalizerType;
tab: TabViews;
tab: MediaTabType;
};
export function EmptyState({ i18n, tab }: Props): JSX.Element {
@@ -18,21 +18,21 @@ export function EmptyState({ i18n, tab }: Props): JSX.Element {
let description: string;
switch (tab) {
case TabViews.Media:
case 'media':
title = i18n('icu:MediaGallery__EmptyState__title--media');
description = i18n('icu:MediaGallery__EmptyState__description--media');
break;
case TabViews.Audio:
case 'audio':
title = i18n('icu:MediaGallery__EmptyState__title--audio');
description = i18n('icu:MediaGallery__EmptyState__description--audio');
break;
case TabViews.Documents:
case 'documents':
title = i18n('icu:MediaGallery__EmptyState__title--documents');
description = i18n(
'icu:MediaGallery__EmptyState__description--documents-2'
);
break;
case TabViews.Links:
case 'links':
title = i18n('icu:MediaGallery__EmptyState__title--links');
description = i18n('icu:MediaGallery__EmptyState__description--links-2');
break;

View File

@@ -1,11 +1,13 @@
// Copyright 2020 Signal Messenger, LLC
// 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 type { Meta } from '@storybook/react';
import type { MediaTabType } from '../../../types/MediaItem.std.js';
import type { Props } from './MediaGallery.dom.js';
import { MediaGallery } from './MediaGallery.dom.js';
import { PanelHeader } from './PanelHeader.dom.js';
import {
createPreparedMediaItems,
createRandomDocuments,
@@ -36,6 +38,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
audio: overrideProps.audio || [],
links: overrideProps.links || [],
documents: overrideProps.documents || [],
tab: overrideProps.tab || 'media',
initialLoad: action('initialLoad'),
loadMore: action('loadMore'),
@@ -46,29 +49,59 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
cancelAttachmentDownload: action('cancelAttachmentDownload'),
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 {
const documents = createRandomDocuments(Date.now() - days(5), days(5));
const media = createPreparedMediaItems(createRandomMedia);
const props = createProps({ documents, media });
return <MediaGallery {...props} />;
return <Panel {...props} />;
}
export function NoDocuments(): JSX.Element {
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 {
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 {
@@ -79,11 +112,16 @@ export function OneEach(): JSX.Element {
const props = createProps({ documents, audio, media, links });
return <MediaGallery {...props} />;
return <Panel {...props} />;
}
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
// 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 type { ItemClickEvent } from './types/ItemClickEvent.std.js';
import type { LocalizerType } from '../../../types/Util.std.js';
import type {
MediaTabType,
LinkPreviewMediaItemType,
MediaItemType,
GenericMediaItemType,
@@ -15,12 +22,10 @@ import type {
import type { SaveAttachmentActionCreatorType } from '../../../state/ducks/conversations.preload.js';
import { AttachmentSection } from './AttachmentSection.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 { missingCaseError } from '../../../util/missingCaseError.std.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 { tw } from '../../../axo/tw.dom.js';
@@ -33,14 +38,12 @@ export type Props = {
haveOldestDocument: boolean;
loading: boolean;
initialLoad: (id: string) => unknown;
loadMore: (
id: string,
type: 'media' | 'audio' | 'documents' | 'links'
) => unknown;
loadMore: (id: string, type: MediaTabType) => unknown;
media: ReadonlyArray<MediaItemType>;
audio: ReadonlyArray<MediaItemType>;
documents: ReadonlyArray<MediaItemType>;
links: ReadonlyArray<LinkPreviewMediaItemType>;
documents: ReadonlyArray<MediaItemType>;
tab: MediaTabType;
saveAttachment: SaveAttachmentActionCreatorType;
kickOffAttachmentDownload: (options: { messageId: string }) => void;
cancelAttachmentDownload: (options: { messageId: string }) => void;
@@ -50,7 +53,6 @@ export type Props = {
messageId: string;
}) => void;
renderMiniPlayer: () => JSX.Element;
renderMediaItem: (props: {
onItemClick: (event: ItemClickEvent) => unknown;
mediaItem: GenericMediaItemType;
@@ -81,7 +83,7 @@ function MediaSection({
| 'playAudio'
| 'renderMediaItem'
> & {
tab: TabViews;
tab: MediaTabType;
mediaItems: ReadonlyArray<GenericMediaItemType>;
}): JSX.Element {
const onItemClick = useCallback(
@@ -194,25 +196,29 @@ export function MediaGallery({
haveOldestDocument,
i18n,
initialLoad,
loading,
loading: reduxLoading,
loadMore,
media,
audio,
links,
documents,
tab,
saveAttachment,
kickOffAttachmentDownload,
cancelAttachmentDownload,
playAudio,
showLightbox,
renderMediaItem,
renderMiniPlayer,
}: 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);
const [loading, setLoading] = useState(reduxLoading);
// Reset local state when redux finishes loading
useEffect(() => {
if (reduxLoading === false) {
setLoading(false);
}
}, [reduxLoading]);
useEffect(() => {
focusRef.current?.focus();
@@ -232,7 +238,6 @@ export function MediaGallery({
return;
}
initialLoad(conversationId);
loadingRef.current = true;
}, [
conversationId,
haveOldestMedia,
@@ -246,63 +251,42 @@ export function MediaGallery({
documents.length,
]);
const previousLoading = usePrevious(loading, loading);
if (previousLoading && !loading) {
loadingRef.current = false;
}
const [setObserverRef, observerEntry] = useIntersectionObserver();
useEffect(() => {
if (loading || !scrollObserverRef.current) {
if (loading) {
return;
}
intersectionObserver.current?.disconnect();
intersectionObserver.current = null;
intersectionObserver.current = new IntersectionObserver(
(entries: ReadonlyArray<IntersectionObserverEntry>) => {
if (loadingRef.current) {
if (!observerEntry?.isIntersecting) {
return;
}
const entry = entries.find(
item => item.target === scrollObserverRef.current
);
if (entry && entry.intersectionRatio > 0) {
if (tabViewRef.current === TabViews.Media) {
if (!haveOldestMedia) {
if (tab === 'media') {
if (haveOldestMedia) {
return;
}
loadMore(conversationId, 'media');
loadingRef.current = true;
} else if (tab === 'audio') {
if (haveOldestAudio) {
return;
}
} else if (tabViewRef.current === TabViews.Audio) {
if (!haveOldestAudio) {
loadMore(conversationId, 'audio');
loadingRef.current = true;
} else if (tab === 'documents') {
if (haveOldestDocument) {
return;
}
} else if (tabViewRef.current === TabViews.Documents) {
if (!haveOldestDocument) {
loadMore(conversationId, 'documents');
loadingRef.current = true;
} else if (tab === 'links') {
if (haveOldestLink) {
return;
}
} else if (tabViewRef.current === TabViews.Links) {
if (!haveOldestLink) {
loadMore(conversationId, 'links');
loadingRef.current = true;
}
} else {
throw missingCaseError(tabViewRef.current);
throw missingCaseError(tab);
}
}
}
);
intersectionObserver.current.observe(scrollObserverRef.current);
return () => {
intersectionObserver.current?.disconnect();
intersectionObserver.current = null;
};
setLoading(true);
}, [
observerEntry,
conversationId,
haveOldestDocument,
haveOldestMedia,
@@ -310,68 +294,34 @@ export function MediaGallery({
haveOldestLink,
loading,
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 (
<div
className={tw('flex size-full grow flex-col outline-none')}
tabIndex={-1}
ref={focusRef}
>
<Tabs
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'
)}
>
<div className={tw('grow overflow-y-auto')}>
<MediaSection
i18n={i18n}
loading={loading}
tab={tabViewRef.current}
tab={tab}
mediaItems={mediaItems}
saveAttachment={saveAttachment}
showLightbox={showLightbox}
@@ -380,12 +330,8 @@ export function MediaGallery({
playAudio={playAudio}
renderMediaItem={renderMediaItem}
/>
<div ref={scrollObserverRef} className={tw('h-px')} />
<div ref={setObserverRef} className={tw('h-px')} />
</div>
</>
);
}}
</Tabs>
</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,
} from './conversations.preload.js';
import type {
MediaTabType,
MediaItemMessageType,
MediaItemType,
LinkPreviewMediaItemType,
@@ -47,16 +48,17 @@ const { orderBy } = lodash;
const log = createLogger('mediaGallery');
export type MediaGalleryStateType = ReadonlyDeep<{
tab: MediaTabType;
conversationId: string | undefined;
haveOldestDocument: boolean;
haveOldestMedia: boolean;
haveOldestAudio: boolean;
haveOldestLink: boolean;
haveOldestDocument: boolean;
loading: boolean;
media: ReadonlyArray<MediaItemType>;
audio: ReadonlyArray<MediaItemType>;
documents: ReadonlyArray<MediaItemType>;
links: ReadonlyArray<LinkPreviewMediaItemType>;
documents: ReadonlyArray<MediaItemType>;
}>;
const FETCH_CHUNK_COUNT = 50;
@@ -64,15 +66,16 @@ const FETCH_CHUNK_COUNT = 50;
const INITIAL_LOAD = 'mediaGallery/INITIAL_LOAD';
const LOAD_MORE = 'mediaGallery/LOAD_MORE';
const SET_LOADING = 'mediaGallery/SET_LOADING';
const SET_TAB = 'mediaGallery/SET_TAB';
type InitialLoadActionType = ReadonlyDeep<{
type: typeof INITIAL_LOAD;
payload: {
conversationId: string;
documents: ReadonlyArray<MediaItemType>;
media: ReadonlyArray<MediaItemType>;
audio: ReadonlyArray<MediaItemType>;
links: ReadonlyArray<LinkPreviewMediaItemType>;
documents: ReadonlyArray<MediaItemType>;
};
}>;
type LoadMoreActionType = ReadonlyDeep<{
@@ -81,8 +84,8 @@ type LoadMoreActionType = ReadonlyDeep<{
conversationId: string;
media: ReadonlyArray<MediaItemType>;
audio: ReadonlyArray<MediaItemType>;
documents: ReadonlyArray<MediaItemType>;
links: ReadonlyArray<LinkPreviewMediaItemType>;
documents: ReadonlyArray<MediaItemType>;
};
}>;
type SetLoadingActionType = ReadonlyDeep<{
@@ -91,6 +94,12 @@ type SetLoadingActionType = ReadonlyDeep<{
loading: boolean;
};
}>;
type SetTabActionType = ReadonlyDeep<{
type: typeof SET_TAB;
payload: {
tab: MediaGalleryStateType['tab'];
};
}>;
type MediaGalleryActionType = ReadonlyDeep<
| ConversationUnloadedActionType
@@ -100,6 +109,7 @@ type MediaGalleryActionType = ReadonlyDeep<
| MessageDeletedActionType
| MessageExpiredActionType
| SetLoadingActionType
| SetTabActionType
>;
function _sortItems<
@@ -223,7 +233,7 @@ function initialLoad(
function loadMore(
conversationId: string,
type: 'media' | 'audio' | 'documents' | 'links'
type: MediaTabType
): ThunkAction<
void,
RootStateType,
@@ -316,9 +326,19 @@ function loadMore(
};
}
function setTab(tab: MediaGalleryStateType['tab']): SetTabActionType {
return {
type: SET_TAB,
payload: {
tab,
},
};
}
export const actions = {
initialLoad,
loadMore,
setTab,
};
export const useMediaGalleryActions = (): BoundActionCreatorsMapObject<
@@ -327,6 +347,7 @@ export const useMediaGalleryActions = (): BoundActionCreatorsMapObject<
export function getEmptyState(): MediaGalleryStateType {
return {
tab: 'media',
conversationId: undefined,
haveOldestDocument: false,
haveOldestMedia: false,
@@ -357,7 +378,7 @@ export function reducer(
const { payload } = action;
return {
...state,
tab: 'media',
loading: false,
conversationId: payload.conversationId,
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.
// When a message changes, check that the changed message falls within this time range,
// 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 { isSignalConversation } from '../../util/isSignalConversation.dom.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 { HasStories } from '../../types/Stories.std.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.
export const getLastEditableMessageId = createSelector(
getConversationMessages,

View File

@@ -17,7 +17,6 @@ import {
MediaItem,
type PropsType as MediaItemPropsType,
} from './MediaItem.preload.js';
import { SmartMiniPlayer } from './MiniPlayer.preload.js';
const log = createLogger('AllMedia');
@@ -25,10 +24,6 @@ export type PropsType = {
conversationId: string;
};
function renderMiniPlayer(): JSX.Element {
return <SmartMiniPlayer shouldFlow />;
}
function renderMediaItem(props: MediaItemPropsType): JSX.Element {
return <MediaItem {...props} />;
}
@@ -46,6 +41,7 @@ export const SmartAllMedia = memo(function SmartAllMedia({
haveOldestLink,
haveOldestDocument,
loading,
tab,
} = useSelector(getMediaGalleryState);
const { initialLoad, loadMore } = useMediaGalleryActions();
const {
@@ -109,13 +105,13 @@ export const SmartAllMedia = memo(function SmartAllMedia({
audio={audio}
links={links}
documents={documents}
tab={tab}
showLightbox={showLightbox}
playAudio={playAudio}
kickOffAttachmentDownload={kickOffAttachmentDownload}
cancelAttachmentDownload={cancelAttachmentDownload}
saveAttachment={saveAttachment}
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 { toLogFormat } from '../../types/errors.std.js';
import { SmartAllMedia } from './AllMedia.preload.js';
import { SmartAllMediaHeader } from './AllMediaHeader.preload.js';
import { SmartChatColorPicker } from './ChatColorPicker.preload.js';
import { SmartContactDetail } from './ContactDetail.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 { itemStorage } from '../../textsecure/Storage.preload.js';
import { SmartPinnedMessagesPanel } from './PinnedMessagesPanel.preload.js';
import { SmartMiniPlayer } from './MiniPlayer.preload.js';
const log = createLogger('ConversationPanel');
@@ -288,6 +290,19 @@ const PanelContainer = forwardRef<
const { popPanelForConversation } = useConversationsActions();
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);
useEffect(() => {
if (!isActive) {
@@ -315,18 +330,14 @@ const PanelContainer = forwardRef<
onClick={popPanelForConversation}
type="button"
/>
{conversationTitle && (
<div className="ConversationPanel__header__info">
<div className="ConversationPanel__header__info__title">
{conversationTitle}
</div>
</div>
)}
{info}
</div>
<SmartMiniPlayer shouldFlow />
<div
className={classNames(
'ConversationPanel__body',
panel.type !== PanelType.PinnedMessages &&
panel.type !== PanelType.AllMedia &&
'ConversationPanel__body--padding'
)}
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 { ServiceIdString } from './ServiceId.std.js';
export type MediaTabType = 'media' | 'audio' | 'links' | 'documents';
export type MediaItemMessageType = Readonly<{
id: string;
type: MessageAttributesType['type'];

View File

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

View File

@@ -1912,35 +1912,10 @@
},
{
"rule": "React-useRef",
"path": "ts/components/conversation/media-gallery/MediaGallery.dom.tsx",
"line": " const loadingRef = useRef<boolean>(false);",
"path": "ts/components/conversation/pinned-messages/PinnedMessagesPanel.dom.tsx",
"line": " const containerElementRef = useRef<HTMLDivElement>(null);",
"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.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"
"updated": "2025-11-20T18:33:59.075Z"
},
{
"rule": "React-useRef",
@@ -1950,13 +1925,6 @@
"updated": "2025-11-06T20:28:00.760Z",
"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",
"path": "ts/components/fun/FunGif.dom.tsx",