mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-24 20:26:24 +00:00
Link previews in all media
Co-authored-by: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com>
This commit is contained in:
@@ -6001,7 +6001,7 @@
|
||||
"description": "This is the number of members in a group"
|
||||
},
|
||||
"icu:ConversationDetailsMediaList--title": {
|
||||
"messageformat": "Media and files",
|
||||
"messageformat": "Media, links, and files",
|
||||
"description": "Title for the show all media button in the conversation details screen"
|
||||
},
|
||||
"icu:ConversationDetailsMembershipList--title": {
|
||||
@@ -6660,6 +6660,42 @@
|
||||
"messageformat": "Add group description...",
|
||||
"description": "Placeholder text in the details header for those that can edit the group description"
|
||||
},
|
||||
"icu:LinkPreviewItem__alt": {
|
||||
"messageformat": "Open the link in a browser",
|
||||
"description": "Alt text for the link preview item button"
|
||||
},
|
||||
"icu:MediaGallery__tab__files": {
|
||||
"messageformat": "Files",
|
||||
"description": "Header of the links pane in the media gallery, showing files"
|
||||
},
|
||||
"icu:MediaGallery__tab__links": {
|
||||
"messageformat": "Links",
|
||||
"description": "Header of the links pane in the media gallery, showing links"
|
||||
},
|
||||
"icu:MediaGallery__EmptyState__title--media": {
|
||||
"messageformat": "No Media",
|
||||
"description": "Title of the empty state view of media gallery for media tab"
|
||||
},
|
||||
"icu:MediaGallery__EmptyState__description--media": {
|
||||
"messageformat": "Photos, Videos, and GIFs that you send and receive will appear here",
|
||||
"description": "Description of the empty state view of media gallery for media tab"
|
||||
},
|
||||
"icu:MediaGallery__EmptyState__title--links": {
|
||||
"messageformat": "No Links",
|
||||
"description": "Title of the empty state view of media gallery for links tab"
|
||||
},
|
||||
"icu:MediaGallery__EmptyState__description--documents": {
|
||||
"messageformat": "Links that you send and receive will appear here",
|
||||
"description": "Description of the empty state view of media gallery for links tab"
|
||||
},
|
||||
"icu:MediaGallery__EmptyState__title--documents": {
|
||||
"messageformat": "No Files",
|
||||
"description": "Title of the empty state view of media gallery for files tab"
|
||||
},
|
||||
"icu:MediaGallery__EmptyState__description--links": {
|
||||
"messageformat": "Files that you send and receive will appear here",
|
||||
"description": "Description of the empty state view of media gallery for files tab"
|
||||
},
|
||||
"icu:MediaQualitySelector--button": {
|
||||
"messageformat": "Select media quality",
|
||||
"description": "aria-label for the media quality selector button"
|
||||
|
||||
@@ -163,13 +163,15 @@ button.grey {
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
@include mixins.light-theme {
|
||||
color: variables.$color-ultramarine;
|
||||
}
|
||||
@layer base {
|
||||
a {
|
||||
@include mixins.light-theme {
|
||||
color: variables.$color-ultramarine;
|
||||
}
|
||||
|
||||
@include mixins.dark-theme {
|
||||
color: variables.$color-gray-05;
|
||||
@include mixins.dark-theme {
|
||||
color: variables.$color-gray-05;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2467,7 +2467,6 @@ button.ConversationDetails__action-button {
|
||||
// Module: Media Gallery
|
||||
|
||||
.module-media-gallery {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
@@ -2505,19 +2504,6 @@ button.ConversationDetails__action-button {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Module: Empty State*/
|
||||
|
||||
.module-empty-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@include mixins.font-title-1;
|
||||
|
||||
color: variables.$color-gray-45;
|
||||
}
|
||||
|
||||
// Module: Message Request Actions
|
||||
|
||||
.module-message-request-actions {
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
@use '../variables';
|
||||
|
||||
.ConversationPanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
height: 100%;
|
||||
inset-inline-start: 0;
|
||||
overflow-y: auto;
|
||||
@@ -22,13 +25,18 @@
|
||||
}
|
||||
|
||||
&__body {
|
||||
margin-top: calc(
|
||||
// Used for centering EmptyState in All Media view
|
||||
position: relative;
|
||||
|
||||
flex-grow: 1;
|
||||
padding-top: calc(
|
||||
#{variables.$header-height} + var(--title-bar-drag-area-height)
|
||||
);
|
||||
padding-inline: 24px;
|
||||
}
|
||||
|
||||
&__header {
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
@@ -45,6 +45,7 @@ function createMediaItem(
|
||||
fileName: overrideProps.objectURL,
|
||||
url: overrideProps.objectURL,
|
||||
}),
|
||||
type: 'media',
|
||||
index: 0,
|
||||
message: {
|
||||
conversationId: '1234',
|
||||
@@ -53,6 +54,10 @@ function createMediaItem(
|
||||
receivedAt: 0,
|
||||
receivedAtMs: Date.now(),
|
||||
sentAt: Date.now(),
|
||||
|
||||
// Unused for now
|
||||
source: undefined,
|
||||
sourceServiceId: undefined,
|
||||
},
|
||||
...overrideProps,
|
||||
};
|
||||
@@ -86,6 +91,7 @@ export function Multimedia(): JSX.Element {
|
||||
const props = createProps({
|
||||
media: [
|
||||
{
|
||||
type: 'media',
|
||||
attachment: fakeAttachment({
|
||||
contentType: IMAGE_JPEG,
|
||||
fileName: 'tina-rolf-269345-unsplash.jpg',
|
||||
@@ -101,9 +107,13 @@ export function Multimedia(): JSX.Element {
|
||||
receivedAt: 1,
|
||||
receivedAtMs: Date.now(),
|
||||
sentAt: Date.now(),
|
||||
// Unused for now
|
||||
source: undefined,
|
||||
sourceServiceId: undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'media',
|
||||
attachment: fakeAttachment({
|
||||
contentType: VIDEO_MP4,
|
||||
fileName: 'pixabay-Soap-Bubble-7141.mp4',
|
||||
@@ -117,6 +127,9 @@ export function Multimedia(): JSX.Element {
|
||||
receivedAt: 2,
|
||||
receivedAtMs: Date.now(),
|
||||
sentAt: Date.now(),
|
||||
// Unused for now
|
||||
source: undefined,
|
||||
sourceServiceId: undefined,
|
||||
},
|
||||
},
|
||||
createMediaItem({
|
||||
@@ -139,6 +152,7 @@ export function MissingMedia(): JSX.Element {
|
||||
const props = createProps({
|
||||
media: [
|
||||
{
|
||||
type: 'media',
|
||||
attachment: fakeAttachment({
|
||||
contentType: IMAGE_JPEG,
|
||||
fileName: 'tina-rolf-269345-unsplash.jpg',
|
||||
@@ -152,6 +166,10 @@ export function MissingMedia(): JSX.Element {
|
||||
receivedAt: 3,
|
||||
receivedAtMs: Date.now(),
|
||||
sentAt: Date.now(),
|
||||
|
||||
// Unused for now
|
||||
source: undefined,
|
||||
sourceServiceId: undefined,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -8,6 +8,7 @@ import { action } from '@storybook/addon-actions';
|
||||
import type { Meta } from '@storybook/react';
|
||||
import type { Props } from './AttachmentSection.dom.js';
|
||||
import { AttachmentSection } from './AttachmentSection.dom.js';
|
||||
import { LinkPreviewItem } from './LinkPreviewItem.dom.js';
|
||||
import {
|
||||
createRandomDocuments,
|
||||
createRandomMedia,
|
||||
@@ -21,30 +22,31 @@ export default {
|
||||
component: AttachmentSection,
|
||||
argTypes: {
|
||||
header: { control: { type: 'text' } },
|
||||
type: {
|
||||
control: {
|
||||
type: 'select',
|
||||
options: ['media', 'documents'],
|
||||
},
|
||||
},
|
||||
},
|
||||
args: {
|
||||
i18n,
|
||||
header: 'Today',
|
||||
type: 'media',
|
||||
mediaItems: [],
|
||||
renderLinkPreviewItem: ({ mediaItem, onClick }) => {
|
||||
return (
|
||||
<LinkPreviewItem
|
||||
i18n={i18n}
|
||||
authorTitle="Alice"
|
||||
mediaItem={mediaItem}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
},
|
||||
onItemClick: action('onItemClick'),
|
||||
},
|
||||
} satisfies Meta<Props>;
|
||||
|
||||
export function Documents(args: Props) {
|
||||
const mediaItems = createRandomDocuments(Date.now(), days(1));
|
||||
return (
|
||||
<AttachmentSection {...args} type="documents" mediaItems={mediaItems} />
|
||||
);
|
||||
return <AttachmentSection {...args} mediaItems={mediaItems} />;
|
||||
}
|
||||
|
||||
export function Media(args: Props) {
|
||||
const mediaItems = createRandomMedia(Date.now(), days(1));
|
||||
return <AttachmentSection {...args} type="media" mediaItems={mediaItems} />;
|
||||
return <AttachmentSection {...args} mediaItems={mediaItems} />;
|
||||
}
|
||||
|
||||
@@ -1,50 +1,96 @@
|
||||
// Copyright 2018 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import React, { Fragment } from 'react';
|
||||
|
||||
import type { ItemClickEvent } from './types/ItemClickEvent.std.js';
|
||||
import type { LocalizerType, ThemeType } from '../../../types/Util.std.js';
|
||||
import type { MediaItemType } from '../../../types/MediaItem.std.js';
|
||||
import { DocumentListItem } from './DocumentListItem.dom.js';
|
||||
import type {
|
||||
GenericMediaItemType,
|
||||
MediaItemType,
|
||||
LinkPreviewMediaItemType,
|
||||
} from '../../../types/MediaItem.std.js';
|
||||
import { MediaGridItem } from './MediaGridItem.dom.js';
|
||||
import { DocumentListItem } from './DocumentListItem.dom.js';
|
||||
import type { DataProps as LinkPreviewItemPropsType } from './LinkPreviewItem.dom.js';
|
||||
import type { AttachmentStatusType } from '../../../hooks/useAttachmentStatus.std.js';
|
||||
import { missingCaseError } from '../../../util/missingCaseError.std.js';
|
||||
import { strictAssert } from '../../../util/assert.std.js';
|
||||
import { tw } from '../../../axo/tw.dom.js';
|
||||
|
||||
export type Props = {
|
||||
header?: string;
|
||||
i18n: LocalizerType;
|
||||
mediaItems: ReadonlyArray<MediaItemType>;
|
||||
onItemClick: (event: ItemClickEvent) => unknown;
|
||||
type: 'media' | 'documents';
|
||||
theme?: ThemeType;
|
||||
mediaItems: ReadonlyArray<GenericMediaItemType>;
|
||||
|
||||
renderLinkPreviewItem: (props: LinkPreviewItemPropsType) => JSX.Element;
|
||||
};
|
||||
|
||||
function getMediaItemKey(mediaItem: GenericMediaItemType): string {
|
||||
const { message } = mediaItem;
|
||||
if (mediaItem.type === 'media' || mediaItem.type === 'document') {
|
||||
return `attachment-${message.id}-${mediaItem.index}`;
|
||||
}
|
||||
return `attachment-${message.id}-preview`;
|
||||
}
|
||||
|
||||
type VerifiedMediaItems =
|
||||
| {
|
||||
type: 'media' | 'document';
|
||||
entries: ReadonlyArray<MediaItemType>;
|
||||
}
|
||||
| {
|
||||
type: 'link';
|
||||
entries: ReadonlyArray<LinkPreviewMediaItemType>;
|
||||
};
|
||||
|
||||
function verifyMediaItems(
|
||||
mediaItems: ReadonlyArray<GenericMediaItemType>
|
||||
): VerifiedMediaItems {
|
||||
const first = mediaItems.at(0);
|
||||
strictAssert(first != null, 'AttachmentSection cannot be empty');
|
||||
|
||||
const { type } = first;
|
||||
|
||||
const result = {
|
||||
type,
|
||||
entries: mediaItems.filter(item => item.type === type),
|
||||
};
|
||||
|
||||
strictAssert(
|
||||
result.entries.length === mediaItems.length,
|
||||
'Some AttachmentSection items have conflicting types'
|
||||
);
|
||||
|
||||
return result as VerifiedMediaItems;
|
||||
}
|
||||
|
||||
export function AttachmentSection({
|
||||
i18n,
|
||||
header,
|
||||
type,
|
||||
mediaItems,
|
||||
onItemClick,
|
||||
theme,
|
||||
|
||||
renderLinkPreviewItem,
|
||||
}: Props): JSX.Element {
|
||||
switch (type) {
|
||||
const verified = verifyMediaItems(mediaItems);
|
||||
switch (verified.type) {
|
||||
case 'media':
|
||||
return (
|
||||
<section className={tw('ps-5')}>
|
||||
<h2 className={tw('ps-1 pt-4 pb-2 font-semibold')}>{header}</h2>
|
||||
<div className={tw('flex flex-row flex-wrap gap-1 pb-1')}>
|
||||
{mediaItems.map(mediaItem => {
|
||||
const { message, index, attachment } = mediaItem;
|
||||
|
||||
{verified.entries.map(mediaItem => {
|
||||
const onClick = (state: AttachmentStatusType['state']) => {
|
||||
onItemClick({ type, message, attachment, state });
|
||||
onItemClick({ mediaItem, state });
|
||||
};
|
||||
|
||||
return (
|
||||
<MediaGridItem
|
||||
key={`${message.id}-${index}`}
|
||||
key={getMediaItemKey(mediaItem)}
|
||||
mediaItem={mediaItem}
|
||||
onClick={onClick}
|
||||
i18n={i18n}
|
||||
@@ -55,7 +101,7 @@ export function AttachmentSection({
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
case 'documents':
|
||||
case 'document':
|
||||
return (
|
||||
<section
|
||||
className={tw(
|
||||
@@ -66,17 +112,15 @@ export function AttachmentSection({
|
||||
>
|
||||
<h2 className={tw('pt-1.5 pb-2 font-semibold')}>{header}</h2>
|
||||
<div>
|
||||
{mediaItems.map(mediaItem => {
|
||||
const { message, index, attachment } = mediaItem;
|
||||
|
||||
{verified.entries.map(mediaItem => {
|
||||
const onClick = (state: AttachmentStatusType['state']) => {
|
||||
onItemClick({ type, message, attachment, state });
|
||||
onItemClick({ mediaItem, state });
|
||||
};
|
||||
|
||||
return (
|
||||
<DocumentListItem
|
||||
i18n={i18n}
|
||||
key={`${message.id}-${index}`}
|
||||
key={getMediaItemKey(mediaItem)}
|
||||
mediaItem={mediaItem}
|
||||
onClick={onClick}
|
||||
/>
|
||||
@@ -85,7 +129,31 @@ export function AttachmentSection({
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
case 'link':
|
||||
return (
|
||||
<section
|
||||
className={tw('px-6', 'mb-3 divide-y border-b-border-primary pb-3')}
|
||||
>
|
||||
<h2 className={tw('pt-1.5 pb-2 font-semibold')}>{header}</h2>
|
||||
<div>
|
||||
{verified.entries.map(mediaItem => {
|
||||
const onClick = (state: AttachmentStatusType['state']) => {
|
||||
onItemClick({ mediaItem, state });
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment key={getMediaItemKey(mediaItem)}>
|
||||
{renderLinkPreviewItem({
|
||||
mediaItem,
|
||||
onClick,
|
||||
})}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
default:
|
||||
throw missingCaseError(type);
|
||||
throw missingCaseError(verified);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,11 @@ export function DocumentListItem({
|
||||
value={status.totalDownloaded}
|
||||
/>
|
||||
)}
|
||||
<div className={tw('absolute text-label-primary')}>
|
||||
<div
|
||||
className={tw(
|
||||
'absolute flex items-center justify-center text-label-primary'
|
||||
)}
|
||||
>
|
||||
<AxoSymbol.Icon
|
||||
symbol={status.state === 'Downloading' ? 'x' : 'arrow-down'}
|
||||
size={16}
|
||||
|
||||
@@ -5,17 +5,36 @@ 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;
|
||||
|
||||
export default {
|
||||
title: 'Components/Conversation/MediaGallery/EmptyState',
|
||||
argTypes: {
|
||||
label: { control: { type: 'text' } },
|
||||
tab: {
|
||||
control: { type: 'select' },
|
||||
options: [TabViews.Media, TabViews.Documents, TabViews.Links],
|
||||
},
|
||||
},
|
||||
args: {
|
||||
label: 'placeholder text',
|
||||
i18n,
|
||||
tab: TabViews.Media,
|
||||
},
|
||||
} satisfies Meta<Props>;
|
||||
|
||||
export function Default(args: Props): JSX.Element {
|
||||
return <EmptyState {...args} />;
|
||||
}
|
||||
|
||||
export function Media(args: Props): JSX.Element {
|
||||
return <EmptyState {...args} tab={TabViews.Media} />;
|
||||
}
|
||||
|
||||
export function Documents(args: Props): JSX.Element {
|
||||
return <EmptyState {...args} tab={TabViews.Documents} />;
|
||||
}
|
||||
|
||||
export function Links(args: Props): JSX.Element {
|
||||
return <EmptyState {...args} tab={TabViews.Links} />;
|
||||
}
|
||||
|
||||
@@ -3,10 +3,51 @@
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import type { LocalizerType } from '../../../types/Util.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 = {
|
||||
label: string;
|
||||
i18n: LocalizerType;
|
||||
tab: TabViews;
|
||||
};
|
||||
|
||||
export function EmptyState({ label }: Props): JSX.Element {
|
||||
return <div className="module-empty-state">{label}</div>;
|
||||
export function EmptyState({ i18n, tab }: Props): JSX.Element {
|
||||
let title: string;
|
||||
let description: string;
|
||||
|
||||
switch (tab) {
|
||||
case TabViews.Media:
|
||||
title = i18n('icu:MediaGallery__EmptyState__title--media');
|
||||
description = i18n('icu:MediaGallery__EmptyState__description--media');
|
||||
break;
|
||||
case TabViews.Documents:
|
||||
title = i18n('icu:MediaGallery__EmptyState__title--documents');
|
||||
description = i18n(
|
||||
'icu:MediaGallery__EmptyState__description--documents'
|
||||
);
|
||||
break;
|
||||
case TabViews.Links:
|
||||
title = i18n('icu:MediaGallery__EmptyState__title--links');
|
||||
description = i18n('icu:MediaGallery__EmptyState__description--links');
|
||||
break;
|
||||
default:
|
||||
throw missingCaseError(tab);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={tw(
|
||||
'absolute inset-0',
|
||||
'flex items-center justify-center',
|
||||
'pointer-events-none size-full'
|
||||
)}
|
||||
>
|
||||
<div className={tw('text-center')}>
|
||||
<h3 className={tw('type-title-small')}>{title}</h3>
|
||||
<p className={tw('type-body-medium')}>{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import type { Meta } from '@storybook/react';
|
||||
import type { Props } from './LinkPreviewItem.dom.js';
|
||||
import { LinkPreviewItem } from './LinkPreviewItem.dom.js';
|
||||
import {
|
||||
createPreparedMediaItems,
|
||||
createRandomLinks,
|
||||
} from './utils/mocks.std.js';
|
||||
|
||||
export default {
|
||||
title: 'Components/Conversation/MediaGallery/LinkPreviewItem',
|
||||
} satisfies Meta<Props>;
|
||||
|
||||
const { i18n } = window.SignalContext;
|
||||
|
||||
export function Multiple(): JSX.Element {
|
||||
const items = createPreparedMediaItems(createRandomLinks);
|
||||
|
||||
return (
|
||||
<>
|
||||
{items.map((mediaItem, index) => (
|
||||
<LinkPreviewItem
|
||||
i18n={i18n}
|
||||
key={index}
|
||||
authorTitle="Alice"
|
||||
mediaItem={mediaItem}
|
||||
onClick={action('onClick')}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
121
ts/components/conversation/media-gallery/LinkPreviewItem.dom.tsx
Normal file
121
ts/components/conversation/media-gallery/LinkPreviewItem.dom.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import moment from 'moment';
|
||||
import {
|
||||
getAlt,
|
||||
getUrl,
|
||||
defaultBlurHash,
|
||||
} from '../../../util/Attachment.std.js';
|
||||
import type { LinkPreviewMediaItemType } from '../../../types/MediaItem.std.js';
|
||||
import type { LocalizerType, ThemeType } from '../../../types/Util.std.js';
|
||||
import { tw } from '../../../axo/tw.dom.js';
|
||||
import { AxoSymbol } from '../../../axo/AxoSymbol.dom.js';
|
||||
import type { AttachmentStatusType } from '../../../hooks/useAttachmentStatus.std.js';
|
||||
import { ImageOrBlurhash } from '../../ImageOrBlurhash.dom.js';
|
||||
|
||||
export type DataProps = Readonly<{
|
||||
// Required
|
||||
mediaItem: LinkPreviewMediaItemType;
|
||||
|
||||
// Optional
|
||||
onClick?: (status: AttachmentStatusType['state']) => void;
|
||||
}>;
|
||||
|
||||
// Provided by smart layer
|
||||
export type Props = DataProps &
|
||||
Readonly<{
|
||||
i18n: LocalizerType;
|
||||
theme?: ThemeType;
|
||||
authorTitle: string;
|
||||
}>;
|
||||
|
||||
export function LinkPreviewItem({
|
||||
i18n,
|
||||
theme,
|
||||
mediaItem,
|
||||
authorTitle,
|
||||
onClick,
|
||||
}: Props): JSX.Element {
|
||||
const { preview, message } = mediaItem;
|
||||
|
||||
const timestamp = message.receivedAtMs || message.receivedAt;
|
||||
|
||||
const handleClick = useCallback(
|
||||
(ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
onClick?.('ReadyToShow');
|
||||
},
|
||||
[onClick]
|
||||
);
|
||||
|
||||
const url = preview.image == null ? undefined : getUrl(preview.image);
|
||||
let imageOrPlaceholder: JSX.Element;
|
||||
if (preview.image != null && url != null) {
|
||||
const resolvedBlurHash = preview.image.blurHash || defaultBlurHash(theme);
|
||||
|
||||
const { width, height } = preview.image;
|
||||
|
||||
imageOrPlaceholder = (
|
||||
<div className={tw('size-9 overflow-hidden rounded-sm')}>
|
||||
<ImageOrBlurhash
|
||||
className={tw('object-cover')}
|
||||
src={url}
|
||||
intrinsicWidth={width}
|
||||
intrinsicHeight={height}
|
||||
alt={getAlt(preview.image, i18n)}
|
||||
blurHash={resolvedBlurHash}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
imageOrPlaceholder = (
|
||||
<div
|
||||
className={tw(
|
||||
'flex size-9 items-center justify-center',
|
||||
'overflow-hidden rounded-sm bg-elevated-background-tertiary'
|
||||
)}
|
||||
>
|
||||
<AxoSymbol.Icon symbol="link" size={20} label={null} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={tw('flex w-full flex-row gap-3 py-2')}
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
aria-label={i18n('icu:LinkPreviewItem__alt')}
|
||||
>
|
||||
<div className={tw('shrink-0')}>{imageOrPlaceholder}</div>
|
||||
<div className={tw('grow overflow-hidden text-start')}>
|
||||
<h3 className={tw('truncate type-body-large')}>
|
||||
{preview.title ?? ''}
|
||||
</h3>
|
||||
<div
|
||||
className={tw(
|
||||
'truncate type-body-small leading-4 text-label-secondary'
|
||||
)}
|
||||
>
|
||||
<a
|
||||
className={tw('type-body-medium text-label-secondary underline')}
|
||||
href={preview.url}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{preview.url}
|
||||
</a>
|
||||
</div>
|
||||
<div className={tw('truncate type-body-small text-label-secondary')}>
|
||||
{authorTitle} · {preview.domain}
|
||||
</div>
|
||||
</div>
|
||||
<div className={tw('shrink-0 type-body-small text-label-secondary')}>
|
||||
{moment(timestamp).format('MMM D')}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -6,10 +6,12 @@ import { action } from '@storybook/addon-actions';
|
||||
import type { Meta } from '@storybook/react';
|
||||
import type { Props } from './MediaGallery.dom.js';
|
||||
import { MediaGallery } from './MediaGallery.dom.js';
|
||||
import { LinkPreviewItem } from './LinkPreviewItem.dom.js';
|
||||
import {
|
||||
createPreparedMediaItems,
|
||||
createRandomDocuments,
|
||||
createRandomMedia,
|
||||
createRandomLinks,
|
||||
days,
|
||||
} from './utils/mocks.std.js';
|
||||
|
||||
@@ -26,16 +28,28 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||
documents: overrideProps.documents || [],
|
||||
haveOldestDocument: overrideProps.haveOldestDocument || false,
|
||||
haveOldestMedia: overrideProps.haveOldestMedia || false,
|
||||
haveOldestLink: overrideProps.haveOldestLink || false,
|
||||
loading: overrideProps.loading || false,
|
||||
media: overrideProps.media || [],
|
||||
links: overrideProps.links || [],
|
||||
|
||||
initialLoad: action('initialLoad'),
|
||||
loadMoreDocuments: action('loadMoreDocuments'),
|
||||
loadMoreMedia: action('loadMoreMedia'),
|
||||
loadMore: action('loadMore'),
|
||||
saveAttachment: action('saveAttachment'),
|
||||
showLightbox: action('showLightbox'),
|
||||
kickOffAttachmentDownload: action('kickOffAttachmentDownload'),
|
||||
cancelAttachmentDownload: action('cancelAttachmentDownload'),
|
||||
|
||||
renderLinkPreviewItem: ({ mediaItem, onClick }) => {
|
||||
return (
|
||||
<LinkPreviewItem
|
||||
i18n={i18n}
|
||||
authorTitle="Alice"
|
||||
mediaItem={mediaItem}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export function Populated(): JSX.Element {
|
||||
@@ -66,8 +80,9 @@ export function NoMedia(): JSX.Element {
|
||||
export function OneEach(): JSX.Element {
|
||||
const media = createRandomMedia(Date.now(), days(1)).slice(0, 1);
|
||||
const documents = createRandomDocuments(Date.now(), days(1)).slice(0, 1);
|
||||
const links = createRandomLinks(Date.now(), days(1)).slice(0, 1);
|
||||
|
||||
const props = createProps({ documents, media });
|
||||
const props = createProps({ documents, media, links });
|
||||
|
||||
return <MediaGallery {...props} />;
|
||||
}
|
||||
|
||||
@@ -1,38 +1,41 @@
|
||||
// Copyright 2018 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import React, { useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
import moment from 'moment';
|
||||
|
||||
import type { ItemClickEvent } from './types/ItemClickEvent.std.js';
|
||||
import type { LocalizerType, ThemeType } from '../../../types/Util.std.js';
|
||||
import type { MediaItemType } from '../../../types/MediaItem.std.js';
|
||||
import type {
|
||||
LinkPreviewMediaItemType,
|
||||
MediaItemType,
|
||||
GenericMediaItemType,
|
||||
} from '../../../types/MediaItem.std.js';
|
||||
import type { SaveAttachmentActionCreatorType } from '../../../state/ducks/conversations.preload.js';
|
||||
import { AttachmentSection } from './AttachmentSection.dom.js';
|
||||
import { EmptyState } from './EmptyState.dom.js';
|
||||
import type { DataProps as LinkPreviewItemPropsType } from './LinkPreviewItem.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 type { AttachmentType } from '../../../types/Attachment.std.js';
|
||||
|
||||
enum TabViews {
|
||||
Media = 'Media',
|
||||
Documents = 'Documents',
|
||||
}
|
||||
|
||||
export type Props = {
|
||||
conversationId: string;
|
||||
documents: ReadonlyArray<MediaItemType>;
|
||||
i18n: LocalizerType;
|
||||
haveOldestMedia: boolean;
|
||||
haveOldestDocument: boolean;
|
||||
haveOldestLink: boolean;
|
||||
loading: boolean;
|
||||
initialLoad: (id: string) => unknown;
|
||||
loadMoreMedia: (id: string) => unknown;
|
||||
loadMoreDocuments: (id: string) => unknown;
|
||||
loadMore: (id: string, type: 'media' | 'documents' | 'links') => unknown;
|
||||
media: ReadonlyArray<MediaItemType>;
|
||||
documents: ReadonlyArray<MediaItemType>;
|
||||
links: ReadonlyArray<LinkPreviewMediaItemType>;
|
||||
saveAttachment: SaveAttachmentActionCreatorType;
|
||||
kickOffAttachmentDownload: (options: { messageId: string }) => void;
|
||||
cancelAttachmentDownload: (options: { messageId: string }) => void;
|
||||
@@ -41,54 +44,80 @@ export type Props = {
|
||||
messageId: string;
|
||||
}) => void;
|
||||
theme?: ThemeType;
|
||||
|
||||
renderLinkPreviewItem: (props: LinkPreviewItemPropsType) => JSX.Element;
|
||||
};
|
||||
|
||||
const MONTH_FORMAT = 'MMMM YYYY';
|
||||
|
||||
function MediaSection({
|
||||
documents,
|
||||
i18n,
|
||||
loading,
|
||||
media,
|
||||
tab,
|
||||
mediaItems,
|
||||
saveAttachment,
|
||||
kickOffAttachmentDownload,
|
||||
cancelAttachmentDownload,
|
||||
showLightbox,
|
||||
type,
|
||||
theme,
|
||||
renderLinkPreviewItem,
|
||||
}: Pick<
|
||||
Props,
|
||||
| 'documents'
|
||||
| 'i18n'
|
||||
| 'theme'
|
||||
| 'loading'
|
||||
| 'media'
|
||||
| 'saveAttachment'
|
||||
| 'kickOffAttachmentDownload'
|
||||
| 'cancelAttachmentDownload'
|
||||
| 'showLightbox'
|
||||
> & { type: 'media' | 'documents' }): JSX.Element {
|
||||
const mediaItems = type === 'media' ? media : documents;
|
||||
| 'renderLinkPreviewItem'
|
||||
> & {
|
||||
tab: TabViews;
|
||||
mediaItems: ReadonlyArray<GenericMediaItemType>;
|
||||
}): JSX.Element {
|
||||
const onItemClick = useCallback(
|
||||
(event: ItemClickEvent) => {
|
||||
const { state, mediaItem } = event;
|
||||
const { message } = mediaItem;
|
||||
if (state === 'Downloading') {
|
||||
cancelAttachmentDownload({ messageId: message.id });
|
||||
return;
|
||||
}
|
||||
if (state === 'NeedsDownload') {
|
||||
kickOffAttachmentDownload({ messageId: message.id });
|
||||
return;
|
||||
}
|
||||
if (state !== 'ReadyToShow') {
|
||||
throw missingCaseError(state);
|
||||
}
|
||||
|
||||
if (!mediaItems || mediaItems.length === 0) {
|
||||
if (mediaItem.type === 'media') {
|
||||
showLightbox({
|
||||
attachment: mediaItem.attachment,
|
||||
messageId: message.id,
|
||||
});
|
||||
} else if (mediaItem.type === 'document') {
|
||||
saveAttachment(mediaItem.attachment, message.sentAt);
|
||||
} else if (mediaItem.type === 'link') {
|
||||
openLinkInWebBrowser(mediaItem.preview.url);
|
||||
} else {
|
||||
throw missingCaseError(mediaItem.type);
|
||||
}
|
||||
},
|
||||
[
|
||||
saveAttachment,
|
||||
showLightbox,
|
||||
cancelAttachmentDownload,
|
||||
kickOffAttachmentDownload,
|
||||
]
|
||||
);
|
||||
|
||||
if (mediaItems.length === 0) {
|
||||
if (loading) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
const label = (() => {
|
||||
switch (type) {
|
||||
case 'media':
|
||||
return i18n('icu:mediaEmptyState');
|
||||
|
||||
case 'documents':
|
||||
return i18n('icu:documentsEmptyState');
|
||||
|
||||
default:
|
||||
throw missingCaseError(type);
|
||||
}
|
||||
})();
|
||||
|
||||
return <EmptyState data-test="EmptyState" label={label} />;
|
||||
return <EmptyState i18n={i18n} tab={tab} />;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
@@ -122,43 +151,9 @@ function MediaSection({
|
||||
header={header}
|
||||
i18n={i18n}
|
||||
theme={theme}
|
||||
type={type}
|
||||
mediaItems={section.mediaItems}
|
||||
onItemClick={(event: ItemClickEvent) => {
|
||||
switch (event.type) {
|
||||
case 'documents': {
|
||||
if (event.state === 'ReadyToShow') {
|
||||
saveAttachment(event.attachment, event.message.sentAt);
|
||||
} else if (event.state === 'Downloading') {
|
||||
cancelAttachmentDownload({ messageId: event.message.id });
|
||||
} else if (event.state === 'NeedsDownload') {
|
||||
kickOffAttachmentDownload({ messageId: event.message.id });
|
||||
} else {
|
||||
throw missingCaseError(event.state);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'media': {
|
||||
if (event.state === 'ReadyToShow') {
|
||||
showLightbox({
|
||||
attachment: event.attachment,
|
||||
messageId: event.message.id,
|
||||
});
|
||||
} else if (event.state === 'Downloading') {
|
||||
cancelAttachmentDownload({ messageId: event.message.id });
|
||||
} else if (event.state === 'NeedsDownload') {
|
||||
kickOffAttachmentDownload({ messageId: event.message.id });
|
||||
} else {
|
||||
throw missingCaseError(event.state);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new TypeError(`Unknown attachment type: '${event.type}'`);
|
||||
}
|
||||
}}
|
||||
onItemClick={onItemClick}
|
||||
renderLinkPreviewItem={renderLinkPreviewItem}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -168,19 +163,21 @@ function MediaSection({
|
||||
|
||||
export function MediaGallery({
|
||||
conversationId,
|
||||
documents,
|
||||
haveOldestDocument,
|
||||
haveOldestMedia,
|
||||
haveOldestLink,
|
||||
i18n,
|
||||
initialLoad,
|
||||
loading,
|
||||
loadMoreDocuments,
|
||||
loadMoreMedia,
|
||||
loadMore,
|
||||
media,
|
||||
documents,
|
||||
links,
|
||||
saveAttachment,
|
||||
kickOffAttachmentDownload,
|
||||
cancelAttachmentDownload,
|
||||
showLightbox,
|
||||
renderLinkPreviewItem,
|
||||
}: Props): JSX.Element {
|
||||
const focusRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollObserverRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -196,8 +193,10 @@ export function MediaGallery({
|
||||
if (
|
||||
media.length > 0 ||
|
||||
documents.length > 0 ||
|
||||
links.length > 0 ||
|
||||
haveOldestDocument ||
|
||||
haveOldestMedia
|
||||
haveOldestMedia ||
|
||||
haveOldestLink
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -207,9 +206,11 @@ export function MediaGallery({
|
||||
conversationId,
|
||||
haveOldestDocument,
|
||||
haveOldestMedia,
|
||||
haveOldestLink,
|
||||
initialLoad,
|
||||
media,
|
||||
documents,
|
||||
links,
|
||||
]);
|
||||
|
||||
const previousLoading = usePrevious(loading, loading);
|
||||
@@ -238,13 +239,18 @@ export function MediaGallery({
|
||||
if (entry && entry.intersectionRatio > 0) {
|
||||
if (tabViewRef.current === TabViews.Media) {
|
||||
if (!haveOldestMedia) {
|
||||
loadMoreMedia(conversationId);
|
||||
loadMore(conversationId, 'media');
|
||||
loadingRef.current = true;
|
||||
}
|
||||
} else if (tabViewRef.current === TabViews.Documents) {
|
||||
if (!haveOldestDocument) {
|
||||
loadMore(conversationId, 'documents');
|
||||
loadingRef.current = true;
|
||||
}
|
||||
} else {
|
||||
// eslint-disable-next-line no-lonely-if
|
||||
if (!haveOldestDocument) {
|
||||
loadMoreDocuments(conversationId);
|
||||
if (!haveOldestLink) {
|
||||
loadMore(conversationId, 'links');
|
||||
loadingRef.current = true;
|
||||
}
|
||||
}
|
||||
@@ -261,9 +267,9 @@ export function MediaGallery({
|
||||
conversationId,
|
||||
haveOldestDocument,
|
||||
haveOldestMedia,
|
||||
haveOldestLink,
|
||||
loading,
|
||||
loadMoreDocuments,
|
||||
loadMoreMedia,
|
||||
loadMore,
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -275,46 +281,45 @@ export function MediaGallery({
|
||||
id: TabViews.Media,
|
||||
label: i18n('icu:media'),
|
||||
},
|
||||
{
|
||||
id: TabViews.Links,
|
||||
label: i18n('icu:MediaGallery__tab__links'),
|
||||
},
|
||||
{
|
||||
id: TabViews.Documents,
|
||||
label: i18n('icu:documents'),
|
||||
label: i18n('icu:MediaGallery__tab__files'),
|
||||
},
|
||||
]}
|
||||
>
|
||||
{({ selectedTab }) => {
|
||||
tabViewRef.current =
|
||||
selectedTab === TabViews.Media
|
||||
? TabViews.Media
|
||||
: TabViews.Documents;
|
||||
let mediaItems: ReadonlyArray<GenericMediaItemType>;
|
||||
|
||||
if (selectedTab === TabViews.Media) {
|
||||
tabViewRef.current = TabViews.Media;
|
||||
mediaItems = media;
|
||||
} 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 (
|
||||
<div className="module-media-gallery__content">
|
||||
{selectedTab === TabViews.Media && (
|
||||
<MediaSection
|
||||
documents={documents}
|
||||
i18n={i18n}
|
||||
loading={loading}
|
||||
media={media}
|
||||
saveAttachment={saveAttachment}
|
||||
showLightbox={showLightbox}
|
||||
kickOffAttachmentDownload={kickOffAttachmentDownload}
|
||||
cancelAttachmentDownload={cancelAttachmentDownload}
|
||||
type="media"
|
||||
/>
|
||||
)}
|
||||
{selectedTab === TabViews.Documents && (
|
||||
<MediaSection
|
||||
documents={documents}
|
||||
i18n={i18n}
|
||||
loading={loading}
|
||||
media={media}
|
||||
saveAttachment={saveAttachment}
|
||||
showLightbox={showLightbox}
|
||||
kickOffAttachmentDownload={kickOffAttachmentDownload}
|
||||
cancelAttachmentDownload={cancelAttachmentDownload}
|
||||
type="documents"
|
||||
/>
|
||||
)}
|
||||
<MediaSection
|
||||
i18n={i18n}
|
||||
loading={loading}
|
||||
tab={tabViewRef.current}
|
||||
mediaItems={mediaItems}
|
||||
saveAttachment={saveAttachment}
|
||||
showLightbox={showLightbox}
|
||||
kickOffAttachmentDownload={kickOffAttachmentDownload}
|
||||
cancelAttachmentDownload={cancelAttachmentDownload}
|
||||
renderLinkPreviewItem={renderLinkPreviewItem}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -44,6 +44,7 @@ type OverridePropsMediaItemType = Partial<MediaItemType> & {
|
||||
const createMediaItem = (
|
||||
overrideProps: OverridePropsMediaItemType
|
||||
): MediaItemType => ({
|
||||
type: 'media',
|
||||
index: 0,
|
||||
attachment: overrideProps.attachment || {
|
||||
path: '123',
|
||||
@@ -60,6 +61,10 @@ const createMediaItem = (
|
||||
receivedAt: Date.now(),
|
||||
receivedAtMs: Date.now(),
|
||||
sentAt: Date.now(),
|
||||
|
||||
// Unused for now
|
||||
source: undefined,
|
||||
sourceServiceId: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import moment from 'moment';
|
||||
import lodash from 'lodash';
|
||||
import type { MediaItemType } from '../../../types/MediaItem.std.js';
|
||||
import type { GenericMediaItemType } from '../../../types/MediaItem.std.js';
|
||||
import { missingCaseError } from '../../../util/missingCaseError.std.js';
|
||||
|
||||
const { compact, groupBy, sortBy } = lodash;
|
||||
@@ -13,7 +13,7 @@ type YearMonthSectionType = 'yearMonth';
|
||||
|
||||
type GenericSection<T> = {
|
||||
type: T;
|
||||
mediaItems: ReadonlyArray<MediaItemType>;
|
||||
mediaItems: ReadonlyArray<GenericMediaItemType>;
|
||||
};
|
||||
type StaticSection = GenericSection<StaticSectionType>;
|
||||
type YearMonthSection = GenericSection<YearMonthSectionType> & {
|
||||
@@ -23,7 +23,7 @@ type YearMonthSection = GenericSection<YearMonthSectionType> & {
|
||||
export type Section = StaticSection | YearMonthSection;
|
||||
export const groupMediaItemsByDate = (
|
||||
timestamp: number,
|
||||
mediaItems: ReadonlyArray<MediaItemType>
|
||||
mediaItems: ReadonlyArray<GenericMediaItemType>
|
||||
): Array<Section> => {
|
||||
const referenceDateTime = moment(timestamp);
|
||||
|
||||
@@ -89,7 +89,7 @@ const toSection = (
|
||||
type GenericMediaItemWithSection<T> = {
|
||||
order: number;
|
||||
type: T;
|
||||
mediaItem: MediaItemType;
|
||||
mediaItem: GenericMediaItemType;
|
||||
};
|
||||
type MediaItemWithStaticSection =
|
||||
GenericMediaItemWithSection<StaticSectionType>;
|
||||
@@ -108,7 +108,7 @@ const withSection = (referenceDateTime: moment.Moment) => {
|
||||
const thisWeek = moment(referenceDateTime).subtract(7, 'day').startOf('day');
|
||||
const thisMonth = moment(referenceDateTime).startOf('month');
|
||||
|
||||
return (mediaItem: MediaItemType): MediaItemWithSection => {
|
||||
return (mediaItem: GenericMediaItemType): MediaItemWithSection => {
|
||||
const { message } = mediaItem;
|
||||
const messageTimestamp = moment(message.receivedAtMs || message.receivedAt);
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
// Copyright 2018 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { AttachmentType } from '../../../../types/Attachment.std.js';
|
||||
import type { GenericMediaItemType } from '../../../../types/MediaItem.std.js';
|
||||
import type { AttachmentStatusType } from '../../../../hooks/useAttachmentStatus.std.js';
|
||||
|
||||
export type ItemClickEvent = {
|
||||
message: { id: string; sentAt: number };
|
||||
attachment: AttachmentType;
|
||||
type: 'media' | 'documents';
|
||||
state: AttachmentStatusType['state'];
|
||||
mediaItem: GenericMediaItemType;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
export enum TabViews {
|
||||
Media = 'Media',
|
||||
Documents = 'Documents',
|
||||
Links = 'Links',
|
||||
}
|
||||
@@ -3,7 +3,12 @@
|
||||
|
||||
import lodash from 'lodash';
|
||||
import { type MIMEType, IMAGE_JPEG } from '../../../../types/MIME.std.js';
|
||||
import type { MediaItemType } from '../../../../types/MediaItem.std.js';
|
||||
import type {
|
||||
MediaItemType,
|
||||
LinkPreviewMediaItemType,
|
||||
MediaItemMessageType,
|
||||
} from '../../../../types/MediaItem.std.js';
|
||||
import type { AttachmentForUIType } from '../../../../types/Attachment.std.js';
|
||||
import { randomBlurHash } from '../../../../util/randomBlurHash.std.js';
|
||||
import { SignalService } from '../../../../protobuf/index.std.js';
|
||||
|
||||
@@ -24,11 +29,7 @@ const contentTypes = {
|
||||
txt: 'application/text',
|
||||
} as unknown as Record<string, MIMEType>;
|
||||
|
||||
function createRandomFile(
|
||||
startTime: number,
|
||||
timeWindow: number,
|
||||
fileExtension: string
|
||||
): MediaItemType {
|
||||
function createRandomAttachment(fileExtension: string): AttachmentForUIType {
|
||||
const contentType = contentTypes[fileExtension];
|
||||
const fileName = `${sample(tokens)}${sample(tokens)}.${fileExtension}`;
|
||||
|
||||
@@ -36,76 +37,131 @@ function createRandomFile(
|
||||
const isPending = !isDownloaded && Math.random() > 0.5;
|
||||
|
||||
return {
|
||||
message: {
|
||||
conversationId: '123',
|
||||
type: 'incoming',
|
||||
id: random(Date.now()).toString(),
|
||||
receivedAt: Math.floor(Math.random() * 10),
|
||||
receivedAtMs: random(startTime, startTime + timeWindow),
|
||||
sentAt: Date.now(),
|
||||
},
|
||||
attachment: {
|
||||
url: isDownloaded ? '/fixtures/cat-screenshot-3x4.png' : undefined,
|
||||
path: isDownloaded ? 'abc' : undefined,
|
||||
pending: isPending,
|
||||
screenshot:
|
||||
fileExtension === 'mp4'
|
||||
? {
|
||||
url: isDownloaded
|
||||
? '/fixtures/cat-screenshot-3x4.png'
|
||||
: undefined,
|
||||
contentType: IMAGE_JPEG,
|
||||
}
|
||||
: undefined,
|
||||
flags:
|
||||
fileExtension === 'mp4' && Math.random() > 0.5
|
||||
? SignalService.AttachmentPointer.Flags.GIF
|
||||
: 0,
|
||||
width: 400,
|
||||
height: 300,
|
||||
fileName,
|
||||
size: random(1000, 1000 * 1000 * 50),
|
||||
contentType,
|
||||
blurHash: randomBlurHash(),
|
||||
isPermanentlyUndownloadable: false,
|
||||
},
|
||||
url: isDownloaded ? '/fixtures/cat-screenshot-3x4.png' : undefined,
|
||||
path: isDownloaded ? 'abc' : undefined,
|
||||
pending: isPending,
|
||||
screenshot:
|
||||
fileExtension === 'mp4'
|
||||
? {
|
||||
url: isDownloaded ? '/fixtures/cat-screenshot-3x4.png' : undefined,
|
||||
contentType: IMAGE_JPEG,
|
||||
}
|
||||
: undefined,
|
||||
flags:
|
||||
fileExtension === 'mp4' && Math.random() > 0.5
|
||||
? SignalService.AttachmentPointer.Flags.GIF
|
||||
: 0,
|
||||
width: 400,
|
||||
height: 300,
|
||||
fileName,
|
||||
size: random(1000, 1000 * 1000 * 50),
|
||||
contentType,
|
||||
blurHash: randomBlurHash(),
|
||||
isPermanentlyUndownloadable: false,
|
||||
};
|
||||
}
|
||||
|
||||
function createRandomMessage(
|
||||
startTime: number,
|
||||
timeWindow: number
|
||||
): MediaItemMessageType {
|
||||
return {
|
||||
conversationId: '123',
|
||||
type: 'incoming',
|
||||
id: random(Date.now()).toString(),
|
||||
receivedAt: Math.floor(Math.random() * 10),
|
||||
receivedAtMs: random(startTime, startTime + timeWindow),
|
||||
sentAt: Date.now(),
|
||||
|
||||
// Unused for now
|
||||
source: undefined,
|
||||
sourceServiceId: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function createRandomFile(
|
||||
type: 'media' | 'document',
|
||||
startTime: number,
|
||||
timeWindow: number,
|
||||
fileExtension: string
|
||||
): MediaItemType {
|
||||
return {
|
||||
type,
|
||||
message: createRandomMessage(startTime, timeWindow),
|
||||
attachment: createRandomAttachment(fileExtension),
|
||||
index: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function createRandomLink(
|
||||
startTime: number,
|
||||
timeWindow: number
|
||||
): LinkPreviewMediaItemType {
|
||||
return {
|
||||
type: 'link',
|
||||
message: createRandomMessage(startTime, timeWindow),
|
||||
preview: {
|
||||
url: 'https://signal.org/',
|
||||
domain: 'signal.org',
|
||||
title: 'Signal',
|
||||
description: 'description',
|
||||
image: Math.random() > 0.7 ? createRandomAttachment('png') : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createRandomFiles(
|
||||
type: 'media' | 'document',
|
||||
startTime: number,
|
||||
timeWindow: number,
|
||||
fileExtensions: Array<string>
|
||||
): Array<MediaItemType> {
|
||||
return range(random(5, 10)).map(() =>
|
||||
createRandomFile(startTime, timeWindow, sample(fileExtensions) as string)
|
||||
createRandomFile(
|
||||
type,
|
||||
startTime,
|
||||
timeWindow,
|
||||
sample(fileExtensions) as string
|
||||
)
|
||||
);
|
||||
}
|
||||
export function createRandomDocuments(
|
||||
startTime: number,
|
||||
timeWindow: number
|
||||
): Array<MediaItemType> {
|
||||
return createRandomFiles(startTime, timeWindow, [
|
||||
return createRandomFiles('document', startTime, timeWindow, [
|
||||
'docx',
|
||||
'pdf',
|
||||
'exe',
|
||||
'txt',
|
||||
]);
|
||||
}
|
||||
export function createRandomLinks(
|
||||
startTime: number,
|
||||
timeWindow: number
|
||||
): Array<LinkPreviewMediaItemType> {
|
||||
return range(random(5, 10)).map(() =>
|
||||
createRandomLink(startTime, timeWindow)
|
||||
);
|
||||
}
|
||||
|
||||
export function createRandomMedia(
|
||||
startTime: number,
|
||||
timeWindow: number
|
||||
): Array<MediaItemType> {
|
||||
return createRandomFiles(startTime, timeWindow, ['mp4', 'jpg', 'png', 'gif']);
|
||||
return createRandomFiles('media', startTime, timeWindow, [
|
||||
'mp4',
|
||||
'jpg',
|
||||
'png',
|
||||
'gif',
|
||||
]);
|
||||
}
|
||||
|
||||
export function createPreparedMediaItems(
|
||||
fn: typeof createRandomDocuments | typeof createRandomMedia
|
||||
): Array<MediaItemType> {
|
||||
export function createPreparedMediaItems<
|
||||
Item extends MediaItemType | LinkPreviewMediaItemType,
|
||||
>(fn: (startTime: number, timeWindow: number) => Array<Item>): Array<Item> {
|
||||
const now = Date.now();
|
||||
return sortBy(
|
||||
return sortBy<Item>(
|
||||
[
|
||||
...fn(now, days(1)),
|
||||
...fn(now - days(1), days(1)),
|
||||
@@ -113,6 +169,6 @@ export function createPreparedMediaItems(
|
||||
...fn(now - days(30), days(15)),
|
||||
...fn(now - days(365), days(300)),
|
||||
],
|
||||
(item: MediaItemType) => -item.message.receivedAt
|
||||
item => -item.message.receivedAt
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ import type { SyncTaskType } from '../util/syncTasks.preload.js';
|
||||
import type { AttachmentBackupJobType } from '../types/AttachmentBackup.std.js';
|
||||
import type { AttachmentType } from '../types/Attachment.std.js';
|
||||
import type { MediaItemMessageType } from '../types/MediaItem.std.js';
|
||||
import type { LinkPreviewType } from '../types/message/LinkPreviews.std.js';
|
||||
import type { GifType } from '../components/fun/panels/FunPanelGifs.dom.js';
|
||||
import type { NotificationProfileType } from '../types/NotificationProfile.std.js';
|
||||
import type { DonationReceipt } from '../types/Donations.std.js';
|
||||
@@ -590,7 +591,15 @@ export type GetOlderMediaOptionsType = Readonly<{
|
||||
messageId?: string;
|
||||
receivedAt?: number;
|
||||
sentAt?: number;
|
||||
type: 'media' | 'files';
|
||||
type: 'media' | 'documents';
|
||||
}>;
|
||||
|
||||
export type GetOlderLinkPreviewsOptionsType = Readonly<{
|
||||
conversationId: string;
|
||||
limit: number;
|
||||
messageId?: string;
|
||||
receivedAt?: number;
|
||||
sentAt?: number;
|
||||
}>;
|
||||
|
||||
export type MediaItemDBType = Readonly<{
|
||||
@@ -599,6 +608,11 @@ export type MediaItemDBType = Readonly<{
|
||||
message: MediaItemMessageType;
|
||||
}>;
|
||||
|
||||
export type LinkPreviewMediaItemDBType = Readonly<{
|
||||
preview: LinkPreviewType;
|
||||
message: MediaItemMessageType;
|
||||
}>;
|
||||
|
||||
export type KyberPreKeyTripleType = Readonly<{
|
||||
id: PreKeyIdType;
|
||||
signedPreKeyId: number;
|
||||
@@ -829,6 +843,9 @@ type ReadableInterface = {
|
||||
// getOlderMessagesByConversation is JSON on server, full message on Client
|
||||
hasMedia: (conversationId: string) => boolean;
|
||||
getOlderMedia: (options: GetOlderMediaOptionsType) => Array<MediaItemDBType>;
|
||||
getOlderLinkPreviews: (
|
||||
options: GetOlderLinkPreviewsOptionsType
|
||||
) => Array<LinkPreviewMediaItemDBType>;
|
||||
getAllStories: (options: {
|
||||
conversationId?: string;
|
||||
sourceServiceId?: ServiceIdString;
|
||||
|
||||
@@ -136,11 +136,13 @@ import type {
|
||||
GetKnownMessageAttachmentsResultType,
|
||||
GetNearbyMessageFromDeletedSetOptionsType,
|
||||
GetOlderMediaOptionsType,
|
||||
GetOlderLinkPreviewsOptionsType,
|
||||
GetRecentStoryRepliesOptionsType,
|
||||
GetUnreadByConversationAndMarkReadResultType,
|
||||
IdentityKeyIdType,
|
||||
ItemKeyType,
|
||||
KyberPreKeyTripleType,
|
||||
LinkPreviewMediaItemDBType,
|
||||
MediaItemDBType,
|
||||
MessageAttachmentsCursorType,
|
||||
MessageCursorType,
|
||||
@@ -454,6 +456,7 @@ export const DataReader: ServerReadableInterface = {
|
||||
|
||||
hasMedia,
|
||||
getOlderMedia,
|
||||
getOlderLinkPreviews,
|
||||
|
||||
getAllNotificationProfiles,
|
||||
getNotificationProfileById,
|
||||
@@ -5192,25 +5195,49 @@ function hasGroupCallHistoryMessage(
|
||||
}
|
||||
|
||||
function hasMedia(db: ReadableDB, conversationId: string): boolean {
|
||||
const [query, params] = sql`
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM message_attachments
|
||||
INDEXED BY message_attachments_getOlderMedia
|
||||
WHERE
|
||||
conversationId IS ${conversationId} AND
|
||||
editHistoryIndex IS -1 AND
|
||||
attachmentType IS 'attachment' AND
|
||||
messageType IN ('incoming', 'outgoing') AND
|
||||
isViewOnce IS NOT 1 AND
|
||||
contentType IS NOT NULL AND
|
||||
contentType IS NOT '' AND
|
||||
contentType IS NOT 'text/x-signal-plain' AND
|
||||
contentType NOT LIKE 'audio/%'
|
||||
);
|
||||
`;
|
||||
const exists = db.prepare(query, { pluck: true }).get<number>(params);
|
||||
return db.transaction(() => {
|
||||
let hasAttachments: boolean;
|
||||
let hasPreviews: boolean;
|
||||
|
||||
return exists === 1;
|
||||
{
|
||||
const [query, params] = sql`
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM message_attachments
|
||||
INDEXED BY message_attachments_getOlderMedia
|
||||
WHERE
|
||||
conversationId IS ${conversationId} AND
|
||||
editHistoryIndex IS -1 AND
|
||||
attachmentType IS 'attachment' AND
|
||||
messageType IN ('incoming', 'outgoing') AND
|
||||
isViewOnce IS NOT 1 AND
|
||||
contentType IS NOT NULL AND
|
||||
contentType IS NOT '' AND
|
||||
contentType IS NOT 'text/x-signal-plain' AND
|
||||
contentType NOT LIKE 'audio/%'
|
||||
);
|
||||
`;
|
||||
hasAttachments =
|
||||
db.prepare(query, { pluck: true }).get<number>(params) === 1;
|
||||
}
|
||||
|
||||
{
|
||||
const [query, params] = sql`
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM messages
|
||||
INDEXED BY messages_hasPreviews
|
||||
WHERE
|
||||
conversationId IS ${conversationId} AND
|
||||
type IN ('incoming', 'outgoing') AND
|
||||
isViewOnce IS NOT 1 AND
|
||||
hasPreviews IS 1
|
||||
);
|
||||
`;
|
||||
hasPreviews =
|
||||
db.prepare(query, { pluck: true }).get<number>(params) === 1;
|
||||
}
|
||||
|
||||
return hasAttachments || hasPreviews;
|
||||
})();
|
||||
}
|
||||
|
||||
function getOlderMedia(
|
||||
@@ -5225,26 +5252,30 @@ function getOlderMedia(
|
||||
}: GetOlderMediaOptionsType
|
||||
): Array<MediaItemDBType> {
|
||||
const timeFilters = {
|
||||
first: sqlFragment`receivedAt = ${maxReceivedAt} AND sentAt < ${maxSentAt}`,
|
||||
second: sqlFragment`receivedAt < ${maxReceivedAt}`,
|
||||
first: sqlFragment`
|
||||
message_attachments.receivedAt = ${maxReceivedAt}
|
||||
AND
|
||||
message_attachments.sentAt < ${maxSentAt}
|
||||
`,
|
||||
second: sqlFragment`message_attachments.receivedAt < ${maxReceivedAt}`,
|
||||
};
|
||||
|
||||
let contentFilter: QueryFragment;
|
||||
if (type === 'media') {
|
||||
// see 'isVisualMedia' in ts/types/Attachment.ts
|
||||
contentFilter = sqlFragment`
|
||||
contentType LIKE 'image/%' OR
|
||||
contentType LIKE 'video/%'
|
||||
message_attachments.contentType LIKE 'image/%' OR
|
||||
message_attachments.contentType LIKE 'video/%'
|
||||
`;
|
||||
} else if (type === 'files') {
|
||||
} else if (type === 'documents') {
|
||||
// see 'isFile' in ts/types/Attachment.ts
|
||||
contentFilter = sqlFragment`
|
||||
contentType IS NOT NULL AND
|
||||
contentType IS NOT '' AND
|
||||
contentType IS NOT 'text/x-signal-plain' AND
|
||||
contentType NOT LIKE 'audio/%' AND
|
||||
contentType NOT LIKE 'image/%' AND
|
||||
contentType NOT LIKE 'video/%'
|
||||
message_attachments.contentType IS NOT NULL AND
|
||||
message_attachments.contentType IS NOT '' AND
|
||||
message_attachments.contentType IS NOT 'text/x-signal-plain' AND
|
||||
message_attachments.contentType NOT LIKE 'audio/%' AND
|
||||
message_attachments.contentType NOT LIKE 'image/%' AND
|
||||
message_attachments.contentType NOT LIKE 'video/%'
|
||||
`;
|
||||
} else {
|
||||
throw missingCaseError(type);
|
||||
@@ -5252,21 +5283,25 @@ function getOlderMedia(
|
||||
|
||||
const createQuery = (timeFilter: QueryFragment): QueryFragment => sqlFragment`
|
||||
SELECT
|
||||
*
|
||||
message_attachments.*,
|
||||
messages.source AS messageSource,
|
||||
messages.sourceServiceId AS messageSourceServiceId
|
||||
FROM message_attachments
|
||||
INDEXED BY message_attachments_getOlderMedia
|
||||
INNER JOIN messages ON
|
||||
messages.id = message_attachments.messageId
|
||||
WHERE
|
||||
conversationId IS ${conversationId} AND
|
||||
editHistoryIndex IS -1 AND
|
||||
attachmentType IS 'attachment' AND
|
||||
message_attachments.conversationId IS ${conversationId} AND
|
||||
message_attachments.editHistoryIndex IS -1 AND
|
||||
message_attachments.attachmentType IS 'attachment' AND
|
||||
(
|
||||
${timeFilter}
|
||||
) AND
|
||||
(${contentFilter}) AND
|
||||
isViewOnce IS NOT 1 AND
|
||||
messageType IN ('incoming', 'outgoing') AND
|
||||
(${messageId ?? null} IS NULL OR messageId IS NOT ${messageId ?? null})
|
||||
ORDER BY receivedAt DESC, sentAt DESC
|
||||
message_attachments.isViewOnce IS NOT 1 AND
|
||||
message_attachments.messageType IN ('incoming', 'outgoing') AND
|
||||
(${messageId ?? null} IS NULL OR message_attachments.messageId IS NOT ${messageId ?? null})
|
||||
ORDER BY message_attachments.receivedAt DESC, message_attachments.sentAt DESC
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
|
||||
@@ -5276,16 +5311,30 @@ function getOlderMedia(
|
||||
SELECT second.* FROM (${createQuery(timeFilters.second)}) as second
|
||||
`;
|
||||
|
||||
const results: Array<MessageAttachmentDBType> = db.prepare(query).all(params);
|
||||
const results: Array<
|
||||
MessageAttachmentDBType & {
|
||||
messageSource: string | null;
|
||||
messageSourceServiceId: ServiceIdString | null;
|
||||
}
|
||||
> = db.prepare(query).all(params);
|
||||
|
||||
return results.map(attachment => {
|
||||
const { orderInMessage, messageType, sentAt, receivedAt, receivedAtMs } =
|
||||
attachment;
|
||||
const {
|
||||
orderInMessage,
|
||||
messageType,
|
||||
messageSource,
|
||||
messageSourceServiceId,
|
||||
sentAt,
|
||||
receivedAt,
|
||||
receivedAtMs,
|
||||
} = attachment;
|
||||
|
||||
return {
|
||||
message: {
|
||||
id: attachment.messageId,
|
||||
type: messageType as 'incoming' | 'outgoing',
|
||||
source: messageSource ?? undefined,
|
||||
sourceServiceId: messageSourceServiceId ?? undefined,
|
||||
conversationId,
|
||||
receivedAt,
|
||||
receivedAtMs: receivedAtMs ?? undefined,
|
||||
@@ -5297,6 +5346,67 @@ function getOlderMedia(
|
||||
});
|
||||
}
|
||||
|
||||
function getOlderLinkPreviews(
|
||||
db: ReadableDB,
|
||||
{
|
||||
conversationId,
|
||||
limit,
|
||||
messageId,
|
||||
receivedAt: maxReceivedAt = Number.MAX_VALUE,
|
||||
sentAt: maxSentAt = Number.MAX_VALUE,
|
||||
}: GetOlderLinkPreviewsOptionsType
|
||||
): Array<LinkPreviewMediaItemDBType> {
|
||||
const timeFilters = {
|
||||
first: sqlFragment`received_at = ${maxReceivedAt} AND sent_at < ${maxSentAt}`,
|
||||
second: sqlFragment`received_at < ${maxReceivedAt}`,
|
||||
};
|
||||
|
||||
const createQuery = (timeFilter: QueryFragment): QueryFragment => sqlFragment`
|
||||
SELECT
|
||||
${sqlJoin(MESSAGE_COLUMNS_FRAGMENTS)}
|
||||
FROM messages
|
||||
INDEXED BY messages_hasPreviews
|
||||
WHERE
|
||||
conversationId IS ${conversationId} AND
|
||||
hasPreviews IS 1 AND
|
||||
isViewOnce IS NOT 1 AND
|
||||
type IN ('incoming', 'outgoing') AND
|
||||
(${messageId ?? null} IS NULL OR id IS NOT ${messageId ?? null})
|
||||
AND (${timeFilter})
|
||||
ORDER BY received_at DESC, sent_at DESC
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
|
||||
const [query, params] = sql`
|
||||
SELECT first.* FROM (${createQuery(timeFilters.first)}) as first
|
||||
UNION ALL
|
||||
SELECT second.* FROM (${createQuery(timeFilters.second)}) as second
|
||||
`;
|
||||
|
||||
const rows = db.prepare(query).all<MessageTypeUnhydrated>(params);
|
||||
|
||||
return hydrateMessages(db, rows).map(message => {
|
||||
strictAssert(
|
||||
message.preview != null && message.preview.length >= 1,
|
||||
`getOlderLinkPreviews: got message without previe ${message.id}`
|
||||
);
|
||||
|
||||
return {
|
||||
message: {
|
||||
id: message.id,
|
||||
type: message.type as 'incoming' | 'outgoing',
|
||||
conversationId,
|
||||
source: message.source,
|
||||
sourceServiceId: message.sourceServiceId,
|
||||
receivedAt: message.received_at,
|
||||
receivedAtMs: message.received_at_ms ?? undefined,
|
||||
sentAt: message.sent_at,
|
||||
},
|
||||
preview: message.preview[0],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function _markCallHistoryMissed(
|
||||
db: WritableDB,
|
||||
callIds: ReadonlyArray<string>
|
||||
|
||||
21
ts/sql/migrations/1550-has-link-preview.std.ts
Normal file
21
ts/sql/migrations/1550-has-link-preview.std.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { WritableDB } from '../Interface.std.js';
|
||||
|
||||
export default function updateToSchemaVersion1520(db: WritableDB): void {
|
||||
db.exec(`
|
||||
ALTER TABLE messages
|
||||
ADD COLUMN hasPreviews INTEGER NOT NULL
|
||||
GENERATED ALWAYS AS (
|
||||
IFNULL(json_array_length(json, '$.preview'), 0) > 0
|
||||
);
|
||||
|
||||
CREATE INDEX messages_hasPreviews
|
||||
ON messages (conversationId, received_at DESC, sent_at DESC)
|
||||
WHERE
|
||||
hasPreviews IS 1 AND
|
||||
isViewOnce IS NOT 1 AND
|
||||
type IN ('incoming', 'outgoing');
|
||||
`);
|
||||
}
|
||||
@@ -130,6 +130,7 @@ import updateToSchemaVersion1510 from './1510-chat-folders-normalize-all-chats.s
|
||||
import updateToSchemaVersion1520 from './1520-poll-votes-unread.std.js';
|
||||
import updateToSchemaVersion1530 from './1530-update-expiring-index.std.js';
|
||||
import updateToSchemaVersion1540 from './1540-partial-expiring-index.std.js';
|
||||
import updateToSchemaVersion1550 from './1550-has-link-preview.std.js';
|
||||
|
||||
import { DataWriter } from '../Server.node.js';
|
||||
|
||||
@@ -1618,6 +1619,7 @@ export const SCHEMA_VERSIONS: ReadonlyArray<SchemaUpdateType> = [
|
||||
{ version: 1520, update: updateToSchemaVersion1520 },
|
||||
{ version: 1530, update: updateToSchemaVersion1530 },
|
||||
{ version: 1540, update: updateToSchemaVersion1540 },
|
||||
{ version: 1550, update: updateToSchemaVersion1550 },
|
||||
];
|
||||
|
||||
export class DBVersionFromFutureError extends Error {
|
||||
|
||||
@@ -221,6 +221,7 @@ function showLightboxForViewOnceMedia(
|
||||
|
||||
const media = [
|
||||
{
|
||||
type: 'media' as const,
|
||||
attachment: tempAttachment,
|
||||
index: 0,
|
||||
message: {
|
||||
@@ -230,6 +231,8 @@ function showLightboxForViewOnceMedia(
|
||||
receivedAt: message.get('received_at'),
|
||||
receivedAtMs: Number(message.get('received_at_ms')),
|
||||
sentAt: message.get('sent_at'),
|
||||
source: message.get('source'),
|
||||
sourceServiceId: message.get('sourceServiceId'),
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -332,8 +335,11 @@ function showLightbox(opts: {
|
||||
conversationId: authorId,
|
||||
receivedAt,
|
||||
receivedAtMs: Number(message.get('received_at_ms')),
|
||||
source: message.get('source'),
|
||||
sourceServiceId: message.get('sourceServiceId'),
|
||||
sentAt,
|
||||
},
|
||||
type: 'media' as const,
|
||||
attachment: getPropsForAttachment(
|
||||
item,
|
||||
'attachment',
|
||||
|
||||
@@ -7,7 +7,10 @@ import type { ReadonlyDeep } from 'type-fest';
|
||||
|
||||
import { createLogger } from '../../logging/log.std.js';
|
||||
import { DataReader } from '../../sql/Client.preload.js';
|
||||
import type { MediaItemDBType } from '../../sql/Interface.std.js';
|
||||
import type {
|
||||
MediaItemDBType,
|
||||
LinkPreviewMediaItemDBType,
|
||||
} from '../../sql/Interface.std.js';
|
||||
import {
|
||||
CONVERSATION_UNLOADED,
|
||||
MESSAGE_CHANGED,
|
||||
@@ -23,8 +26,13 @@ import type {
|
||||
MessageDeletedActionType,
|
||||
MessageExpiredActionType,
|
||||
} from './conversations.preload.js';
|
||||
import type { MediaItemType } from '../../types/MediaItem.std.js';
|
||||
import type {
|
||||
MediaItemMessageType,
|
||||
MediaItemType,
|
||||
LinkPreviewMediaItemType,
|
||||
} from '../../types/MediaItem.std.js';
|
||||
import { isFile, isVisualMedia } from '../../util/Attachment.std.js';
|
||||
import { missingCaseError } from '../../util/missingCaseError.std.js';
|
||||
import type { StateType as RootStateType } from '../reducer.preload.js';
|
||||
import { getPropsForAttachment } from '../selectors/message.preload.js';
|
||||
|
||||
@@ -34,18 +42,19 @@ const log = createLogger('mediaGallery');
|
||||
|
||||
export type MediaGalleryStateType = ReadonlyDeep<{
|
||||
conversationId: string | undefined;
|
||||
documents: ReadonlyArray<MediaItemType>;
|
||||
haveOldestDocument: boolean;
|
||||
haveOldestMedia: boolean;
|
||||
haveOldestLink: boolean;
|
||||
loading: boolean;
|
||||
media: ReadonlyArray<MediaItemType>;
|
||||
documents: ReadonlyArray<MediaItemType>;
|
||||
links: ReadonlyArray<LinkPreviewMediaItemType>;
|
||||
}>;
|
||||
|
||||
const FETCH_CHUNK_COUNT = 50;
|
||||
|
||||
const INITIAL_LOAD = 'mediaGallery/INITIAL_LOAD';
|
||||
const LOAD_MORE_MEDIA = 'mediaGallery/LOAD_MORE_MEDIA';
|
||||
const LOAD_MORE_DOCUMENTS = 'mediaGallery/LOAD_MORE_DOCUMENTS';
|
||||
const LOAD_MORE = 'mediaGallery/LOAD_MORE';
|
||||
const SET_LOADING = 'mediaGallery/SET_LOADING';
|
||||
|
||||
type InitialLoadActionType = ReadonlyDeep<{
|
||||
@@ -54,20 +63,16 @@ type InitialLoadActionType = ReadonlyDeep<{
|
||||
conversationId: string;
|
||||
documents: ReadonlyArray<MediaItemType>;
|
||||
media: ReadonlyArray<MediaItemType>;
|
||||
links: ReadonlyArray<LinkPreviewMediaItemType>;
|
||||
};
|
||||
}>;
|
||||
type LoadMoreMediaActionType = ReadonlyDeep<{
|
||||
type: typeof LOAD_MORE_MEDIA;
|
||||
type LoadMoreActionType = ReadonlyDeep<{
|
||||
type: typeof LOAD_MORE;
|
||||
payload: {
|
||||
conversationId: string;
|
||||
media: ReadonlyArray<MediaItemType>;
|
||||
};
|
||||
}>;
|
||||
type LoadMoreDocumentsActionType = ReadonlyDeep<{
|
||||
type: typeof LOAD_MORE_DOCUMENTS;
|
||||
payload: {
|
||||
conversationId: string;
|
||||
documents: ReadonlyArray<MediaItemType>;
|
||||
links: ReadonlyArray<LinkPreviewMediaItemType>;
|
||||
};
|
||||
}>;
|
||||
type SetLoadingActionType = ReadonlyDeep<{
|
||||
@@ -80,34 +85,30 @@ type SetLoadingActionType = ReadonlyDeep<{
|
||||
type MediaGalleryActionType = ReadonlyDeep<
|
||||
| ConversationUnloadedActionType
|
||||
| InitialLoadActionType
|
||||
| LoadMoreDocumentsActionType
|
||||
| LoadMoreMediaActionType
|
||||
| LoadMoreActionType
|
||||
| MessageChangedActionType
|
||||
| MessageDeletedActionType
|
||||
| MessageExpiredActionType
|
||||
| SetLoadingActionType
|
||||
>;
|
||||
|
||||
function _sortMedia(
|
||||
media: ReadonlyArray<MediaItemType>
|
||||
): ReadonlyArray<MediaItemType> {
|
||||
return orderBy(media, [
|
||||
function _sortItems<
|
||||
Item extends ReadonlyDeep<{ message: MediaItemMessageType }>,
|
||||
>(items: ReadonlyArray<Item>): ReadonlyArray<Item> {
|
||||
return orderBy(items, [
|
||||
'message.receivedAt',
|
||||
'message.sentAt',
|
||||
'message.index',
|
||||
]);
|
||||
}
|
||||
function _sortDocuments(
|
||||
documents: ReadonlyArray<MediaItemType>
|
||||
): ReadonlyArray<MediaItemType> {
|
||||
return orderBy(documents, ['message.receivedAt', 'message.sentAt']);
|
||||
}
|
||||
|
||||
function _cleanAttachments(
|
||||
type: 'media' | 'document',
|
||||
rawMedia: ReadonlyArray<MediaItemDBType>
|
||||
): ReadonlyArray<MediaItemType> {
|
||||
return rawMedia.map(({ message, index, attachment }) => {
|
||||
return {
|
||||
type,
|
||||
index,
|
||||
attachment: getPropsForAttachment(attachment, 'attachment', message),
|
||||
message,
|
||||
@@ -115,6 +116,24 @@ function _cleanAttachments(
|
||||
});
|
||||
}
|
||||
|
||||
function _cleanLinkPreviews(
|
||||
rawPreviews: ReadonlyArray<LinkPreviewMediaItemDBType>
|
||||
): ReadonlyArray<LinkPreviewMediaItemType> {
|
||||
return rawPreviews.map(({ message, preview }) => {
|
||||
return {
|
||||
type: 'link',
|
||||
preview: {
|
||||
...preview,
|
||||
image:
|
||||
preview.image == null
|
||||
? undefined
|
||||
: getPropsForAttachment(preview.image, 'preview', message),
|
||||
},
|
||||
message,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function initialLoad(
|
||||
conversationId: string
|
||||
): ThunkAction<
|
||||
@@ -129,19 +148,26 @@ function initialLoad(
|
||||
payload: { loading: true },
|
||||
});
|
||||
|
||||
const rawMedia = await DataReader.getOlderMedia({
|
||||
conversationId,
|
||||
limit: FETCH_CHUNK_COUNT,
|
||||
type: 'media',
|
||||
});
|
||||
const rawDocuments = await DataReader.getOlderMedia({
|
||||
conversationId,
|
||||
limit: FETCH_CHUNK_COUNT,
|
||||
type: 'files',
|
||||
});
|
||||
const [rawMedia, rawDocuments, rawLinkPreviews] = await Promise.all([
|
||||
DataReader.getOlderMedia({
|
||||
conversationId,
|
||||
limit: FETCH_CHUNK_COUNT,
|
||||
type: 'media',
|
||||
}),
|
||||
DataReader.getOlderMedia({
|
||||
conversationId,
|
||||
limit: FETCH_CHUNK_COUNT,
|
||||
type: 'documents',
|
||||
}),
|
||||
DataReader.getOlderLinkPreviews({
|
||||
conversationId,
|
||||
limit: FETCH_CHUNK_COUNT,
|
||||
}),
|
||||
]);
|
||||
|
||||
const media = _cleanAttachments(rawMedia);
|
||||
const documents = _cleanAttachments(rawDocuments);
|
||||
const media = _cleanAttachments('media', rawMedia);
|
||||
const documents = _cleanAttachments('document', rawDocuments);
|
||||
const links = _cleanLinkPreviews(rawLinkPreviews);
|
||||
|
||||
dispatch({
|
||||
type: INITIAL_LOAD,
|
||||
@@ -149,32 +175,45 @@ function initialLoad(
|
||||
conversationId,
|
||||
documents,
|
||||
media,
|
||||
links,
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function loadMoreMedia(
|
||||
conversationId: string
|
||||
function loadMore(
|
||||
conversationId: string,
|
||||
type: 'media' | 'documents' | 'links'
|
||||
): ThunkAction<
|
||||
void,
|
||||
RootStateType,
|
||||
unknown,
|
||||
InitialLoadActionType | LoadMoreMediaActionType | SetLoadingActionType
|
||||
InitialLoadActionType | LoadMoreActionType | SetLoadingActionType
|
||||
> {
|
||||
return async (dispatch, getState) => {
|
||||
const { conversationId: previousConversationId, media: previousMedia } =
|
||||
getState().mediaGallery;
|
||||
const { mediaGallery } = getState();
|
||||
const { conversationId: previousConversationId } = mediaGallery;
|
||||
|
||||
if (conversationId !== previousConversationId) {
|
||||
log.warn('loadMoreMedia: conversationId mismatch; calling initialLoad()');
|
||||
log.warn('loadMore: conversationId mismatch; calling initialLoad()');
|
||||
initialLoad(conversationId)(dispatch, getState, {});
|
||||
return;
|
||||
}
|
||||
|
||||
const oldestLoadedMedia = previousMedia[0];
|
||||
if (!oldestLoadedMedia) {
|
||||
log.warn('loadMoreMedia: no previous media; calling initialLoad()');
|
||||
let previousItems: ReadonlyArray<MediaItemType | LinkPreviewMediaItemType>;
|
||||
if (type === 'media') {
|
||||
previousItems = mediaGallery.media;
|
||||
} else if (type === 'documents') {
|
||||
previousItems = mediaGallery.documents;
|
||||
} else if (type === 'links') {
|
||||
previousItems = mediaGallery.links;
|
||||
} else {
|
||||
throw missingCaseError(type);
|
||||
}
|
||||
|
||||
const oldestLoadedItem = previousItems[0];
|
||||
if (!oldestLoadedItem) {
|
||||
log.warn('loadMore: no previous media; calling initialLoad()');
|
||||
initialLoad(conversationId)(dispatch, getState, {});
|
||||
return;
|
||||
}
|
||||
@@ -184,83 +223,46 @@ function loadMoreMedia(
|
||||
payload: { loading: true },
|
||||
});
|
||||
|
||||
const { sentAt, receivedAt, id: messageId } = oldestLoadedMedia.message;
|
||||
const { sentAt, receivedAt, id: messageId } = oldestLoadedItem.message;
|
||||
|
||||
const rawMedia = await DataReader.getOlderMedia({
|
||||
const sharedOptions = {
|
||||
conversationId,
|
||||
limit: FETCH_CHUNK_COUNT,
|
||||
messageId,
|
||||
receivedAt,
|
||||
sentAt,
|
||||
type: 'media',
|
||||
});
|
||||
};
|
||||
|
||||
const media = _cleanAttachments(rawMedia);
|
||||
let media: ReadonlyArray<MediaItemType> = [];
|
||||
let documents: ReadonlyArray<MediaItemType> = [];
|
||||
let links: ReadonlyArray<LinkPreviewMediaItemType> = [];
|
||||
if (type === 'media') {
|
||||
const rawMedia = await DataReader.getOlderMedia({
|
||||
...sharedOptions,
|
||||
type: 'media',
|
||||
});
|
||||
|
||||
media = _cleanAttachments('media', rawMedia);
|
||||
} else if (type === 'documents') {
|
||||
const rawDocuments = await DataReader.getOlderMedia({
|
||||
...sharedOptions,
|
||||
type: 'documents',
|
||||
});
|
||||
documents = _cleanAttachments('document', rawDocuments);
|
||||
} else if (type === 'links') {
|
||||
const rawPreviews = await DataReader.getOlderLinkPreviews(sharedOptions);
|
||||
links = _cleanLinkPreviews(rawPreviews);
|
||||
} else {
|
||||
throw missingCaseError(type);
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: LOAD_MORE_MEDIA,
|
||||
type: LOAD_MORE,
|
||||
payload: {
|
||||
conversationId,
|
||||
media,
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function loadMoreDocuments(
|
||||
conversationId: string
|
||||
): ThunkAction<
|
||||
void,
|
||||
RootStateType,
|
||||
unknown,
|
||||
InitialLoadActionType | LoadMoreDocumentsActionType | SetLoadingActionType
|
||||
> {
|
||||
return async (dispatch, getState) => {
|
||||
const {
|
||||
conversationId: previousConversationId,
|
||||
documents: previousDocuments,
|
||||
} = getState().mediaGallery;
|
||||
|
||||
if (conversationId !== previousConversationId) {
|
||||
log.warn(
|
||||
'loadMoreDocuments: conversationId mismatch; calling initialLoad()'
|
||||
);
|
||||
initialLoad(conversationId)(dispatch, getState, {});
|
||||
return;
|
||||
}
|
||||
|
||||
const oldestLoadedDocument = previousDocuments[0];
|
||||
if (!oldestLoadedDocument) {
|
||||
log.warn(
|
||||
'loadMoreDocuments: no previous documents; calling initialLoad()'
|
||||
);
|
||||
initialLoad(conversationId)(dispatch, getState, {});
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: SET_LOADING,
|
||||
payload: { loading: true },
|
||||
});
|
||||
|
||||
const { sentAt, receivedAt, id: messageId } = oldestLoadedDocument.message;
|
||||
|
||||
const rawDocuments = await DataReader.getOlderMedia({
|
||||
conversationId,
|
||||
limit: FETCH_CHUNK_COUNT,
|
||||
messageId,
|
||||
receivedAt,
|
||||
sentAt,
|
||||
type: 'files',
|
||||
});
|
||||
|
||||
const documents = _cleanAttachments(rawDocuments);
|
||||
|
||||
dispatch({
|
||||
type: LOAD_MORE_DOCUMENTS,
|
||||
payload: {
|
||||
conversationId,
|
||||
documents,
|
||||
links,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -268,8 +270,7 @@ function loadMoreDocuments(
|
||||
|
||||
export const actions = {
|
||||
initialLoad,
|
||||
loadMoreMedia,
|
||||
loadMoreDocuments,
|
||||
loadMore,
|
||||
};
|
||||
|
||||
export const useMediaGalleryActions = (): BoundActionCreatorsMapObject<
|
||||
@@ -279,11 +280,13 @@ export const useMediaGalleryActions = (): BoundActionCreatorsMapObject<
|
||||
export function getEmptyState(): MediaGalleryStateType {
|
||||
return {
|
||||
conversationId: undefined,
|
||||
documents: [],
|
||||
haveOldestDocument: false,
|
||||
haveOldestMedia: false,
|
||||
haveOldestLink: false,
|
||||
loading: true,
|
||||
media: [],
|
||||
documents: [],
|
||||
links: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -307,15 +310,17 @@ export function reducer(
|
||||
...state,
|
||||
loading: false,
|
||||
conversationId: payload.conversationId,
|
||||
media: _sortMedia(payload.media),
|
||||
documents: _sortDocuments(payload.documents),
|
||||
haveOldestMedia: payload.media.length === 0,
|
||||
haveOldestDocument: payload.documents.length === 0,
|
||||
haveOldestLink: payload.links.length === 0,
|
||||
media: _sortItems(payload.media),
|
||||
documents: _sortItems(payload.documents),
|
||||
links: _sortItems(payload.links),
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === LOAD_MORE_MEDIA) {
|
||||
const { conversationId, media } = action.payload;
|
||||
if (action.type === LOAD_MORE) {
|
||||
const { conversationId, media, documents, links } = action.payload;
|
||||
if (state.conversationId !== conversationId) {
|
||||
return state;
|
||||
}
|
||||
@@ -324,21 +329,11 @@ export function reducer(
|
||||
...state,
|
||||
loading: false,
|
||||
haveOldestMedia: media.length === 0,
|
||||
media: _sortMedia(media.concat(state.media)),
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === LOAD_MORE_DOCUMENTS) {
|
||||
const { conversationId, documents } = action.payload;
|
||||
if (state.conversationId !== conversationId) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
loading: false,
|
||||
haveOldestDocument: documents.length === 0,
|
||||
documents: _sortDocuments(documents.concat(state.documents)),
|
||||
haveOldestLink: links.length === 0,
|
||||
media: _sortItems(media.concat(state.media)),
|
||||
documents: _sortItems(documents.concat(state.documents)),
|
||||
links: _sortItems(links.concat(state.links)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -359,8 +354,12 @@ export function reducer(
|
||||
const documentsWithout = state.documents.filter(
|
||||
item => item.message.id !== message.id
|
||||
);
|
||||
const linksWithout = state.links.filter(
|
||||
item => item.message.id !== message.id
|
||||
);
|
||||
const mediaDifference = state.media.length - mediaWithout.length;
|
||||
const documentDifference = state.documents.length - documentsWithout.length;
|
||||
const linkDifference = state.links.length - linksWithout.length;
|
||||
|
||||
if (message.deletedForEveryone || message.isErased) {
|
||||
if (mediaDifference > 0 || documentDifference > 0) {
|
||||
@@ -368,6 +367,7 @@ export function reducer(
|
||||
...state,
|
||||
media: mediaWithout,
|
||||
documents: documentsWithout,
|
||||
links: linksWithout,
|
||||
};
|
||||
}
|
||||
return state;
|
||||
@@ -375,6 +375,7 @@ export function reducer(
|
||||
|
||||
const oldestLoadedMedia = state.media[0];
|
||||
const oldestLoadedDocument = state.documents[0];
|
||||
const oldestLoadedLink = state.links[0];
|
||||
|
||||
const messageMediaItems: Array<MediaItemDBType> = (
|
||||
message.attachments ?? []
|
||||
@@ -385,6 +386,8 @@ export function reducer(
|
||||
message: {
|
||||
id: message.id,
|
||||
type: message.type,
|
||||
source: message.source,
|
||||
sourceServiceId: message.sourceServiceId,
|
||||
conversationId: message.conversationId,
|
||||
receivedAt: message.received_at,
|
||||
receivedAtMs: message.received_at_ms,
|
||||
@@ -394,20 +397,48 @@ export function reducer(
|
||||
});
|
||||
|
||||
const newMedia = _cleanAttachments(
|
||||
'media',
|
||||
messageMediaItems.filter(({ attachment }) => isVisualMedia(attachment))
|
||||
);
|
||||
const newDocuments = _cleanAttachments(
|
||||
'document',
|
||||
messageMediaItems.filter(({ attachment }) => isFile(attachment))
|
||||
);
|
||||
const newLinks = _cleanLinkPreviews(
|
||||
message.preview != null && message.preview.length > 0
|
||||
? [
|
||||
{
|
||||
preview: message.preview[0],
|
||||
message: {
|
||||
id: message.id,
|
||||
type: message.type,
|
||||
source: message.source,
|
||||
sourceServiceId: message.sourceServiceId,
|
||||
conversationId: message.conversationId,
|
||||
receivedAt: message.received_at,
|
||||
receivedAtMs: message.received_at_ms,
|
||||
sentAt: message.sent_at,
|
||||
},
|
||||
},
|
||||
]
|
||||
: []
|
||||
);
|
||||
|
||||
let { documents, haveOldestDocument, haveOldestMedia, media } = state;
|
||||
let {
|
||||
documents,
|
||||
haveOldestDocument,
|
||||
haveOldestMedia,
|
||||
media,
|
||||
haveOldestLink,
|
||||
links,
|
||||
} = state;
|
||||
|
||||
const inMediaTimeRange =
|
||||
!oldestLoadedMedia ||
|
||||
(message.received_at >= oldestLoadedMedia.message.receivedAt &&
|
||||
message.sent_at >= oldestLoadedMedia.message.sentAt);
|
||||
if (mediaDifference !== media.length && inMediaTimeRange) {
|
||||
media = _sortMedia(mediaWithout.concat(newMedia));
|
||||
media = _sortItems(mediaWithout.concat(newMedia));
|
||||
} else if (!inMediaTimeRange) {
|
||||
haveOldestMedia = false;
|
||||
}
|
||||
@@ -417,11 +448,21 @@ export function reducer(
|
||||
(message.received_at >= oldestLoadedDocument.message.receivedAt &&
|
||||
message.sent_at >= oldestLoadedDocument.message.sentAt);
|
||||
if (documentDifference !== documents.length && inDocumentTimeRange) {
|
||||
documents = _sortDocuments(documentsWithout.concat(newDocuments));
|
||||
documents = _sortItems(documentsWithout.concat(newDocuments));
|
||||
} else if (!inDocumentTimeRange) {
|
||||
haveOldestDocument = false;
|
||||
}
|
||||
|
||||
const inLinkTimeRange =
|
||||
!oldestLoadedLink ||
|
||||
(message.received_at >= oldestLoadedLink.message.receivedAt &&
|
||||
message.sent_at >= oldestLoadedLink.message.sentAt);
|
||||
if (linkDifference !== links.length && inLinkTimeRange) {
|
||||
links = _sortItems(linksWithout.concat(newLinks));
|
||||
} else if (!inLinkTimeRange) {
|
||||
haveOldestLink = false;
|
||||
}
|
||||
|
||||
if (
|
||||
state.haveOldestDocument !== haveOldestDocument ||
|
||||
state.haveOldestMedia !== haveOldestMedia ||
|
||||
@@ -433,6 +474,7 @@ export function reducer(
|
||||
documents,
|
||||
haveOldestDocument,
|
||||
haveOldestMedia,
|
||||
haveOldestLink,
|
||||
media,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,18 +8,32 @@ import { getIntl, getTheme } from '../selectors/user.std.js';
|
||||
import { useConversationsActions } from '../ducks/conversations.preload.js';
|
||||
import { useLightboxActions } from '../ducks/lightbox.preload.js';
|
||||
import { useMediaGalleryActions } from '../ducks/mediaGallery.preload.js';
|
||||
import {
|
||||
SmartLinkPreviewItem,
|
||||
type PropsType as LinkPreviewItemPropsType,
|
||||
} from './LinkPreviewItem.dom.js';
|
||||
|
||||
export type PropsType = {
|
||||
conversationId: string;
|
||||
};
|
||||
|
||||
function renderLinkPreviewItem(props: LinkPreviewItemPropsType): JSX.Element {
|
||||
return <SmartLinkPreviewItem {...props} />;
|
||||
}
|
||||
|
||||
export const SmartAllMedia = memo(function SmartAllMedia({
|
||||
conversationId,
|
||||
}: PropsType) {
|
||||
const { media, documents, haveOldestDocument, haveOldestMedia, loading } =
|
||||
useSelector(getMediaGalleryState);
|
||||
const { initialLoad, loadMoreMedia, loadMoreDocuments } =
|
||||
useMediaGalleryActions();
|
||||
const {
|
||||
media,
|
||||
documents,
|
||||
links,
|
||||
haveOldestDocument,
|
||||
haveOldestMedia,
|
||||
haveOldestLink,
|
||||
loading,
|
||||
} = useSelector(getMediaGalleryState);
|
||||
const { initialLoad, loadMore } = useMediaGalleryActions();
|
||||
const {
|
||||
saveAttachment,
|
||||
kickOffAttachmentDownload,
|
||||
@@ -34,18 +48,20 @@ export const SmartAllMedia = memo(function SmartAllMedia({
|
||||
conversationId={conversationId}
|
||||
haveOldestDocument={haveOldestDocument}
|
||||
haveOldestMedia={haveOldestMedia}
|
||||
haveOldestLink={haveOldestLink}
|
||||
i18n={i18n}
|
||||
theme={theme}
|
||||
initialLoad={initialLoad}
|
||||
loading={loading}
|
||||
loadMoreMedia={loadMoreMedia}
|
||||
loadMoreDocuments={loadMoreDocuments}
|
||||
loadMore={loadMore}
|
||||
media={media}
|
||||
documents={documents}
|
||||
links={links}
|
||||
showLightbox={showLightbox}
|
||||
kickOffAttachmentDownload={kickOffAttachmentDownload}
|
||||
cancelAttachmentDownload={cancelAttachmentDownload}
|
||||
saveAttachment={saveAttachment}
|
||||
renderLinkPreviewItem={renderLinkPreviewItem}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
42
ts/state/smart/LinkPreviewItem.dom.tsx
Normal file
42
ts/state/smart/LinkPreviewItem.dom.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import React, { memo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { LinkPreviewItem } from '../../components/conversation/media-gallery/LinkPreviewItem.dom.js';
|
||||
import { getSafeDomain } from '../../types/LinkPreview.std.js';
|
||||
import type { DataProps as PropsType } from '../../components/conversation/media-gallery/LinkPreviewItem.dom.js';
|
||||
import { getIntl, getTheme } from '../selectors/user.std.js';
|
||||
import { getConversationSelector } from '../selectors/conversations.dom.js';
|
||||
|
||||
export { PropsType };
|
||||
|
||||
export const SmartLinkPreviewItem = memo(function SmartLinkPreviewItem({
|
||||
mediaItem,
|
||||
onClick,
|
||||
}: PropsType) {
|
||||
const i18n = useSelector(getIntl);
|
||||
const theme = useSelector(getTheme);
|
||||
const getConversation = useSelector(getConversationSelector);
|
||||
|
||||
const author = getConversation(
|
||||
mediaItem.message.sourceServiceId ?? mediaItem.message.source
|
||||
);
|
||||
|
||||
const hydratedMediaItem = {
|
||||
...mediaItem,
|
||||
preview: {
|
||||
...mediaItem.preview,
|
||||
domain: getSafeDomain(mediaItem.preview.url),
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<LinkPreviewItem
|
||||
i18n={i18n}
|
||||
theme={theme}
|
||||
authorTitle={author.title}
|
||||
mediaItem={hydratedMediaItem}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -22,14 +22,17 @@ const testDate = (
|
||||
|
||||
const toMediaItem = (id: string, date: Date): MediaItemType => {
|
||||
return {
|
||||
type: 'media',
|
||||
index: 0,
|
||||
message: {
|
||||
type: 'incoming',
|
||||
conversationId: '1234',
|
||||
id: 'id',
|
||||
id,
|
||||
receivedAt: date.getTime(),
|
||||
receivedAtMs: date.getTime(),
|
||||
sentAt: date.getTime(),
|
||||
source: undefined,
|
||||
sourceServiceId: undefined,
|
||||
},
|
||||
attachment: fakeAttachment({
|
||||
fileName: 'fileName',
|
||||
@@ -63,35 +66,35 @@ describe('groupMediaItemsByDate', () => {
|
||||
|
||||
assert.strictEqual(actual[0].type, 'today');
|
||||
assert.strictEqual(actual[0].mediaItems.length, 2, 'today');
|
||||
assert.strictEqual(actual[0].mediaItems[0].attachment.url, 'today-1');
|
||||
assert.strictEqual(actual[0].mediaItems[1].attachment.url, 'today-2');
|
||||
assert.strictEqual(actual[0].mediaItems[0].message.id, 'today-1');
|
||||
assert.strictEqual(actual[0].mediaItems[1].message.id, 'today-2');
|
||||
|
||||
assert.strictEqual(actual[1].type, 'yesterday');
|
||||
assert.strictEqual(actual[1].mediaItems.length, 2, 'yesterday');
|
||||
assert.strictEqual(actual[1].mediaItems[0].attachment.url, 'yesterday-1');
|
||||
assert.strictEqual(actual[1].mediaItems[1].attachment.url, 'yesterday-2');
|
||||
assert.strictEqual(actual[1].mediaItems[0].message.id, 'yesterday-1');
|
||||
assert.strictEqual(actual[1].mediaItems[1].message.id, 'yesterday-2');
|
||||
|
||||
assert.strictEqual(actual[2].type, 'thisWeek');
|
||||
assert.strictEqual(actual[2].mediaItems.length, 4, 'thisWeek');
|
||||
assert.strictEqual(actual[2].mediaItems[0].attachment.url, 'thisWeek-1');
|
||||
assert.strictEqual(actual[2].mediaItems[1].attachment.url, 'thisWeek-2');
|
||||
assert.strictEqual(actual[2].mediaItems[2].attachment.url, 'thisWeek-3');
|
||||
assert.strictEqual(actual[2].mediaItems[3].attachment.url, 'thisWeek-4');
|
||||
assert.strictEqual(actual[2].mediaItems[0].message.id, 'thisWeek-1');
|
||||
assert.strictEqual(actual[2].mediaItems[1].message.id, 'thisWeek-2');
|
||||
assert.strictEqual(actual[2].mediaItems[2].message.id, 'thisWeek-3');
|
||||
assert.strictEqual(actual[2].mediaItems[3].message.id, 'thisWeek-4');
|
||||
|
||||
assert.strictEqual(actual[3].type, 'thisMonth');
|
||||
assert.strictEqual(actual[3].mediaItems.length, 2, 'thisMonth');
|
||||
assert.strictEqual(actual[3].mediaItems[0].attachment.url, 'thisMonth-1');
|
||||
assert.strictEqual(actual[3].mediaItems[1].attachment.url, 'thisMonth-2');
|
||||
assert.strictEqual(actual[3].mediaItems[0].message.id, 'thisMonth-1');
|
||||
assert.strictEqual(actual[3].mediaItems[1].message.id, 'thisMonth-2');
|
||||
|
||||
assert.strictEqual(actual[4].type, 'yearMonth');
|
||||
assert.strictEqual(actual[4].mediaItems.length, 2, 'mar2024');
|
||||
assert.strictEqual(actual[4].mediaItems[0].attachment.url, 'mar2024-1');
|
||||
assert.strictEqual(actual[4].mediaItems[1].attachment.url, 'mar2024-2');
|
||||
assert.strictEqual(actual[4].mediaItems[0].message.id, 'mar2024-1');
|
||||
assert.strictEqual(actual[4].mediaItems[1].message.id, 'mar2024-2');
|
||||
|
||||
assert.strictEqual(actual[5].type, 'yearMonth');
|
||||
assert.strictEqual(actual[5].mediaItems.length, 2, 'feb2011');
|
||||
assert.strictEqual(actual[5].mediaItems[0].attachment.url, 'feb2011-1');
|
||||
assert.strictEqual(actual[5].mediaItems[1].attachment.url, 'feb2011-2');
|
||||
assert.strictEqual(actual[5].mediaItems[0].message.id, 'feb2011-1');
|
||||
assert.strictEqual(actual[5].mediaItems[1].message.id, 'feb2011-2');
|
||||
|
||||
assert.strictEqual(actual.length, 6, 'total sections');
|
||||
});
|
||||
|
||||
@@ -1,20 +1,33 @@
|
||||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { AttachmentForUIType } from './Attachment.std.js';
|
||||
import type { MessageAttributesType } from '../model-types.d.ts';
|
||||
import type { AttachmentForUIType } from './Attachment.std.js';
|
||||
import type { LinkPreviewForUIType } from './message/LinkPreviews.std.js';
|
||||
import type { ServiceIdString } from './ServiceId.std.js';
|
||||
|
||||
export type MediaItemMessageType = Readonly<{
|
||||
id: string;
|
||||
type: MessageAttributesType['type'];
|
||||
conversationId: string;
|
||||
receivedAt: number;
|
||||
receivedAtMs?: number;
|
||||
receivedAtMs: number | undefined;
|
||||
sentAt: number;
|
||||
source: string | undefined;
|
||||
sourceServiceId: ServiceIdString | undefined;
|
||||
}>;
|
||||
|
||||
export type MediaItemType = {
|
||||
type: 'media' | 'document';
|
||||
attachment: AttachmentForUIType;
|
||||
index: number;
|
||||
message: MediaItemMessageType;
|
||||
};
|
||||
|
||||
export type LinkPreviewMediaItemType = Readonly<{
|
||||
type: 'link';
|
||||
preview: LinkPreviewForUIType;
|
||||
message: MediaItemMessageType;
|
||||
}>;
|
||||
|
||||
export type GenericMediaItemType = MediaItemType | LinkPreviewMediaItemType;
|
||||
|
||||
Reference in New Issue
Block a user