Skip to content

Commit 7d7571b

Browse files
committed
refactor: create AttachmentUploadedSizeIndicator component
1 parent b208d12 commit 7d7571b

File tree

5 files changed

+214
-77
lines changed

5 files changed

+214
-77
lines changed
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import React from 'react';
2+
import { FileSizeIndicator } from '../../Attachment';
3+
import { prettifyFileSize } from '../hooks/utils';
4+
5+
function safePrettifyFileSize(bytes: number, maximumFractionDigits?: number): string {
6+
if (!Number.isFinite(bytes) || bytes < 0) return '';
7+
if (bytes === 0) return '0 B';
8+
return prettifyFileSize(bytes, maximumFractionDigits);
9+
}
10+
11+
function formatUploadByteFraction(
12+
uploadPercent: number,
13+
fullBytes: number,
14+
maximumFractionDigits?: number,
15+
): string {
16+
const uploaded = Math.round((uploadPercent / 100) * fullBytes);
17+
return `${safePrettifyFileSize(uploaded, maximumFractionDigits)} / ${safePrettifyFileSize(fullBytes, maximumFractionDigits)}`;
18+
}
19+
20+
function resolveAttachmentFullByteSize(attachment: {
21+
file_size?: number | string;
22+
localMetadata?: { file?: { size?: unknown } } | null;
23+
}): number | undefined {
24+
const fromFile = attachment.localMetadata?.file?.size;
25+
if (typeof fromFile === 'number' && Number.isFinite(fromFile) && fromFile >= 0) {
26+
return fromFile;
27+
}
28+
const raw = attachment.file_size;
29+
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return raw;
30+
if (typeof raw === 'string') {
31+
const n = parseFloat(raw);
32+
if (Number.isFinite(n) && n >= 0) return n;
33+
}
34+
return undefined;
35+
}
36+
37+
export type AttachmentUploadedSizeIndicatorProps = {
38+
attachment: {
39+
file_size?: number | string;
40+
localMetadata?: {
41+
file?: { size?: unknown };
42+
uploadProgress?: number;
43+
uploadState?: string;
44+
} | null;
45+
};
46+
};
47+
48+
export const AttachmentUploadedSizeIndicator = ({
49+
attachment,
50+
}: AttachmentUploadedSizeIndicatorProps) => {
51+
const { uploadProgress, uploadState } = attachment.localMetadata ?? {};
52+
const fullBytes = resolveAttachmentFullByteSize(attachment);
53+
54+
if (
55+
uploadState === 'uploading' &&
56+
uploadProgress !== undefined &&
57+
fullBytes !== undefined
58+
) {
59+
return (
60+
<span
61+
className='str-chat__attachment-preview-file__upload-size-fraction'
62+
data-testid='upload-size-fraction'
63+
>
64+
{formatUploadByteFraction(uploadProgress, fullBytes)}
65+
</span>
66+
);
67+
}
68+
69+
if (uploadState === 'finished') {
70+
return <FileSizeIndicator fileSize={attachment.file_size} />;
71+
}
72+
73+
return null;
74+
};

src/components/MessageComposer/AttachmentPreviewList/AudioAttachmentPreview.tsx

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import clsx from 'clsx';
1010
import { AttachmentUploadProgressIndicator } from './AttachmentUploadProgressIndicator';
1111
import { RemoveAttachmentPreviewButton } from '../RemoveAttachmentPreviewButton';
1212
import { AttachmentPreviewRoot } from './utils/AttachmentPreviewRoot';
13-
import { FileSizeIndicator } from '../../Attachment';
1413
import { IconExclamationCircle, IconExclamationTriangle } from '../../Icons';
1514
import { PlayButton } from '../../Button';
1615
import {
@@ -21,10 +20,7 @@ import {
2120
} from '../../AudioPlayback';
2221
import { useAudioPlayer } from '../../AudioPlayback/WithAudioPlayback';
2322
import { useStateStore } from '../../../store';
24-
import {
25-
formatUploadByteFraction,
26-
resolveAttachmentFullByteSize,
27-
} from './utils/uploadProgress';
23+
import { AttachmentUploadedSizeIndicator } from './AttachmentUploadedSizeIndicator';
2824

2925
export type AudioAttachmentPreviewProps<CustomLocalMetadata = Record<string, unknown>> =
3026
UploadAttachmentPreviewProps<
@@ -48,11 +44,6 @@ export const AudioAttachmentPreview = ({
4844
const { t } = useTranslationContext();
4945
const { id, previewUri, uploadPermissionCheck, uploadProgress, uploadState } =
5046
attachment.localMetadata ?? {};
51-
const fullBytes = resolveAttachmentFullByteSize(attachment);
52-
const showUploadFraction =
53-
uploadState === 'uploading' &&
54-
uploadProgress !== undefined &&
55-
fullBytes !== undefined;
5647
const url = attachment.asset_url || previewUri;
5748

5849
const audioPlayer = useAudioPlayer({
@@ -111,18 +102,7 @@ export const AudioAttachmentPreview = ({
111102
{showProgressControls ? (
112103
<>
113104
{!resolvedDuration && !progressPercent && !isPlaying && (
114-
<>
115-
{showUploadFraction ? (
116-
<span
117-
className='str-chat__attachment-preview-file__upload-size-fraction'
118-
data-testid='upload-size-fraction'
119-
>
120-
{formatUploadByteFraction(uploadProgress, fullBytes)}
121-
</span>
122-
) : (
123-
<FileSizeIndicator fileSize={attachment.file_size} />
124-
)}
125-
</>
105+
<AttachmentUploadedSizeIndicator attachment={attachment} />
126106
)}
127107
{hasWaveform ? (
128108
<>

src/components/MessageComposer/AttachmentPreviewList/FileAttachmentPreview.tsx

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,11 @@ import React from 'react';
22
import { useTranslationContext } from '../../../context';
33
import { FileIcon } from '../../FileIcon';
44
import { AttachmentUploadProgressIndicator } from './AttachmentUploadProgressIndicator';
5+
import { AttachmentUploadedSizeIndicator } from './AttachmentUploadedSizeIndicator';
56
import type { LocalAudioAttachment, LocalFileAttachment } from 'stream-chat';
67
import type { UploadAttachmentPreviewProps } from './types';
78
import { RemoveAttachmentPreviewButton } from '../RemoveAttachmentPreviewButton';
89
import { AttachmentPreviewRoot } from './utils/AttachmentPreviewRoot';
9-
import {
10-
formatUploadByteFraction,
11-
resolveAttachmentFullByteSize,
12-
} from './utils/uploadProgress';
13-
import { FileSizeIndicator } from '../../Attachment';
1410
import { IconExclamationCircle, IconExclamationTriangle } from '../../Icons';
1511

1612
export type FileAttachmentPreviewProps<CustomLocalMetadata = unknown> =
@@ -26,16 +22,10 @@ export const FileAttachmentPreview = ({
2622
const { t } = useTranslationContext('FilePreview');
2723
const { id, uploadPermissionCheck, uploadProgress, uploadState } =
2824
attachment.localMetadata ?? {};
29-
const fullBytes = resolveAttachmentFullByteSize(attachment);
30-
const showUploadFraction =
31-
uploadState === 'uploading' &&
32-
uploadProgress !== undefined &&
33-
fullBytes !== undefined;
3425

3526
const hasSizeLimitError = uploadPermissionCheck?.reason === 'size_limit';
3627
const hasFatalError = uploadState === 'blocked' || hasSizeLimitError;
3728
const hasRetriableError = uploadState === 'failed' && !!handleRetry;
38-
const hasError = hasRetriableError || hasFatalError;
3929

4030
return (
4131
<AttachmentPreviewRoot
@@ -58,17 +48,7 @@ export const FileAttachmentPreview = ({
5848
variant='inline'
5949
/>
6050
)}
61-
{!hasError && showUploadFraction && (
62-
<span
63-
className='str-chat__attachment-preview-file__upload-size-fraction'
64-
data-testid='upload-size-fraction'
65-
>
66-
{formatUploadByteFraction(uploadProgress, fullBytes)}
67-
</span>
68-
)}
69-
{!hasError && !showUploadFraction && (
70-
<FileSizeIndicator fileSize={attachment.file_size} />
71-
)}
51+
<AttachmentUploadedSizeIndicator attachment={attachment} />
7252
{hasFatalError && (
7353
<div className='str-chat__attachment-preview-file__fatal-error'>
7454
<IconExclamationCircle />

src/components/MessageComposer/AttachmentPreviewList/utils/uploadProgress.ts

Lines changed: 0 additions & 33 deletions
This file was deleted.
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import React from 'react';
2+
import { render, screen } from '@testing-library/react';
3+
import { AttachmentUploadedSizeIndicator } from '../AttachmentPreviewList/AttachmentUploadedSizeIndicator';
4+
5+
describe('AttachmentUploadedSizeIndicator', () => {
6+
it('renders nothing when upload state is not uploading or finished', () => {
7+
const { container } = render(
8+
<AttachmentUploadedSizeIndicator
9+
attachment={{
10+
file_size: 100,
11+
localMetadata: { uploadState: 'failed' },
12+
}}
13+
/>,
14+
);
15+
16+
expect(container.firstChild).toBeNull();
17+
});
18+
19+
it('renders nothing when uploading without uploadProgress', () => {
20+
const { container } = render(
21+
<AttachmentUploadedSizeIndicator
22+
attachment={{
23+
file_size: 1000,
24+
localMetadata: { uploadState: 'uploading' },
25+
}}
26+
/>,
27+
);
28+
29+
expect(container.firstChild).toBeNull();
30+
});
31+
32+
it('renders nothing when uploading without a resolvable full byte size', () => {
33+
const { container } = render(
34+
<AttachmentUploadedSizeIndicator
35+
attachment={{
36+
localMetadata: {
37+
uploadProgress: 50,
38+
uploadState: 'uploading',
39+
},
40+
}}
41+
/>,
42+
);
43+
44+
expect(container.firstChild).toBeNull();
45+
});
46+
47+
it('renders upload size fraction when uploading with numeric file_size and progress', () => {
48+
render(
49+
<AttachmentUploadedSizeIndicator
50+
attachment={{
51+
file_size: 1000,
52+
localMetadata: {
53+
uploadProgress: 50,
54+
uploadState: 'uploading',
55+
},
56+
}}
57+
/>,
58+
);
59+
60+
expect(screen.getByTestId('upload-size-fraction')).toHaveTextContent(
61+
'500 B / 1.00e+3 B',
62+
);
63+
expect(screen.getByTestId('upload-size-fraction')).toHaveClass(
64+
'str-chat__attachment-preview-file__upload-size-fraction',
65+
);
66+
});
67+
68+
it('parses string file_size for the upload fraction', () => {
69+
render(
70+
<AttachmentUploadedSizeIndicator
71+
attachment={{
72+
file_size: '1000',
73+
localMetadata: {
74+
uploadProgress: 50,
75+
uploadState: 'uploading',
76+
},
77+
}}
78+
/>,
79+
);
80+
81+
expect(screen.getByTestId('upload-size-fraction')).toHaveTextContent(
82+
'500 B / 1.00e+3 B',
83+
);
84+
});
85+
86+
it('prefers localMetadata.file.size over file_size when both are present', () => {
87+
render(
88+
<AttachmentUploadedSizeIndicator
89+
attachment={{
90+
file_size: 1000,
91+
localMetadata: {
92+
file: { size: 200 },
93+
uploadProgress: 50,
94+
uploadState: 'uploading',
95+
},
96+
}}
97+
/>,
98+
);
99+
100+
expect(screen.getByTestId('upload-size-fraction')).toHaveTextContent('100 B / 200 B');
101+
});
102+
103+
it('renders FileSizeIndicator when upload is finished', () => {
104+
render(
105+
<AttachmentUploadedSizeIndicator
106+
attachment={{
107+
file_size: 1024,
108+
localMetadata: { uploadState: 'finished' },
109+
}}
110+
/>,
111+
);
112+
113+
expect(screen.getByTestId('file-size-indicator')).toHaveTextContent('1.00 kB');
114+
});
115+
116+
it('renders nothing when finished but file_size is missing or invalid', () => {
117+
const { container: missing } = render(
118+
<AttachmentUploadedSizeIndicator
119+
attachment={{
120+
localMetadata: { uploadState: 'finished' },
121+
}}
122+
/>,
123+
);
124+
expect(missing.firstChild).toBeNull();
125+
126+
const { container: nanString } = render(
127+
<AttachmentUploadedSizeIndicator
128+
attachment={{
129+
file_size: 'not-a-number',
130+
localMetadata: { uploadState: 'finished' },
131+
}}
132+
/>,
133+
);
134+
expect(nanString.firstChild).toBeNull();
135+
});
136+
});

0 commit comments

Comments
 (0)