Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 49 additions & 5 deletions package/src/components/Attachment/Attachment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,17 @@ import {
isVideoAttachment,
isVoiceRecordingAttachment,
type Attachment as AttachmentType,
type LocalMessage,
} from 'stream-chat';

import { AudioAttachment as AudioAttachmentDefault } from './Audio';
import type { AudioAttachmentProps } from './Audio/AudioAttachment';

import { UnsupportedAttachment as UnsupportedAttachmentDefault } from './UnsupportedAttachment';
import { URLPreview as URLPreviewDefault } from './UrlPreview';
import { URLPreviewCompact as URLPreviewCompactDefault } from './UrlPreview/URLPreviewCompact';

import { AttachmentFileUploadProgressIndicator } from '../../components/Attachment/AttachmentFileUploadProgressIndicator';
import { FileAttachment as FileAttachmentDefault } from '../../components/Attachment/FileAttachment';
import { Gallery as GalleryDefault } from '../../components/Attachment/Gallery';
import { Giphy as GiphyDefault } from '../../components/Attachment/Giphy';
Expand All @@ -30,9 +33,11 @@ import {
MessagesContextValue,
useMessagesContext,
} from '../../contexts/messagesContext/MessagesContext';
import { usePendingAttachmentUpload } from '../../hooks/usePendingAttachmentUpload';
import { isSoundPackageAvailable, isVideoPlayerAvailable } from '../../native';

import { primitives } from '../../theme';
import type { DefaultAttachmentData } from '../../types/types';
import { FileTypes } from '../../types/types';

export type ActionHandler = (name: string, value: string) => void;
Expand Down Expand Up @@ -104,12 +109,12 @@ const AttachmentWithContext = (props: AttachmentPropsWithContext) => {
if (isAudioAttachment(attachment) || isVoiceRecordingAttachment(attachment)) {
if (isSoundPackageAvailable()) {
return (
<AudioAttachment
item={{ ...attachment, id: index?.toString() ?? '', type: attachment.type }}
<MessageAudioAttachment
AudioAttachment={AudioAttachment}
attachment={attachment}
audioAttachmentStyles={audioAttachmentStyles}
index={index}
message={message}
showSpeedSettings={true}
showTitle={false}
styles={audioAttachmentStyles}
/>
);
}
Expand Down Expand Up @@ -228,6 +233,45 @@ export const Attachment = (props: AttachmentProps) => {
);
};

type MessageAudioAttachmentProps = {
AudioAttachment: React.ComponentType<AudioAttachmentProps>;
attachment: AttachmentType;
audioAttachmentStyles: AudioAttachmentProps['styles'];
index?: number;
message: LocalMessage | undefined;
};

const MessageAudioAttachment = ({
AudioAttachment: AudioAttachmentComponent,
attachment,
audioAttachmentStyles,
index,
message,
}: MessageAudioAttachmentProps) => {
const localId = (attachment as DefaultAttachmentData).localId;
const { isUploading, uploadProgress } = usePendingAttachmentUpload(localId);

const indicator = isUploading ? (
<AttachmentFileUploadProgressIndicator
totalBytes={attachment.file_size}
uploadProgress={uploadProgress}
/>
) : undefined;

const audioItemType = isVoiceRecordingAttachment(attachment) ? 'voiceRecording' : 'audio';

return (
<AudioAttachmentComponent
indicator={indicator}
item={{ ...attachment, id: index?.toString() ?? '', type: audioItemType }}
message={message}
showSpeedSettings={true}
showTitle={false}
styles={audioAttachmentStyles}
/>
);
};

const useAudioAttachmentStyles = () => {
const {
theme: { semantics },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import React, { useMemo } from 'react';
import { StyleSheet, Text, View } from 'react-native';

import { AttachmentUploadIndicator } from './AttachmentUploadIndicator';

import { useTheme } from '../../contexts/themeContext/ThemeContext';
import { primitives } from '../../theme';

export type AttachmentFileUploadProgressIndicatorProps = {
totalBytes?: number | string | null;
uploadProgress: number | undefined;
};

const parseTotalBytes = (value: number | string | null | undefined): number | null => {
if (value == null) {
return null;
}
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const n = parseFloat(value);
return Number.isFinite(n) ? n : null;
}
return null;
};

const formatMegabytesOneDecimal = (bytes: number) => {
if (!Number.isFinite(bytes) || bytes <= 0) {
return '0.0 MB';
}
return `${(bytes / (1000 * 1000)).toFixed(1)} MB`;
};

/**
* Circular progress plus `uploaded / total` for file and audio attachments during upload.
*/
export const AttachmentFileUploadProgressIndicator = ({
totalBytes,
uploadProgress,
}: AttachmentFileUploadProgressIndicatorProps) => {
const {
theme: { semantics },
} = useTheme();

const progressLabel = useMemo(() => {
const bytes = parseTotalBytes(totalBytes);
if (bytes == null || bytes <= 0) {
return null;
}
const uploaded = ((uploadProgress ?? 0) / 100) * bytes;
return `${formatMegabytesOneDecimal(uploaded)} / ${formatMegabytesOneDecimal(bytes)}`;
}, [totalBytes, uploadProgress]);

return (
<View style={styles.row}>
<AttachmentUploadIndicator uploadProgress={uploadProgress} />
{progressLabel ? (
<Text numberOfLines={1} style={[styles.label, { color: semantics.textSecondary }]}>
{progressLabel}
</Text>
) : null}
</View>
);
};

const styles = StyleSheet.create({
label: {
flex: 1,
flexShrink: 1,
fontSize: primitives.typographyFontSizeXs,
fontWeight: primitives.typographyFontWeightRegular,
lineHeight: primitives.typographyLineHeightTight,
},
row: {
alignItems: 'center',
flexDirection: 'row',
gap: primitives.spacingXxs,
},
});
61 changes: 61 additions & 0 deletions package/src/components/Attachment/AttachmentUploadIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import React from 'react';
import { ActivityIndicator, StyleSheet, View } from 'react-native';
import type { StyleProp, ViewStyle } from 'react-native';

import { CircularProgressIndicator } from './CircularProgressIndicator';

import { useTheme } from '../../contexts/themeContext/ThemeContext';

export type AttachmentUploadIndicatorProps = {
size?: number;
strokeWidth?: number;
style?: StyleProp<ViewStyle>;
testID?: string;
/** When set, shows determinate `CircularProgressIndicator`; otherwise a generic spinner. */
uploadProgress: number | undefined;
};

/**
* Upload state for attachment previews: determinate ring when progress is known, otherwise `ActivityIndicator`.
*/
export const AttachmentUploadIndicator = ({
size = 16,
strokeWidth = 2,
style,
testID,
uploadProgress,
}: AttachmentUploadIndicatorProps) => {
const {
theme: { semantics },
} = useTheme();

if (uploadProgress === undefined) {
return (
<View
pointerEvents='none'
style={[styles.indeterminateWrap, { height: size, width: size }, style]}
testID={testID}
>
<ActivityIndicator color={semantics.accentPrimary} size='small' />
</View>
);
}

return (
<CircularProgressIndicator
color={semantics.accentPrimary}
progress={uploadProgress}
size={size}
strokeWidth={strokeWidth}
style={style}
testID={testID}
/>
);
};

const styles = StyleSheet.create({
indeterminateWrap: {
alignItems: 'center',
justifyContent: 'center',
},
});
113 changes: 113 additions & 0 deletions package/src/components/Attachment/CircularProgressIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import React, { useEffect, useMemo, useRef } from 'react';
import type { ColorValue } from 'react-native';
import { Animated, Easing, StyleProp, ViewStyle } from 'react-native';
import Svg, { Circle } from 'react-native-svg';

export type CircularProgressIndicatorProps = {
/** Upload percent **0–100**. */
progress: number;
color: ColorValue;
size?: number;
strokeWidth?: number;
style?: StyleProp<ViewStyle>;
testID?: string;
};

/**
* Circular upload progress ring (determinate) or rotating arc (indeterminate).
*/
export const CircularProgressIndicator = ({
color,
progress,
size = 16,
strokeWidth = 2,
style,
testID,
}: CircularProgressIndicatorProps) => {
const spin = useRef(new Animated.Value(0)).current;

useEffect(() => {
const loop = Animated.loop(
Animated.timing(spin, {
toValue: 1,
duration: 900,
easing: Easing.linear,
useNativeDriver: true,
}),
);
loop.start();
return () => {
loop.stop();
spin.setValue(0);
};
}, [progress, spin]);

const rotate = useMemo(
() =>
spin.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '360deg'],
}),
[spin],
);

const { cx, cy, r, circumference } = useMemo(() => {
const pad = strokeWidth / 2;
const rInner = size / 2 - pad;
return {
cx: size / 2,
cy: size / 2,
r: rInner,
circumference: 2 * Math.PI * rInner,
};
}, [size, strokeWidth]);

const fraction =
progress === undefined || Number.isNaN(progress)
? undefined
: Math.min(100, Math.max(0, progress)) / 100;

if (fraction !== undefined) {
const offset = circumference * (1 - fraction);
return (
<Svg height={size} style={style} testID={testID} viewBox={`0 0 ${size} ${size}`} width={size}>
<Circle
cx={cx}
cy={cy}
fill='none'
r={r}
stroke={color as string}
strokeDasharray={`${circumference}`}
strokeDashoffset={offset}
strokeLinecap='round'
strokeWidth={strokeWidth}
transform={`rotate(-90 ${cx} ${cy})`}
/>
</Svg>
);
}

const arc = circumference * 0.22;
const gap = circumference - arc;

return (
<Animated.View
style={[{ height: size, width: size }, style, { transform: [{ rotate }] }]}
testID={testID}
>
<Svg height={size} viewBox={`0 0 ${size} ${size}`} width={size}>
<Circle
cx={cx}
cy={cy}
fill='none'
r={r}
stroke={color as string}
strokeDasharray={`${arc} ${gap}`}
strokeLinecap='round'
strokeWidth={strokeWidth}
transform={`rotate(-90 ${cx} ${cy})`}
/>
</Svg>
</Animated.View>
);
};
Loading
Loading