mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-20 02:08:57 +00:00
Update tabs UI in MediaGallery
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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" />;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
if (!observerEntry?.isIntersecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
intersectionObserver.current = new IntersectionObserver(
|
||||
(entries: ReadonlyArray<IntersectionObserverEntry>) => {
|
||||
if (loadingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = entries.find(
|
||||
item => item.target === scrollObserverRef.current
|
||||
);
|
||||
|
||||
if (entry && entry.intersectionRatio > 0) {
|
||||
if (tabViewRef.current === TabViews.Media) {
|
||||
if (!haveOldestMedia) {
|
||||
loadMore(conversationId, 'media');
|
||||
loadingRef.current = true;
|
||||
}
|
||||
} else if (tabViewRef.current === TabViews.Audio) {
|
||||
if (!haveOldestAudio) {
|
||||
loadMore(conversationId, 'audio');
|
||||
loadingRef.current = true;
|
||||
}
|
||||
} else if (tabViewRef.current === TabViews.Documents) {
|
||||
if (!haveOldestDocument) {
|
||||
loadMore(conversationId, 'documents');
|
||||
loadingRef.current = true;
|
||||
}
|
||||
} else if (tabViewRef.current === TabViews.Links) {
|
||||
if (!haveOldestLink) {
|
||||
loadMore(conversationId, 'links');
|
||||
loadingRef.current = true;
|
||||
}
|
||||
} else {
|
||||
throw missingCaseError(tabViewRef.current);
|
||||
}
|
||||
}
|
||||
if (tab === 'media') {
|
||||
if (haveOldestMedia) {
|
||||
return;
|
||||
}
|
||||
);
|
||||
intersectionObserver.current.observe(scrollObserverRef.current);
|
||||
|
||||
return () => {
|
||||
intersectionObserver.current?.disconnect();
|
||||
intersectionObserver.current = null;
|
||||
};
|
||||
loadMore(conversationId, 'media');
|
||||
} else if (tab === 'audio') {
|
||||
if (haveOldestAudio) {
|
||||
return;
|
||||
}
|
||||
loadMore(conversationId, 'audio');
|
||||
} else if (tab === 'documents') {
|
||||
if (haveOldestDocument) {
|
||||
return;
|
||||
}
|
||||
loadMore(conversationId, 'documents');
|
||||
} else if (tab === 'links') {
|
||||
if (haveOldestLink) {
|
||||
return;
|
||||
}
|
||||
loadMore(conversationId, 'links');
|
||||
} else {
|
||||
throw missingCaseError(tab);
|
||||
}
|
||||
setLoading(true);
|
||||
}, [
|
||||
observerEntry,
|
||||
conversationId,
|
||||
haveOldestDocument,
|
||||
haveOldestMedia,
|
||||
@@ -310,82 +294,44 @@ 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'
|
||||
)}
|
||||
>
|
||||
<MediaSection
|
||||
i18n={i18n}
|
||||
loading={loading}
|
||||
tab={tabViewRef.current}
|
||||
mediaItems={mediaItems}
|
||||
saveAttachment={saveAttachment}
|
||||
showLightbox={showLightbox}
|
||||
kickOffAttachmentDownload={kickOffAttachmentDownload}
|
||||
cancelAttachmentDownload={cancelAttachmentDownload}
|
||||
playAudio={playAudio}
|
||||
renderMediaItem={renderMediaItem}
|
||||
/>
|
||||
<div ref={scrollObserverRef} className={tw('h-px')} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Tabs>
|
||||
<div className={tw('grow overflow-y-auto')}>
|
||||
<MediaSection
|
||||
i18n={i18n}
|
||||
loading={loading}
|
||||
tab={tab}
|
||||
mediaItems={mediaItems}
|
||||
saveAttachment={saveAttachment}
|
||||
showLightbox={showLightbox}
|
||||
kickOffAttachmentDownload={kickOffAttachmentDownload}
|
||||
cancelAttachmentDownload={cancelAttachmentDownload}
|
||||
playAudio={playAudio}
|
||||
renderMediaItem={renderMediaItem}
|
||||
/>
|
||||
<div ref={setObserverRef} className={tw('h-px')} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
112
ts/components/conversation/media-gallery/PanelHeader.dom.tsx
Normal file
112
ts/components/conversation/media-gallery/PanelHeader.dom.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
16
ts/state/smart/AllMediaHeader.preload.tsx
Normal file
16
ts/state/smart/AllMediaHeader.preload.tsx
Normal 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} />;
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user