diff --git a/examples/SampleApp/src/screens/NewDirectMessagingScreen.tsx b/examples/SampleApp/src/screens/NewDirectMessagingScreen.tsx index 339ee4c998..34cde20a96 100644 --- a/examples/SampleApp/src/screens/NewDirectMessagingScreen.tsx +++ b/examples/SampleApp/src/screens/NewDirectMessagingScreen.tsx @@ -259,7 +259,11 @@ export const NewDirectMessagingScreen: React.FC = )} - {selectedUsers.length === 0 ? : } + {selectedUsers.length === 0 ? ( + + ) : ( + + )} {focusOnSearchInput && !searchText && selectedUsers.length === 0 && ( diff --git a/package/src/components/AttachmentPicker/components/ImageOverlaySelectedComponent.tsx b/package/src/components/AttachmentPicker/components/ImageOverlaySelectedComponent.tsx index 8085efcbb7..7b663cbe0c 100644 --- a/package/src/components/AttachmentPicker/components/ImageOverlaySelectedComponent.tsx +++ b/package/src/components/AttachmentPicker/components/ImageOverlaySelectedComponent.tsx @@ -34,7 +34,7 @@ export const ImageOverlaySelectedComponent = ({ index }: { index: number }) => { check, ]} > - {index !== -1 ? : null} + {index !== -1 ? : null} ); }; diff --git a/package/src/components/AutoCompleteInput/AutoCompleteSuggestionCommandIcon.tsx b/package/src/components/AutoCompleteInput/AutoCompleteSuggestionCommandIcon.tsx index 0cbfcf0ca6..0930521ba6 100644 --- a/package/src/components/AutoCompleteInput/AutoCompleteSuggestionCommandIcon.tsx +++ b/package/src/components/AutoCompleteInput/AutoCompleteSuggestionCommandIcon.tsx @@ -23,7 +23,7 @@ export const SuggestionCommandIcon = ({ name }: { name: CommandVariants }) => { } else if (name === 'imgur') { return ; } else if (name === 'mute') { - return ; + return ; } else if (name === 'unban') { return ; } else if (name === 'unmute') { diff --git a/package/src/components/ChannelList/ChannelList.tsx b/package/src/components/ChannelList/ChannelList.tsx index f41fd2dded..e7c8f6f6b0 100644 --- a/package/src/components/ChannelList/ChannelList.tsx +++ b/package/src/components/ChannelList/ChannelList.tsx @@ -27,7 +27,7 @@ import { } from '../../contexts/channelsContext/ChannelsContext'; import { useChatContext } from '../../contexts/chatContext/ChatContext'; import type { ChannelListEventListenerOptions } from '../../types/types'; -import { ChannelPreviewMessenger } from '../ChannelPreview/ChannelPreviewMessenger'; +import { ChannelPreview } from '../ChannelPreview/ChannelPreview'; import { EmptyStateIndicator as EmptyStateIndicatorDefault } from '../Indicators/EmptyStateIndicator'; import { LoadingErrorIndicator as LoadingErrorIndicatorDefault } from '../Indicators/LoadingErrorIndicator'; @@ -55,6 +55,7 @@ export type ChannelListProps = Partial< | 'Skeleton' | 'maxUnreadCount' | 'numberOfSkeletons' + | 'mutedStatusPosition' > > & { /** Optional function to filter channels prior to rendering the list. Do not use any complex logic that would delay the loading of the ChannelList. We recommend using a pure function with array methods like filter/sort/reduce. */ @@ -261,7 +262,7 @@ export const ChannelList = (props: ChannelListProps) => { loadMoreThreshold = 0.1, lockChannelOrder = false, maxUnreadCount = 255, - numberOfSkeletons = 6, + numberOfSkeletons = 8, onAddedToChannel, onChannelDeleted, onChannelHidden, @@ -274,7 +275,7 @@ export const ChannelList = (props: ChannelListProps) => { onRemovedFromChannel, onSelect, options = DEFAULT_OPTIONS, - Preview = ChannelPreviewMessenger, + Preview = ChannelPreview, PreviewAvatar, PreviewMessage, PreviewMutedStatus, @@ -285,6 +286,7 @@ export const ChannelList = (props: ChannelListProps) => { Skeleton = SkeletonDefault, sort = DEFAULT_SORT, queryChannelsOverride, + mutedStatusPosition = 'inlineTitle', } = props; const [forceUpdate, setForceUpdate] = useState(0); @@ -416,6 +418,7 @@ export const ChannelList = (props: ChannelListProps) => { } }, Skeleton, + mutedStatusPosition, }); return ( diff --git a/package/src/components/ChannelList/ChannelListLoadingIndicator.tsx b/package/src/components/ChannelList/ChannelListLoadingIndicator.tsx index ce29cfb5be..2780811312 100644 --- a/package/src/components/ChannelList/ChannelListLoadingIndicator.tsx +++ b/package/src/components/ChannelList/ChannelListLoadingIndicator.tsx @@ -14,16 +14,12 @@ export const ChannelListLoadingIndicator = () => { const { theme: { channelListLoadingIndicator: { container }, - colors: { white_snow }, }, } = useTheme(); const { numberOfSkeletons, Skeleton } = useChannelsContext(); return ( - + {Array.from(Array(numberOfSkeletons)).map((_, index) => ( ))} diff --git a/package/src/components/ChannelList/Skeleton.tsx b/package/src/components/ChannelList/Skeleton.tsx index f709931e68..67a107d622 100644 --- a/package/src/components/ChannelList/Skeleton.tsx +++ b/package/src/components/ChannelList/Skeleton.tsx @@ -1,52 +1,24 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { StyleSheet, useWindowDimensions, View } from 'react-native'; import Animated, { Easing, - useAnimatedProps, useAnimatedStyle, - useDerivedValue, useSharedValue, withRepeat, withTiming, } from 'react-native-reanimated'; -import { Defs, LinearGradient, Path, Rect, Stop, Svg } from 'react-native-svg'; +import Svg, { Path, Rect, Defs, LinearGradient, Stop, ClipPath, G, Mask } from 'react-native-svg'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; -const paddingLarge = 16; -const paddingMedium = 12; -const paddingSmall = 8; - -const AnimatedPath = Animated.createAnimatedComponent(Path); - -const styles = StyleSheet.create({ - background: { - height: 64, - position: 'absolute', - width: '100%', - }, - container: { - borderBottomWidth: 1, - flexDirection: 'row', - }, -}); - export const Skeleton = () => { const width = useWindowDimensions().width; const startOffset = useSharedValue(-width); + const styles = useStyles(); const { theme: { - channelListSkeleton: { - animationTime = 1800, - background, - container, - gradientStart, - gradientStop, - height = 64, - maskFillColor, - }, - colors: { grey_gainsboro, white_snow }, + channelListSkeleton: { animationTime = 1500, container, height = 80 }, semantics, }, } = useTheme(); @@ -69,92 +41,132 @@ export const Skeleton = () => { [], ); - const d = useDerivedValue(() => { - const useableHeight = height - paddingMedium * 2; - const boneHeight = (useableHeight - 8) / 2; - const boneRadius = boneHeight / 2; - const circleRadius = useableHeight / 2; - const avatarBoneWidth = circleRadius * 2 + paddingSmall * 2; - const detailsBonesWidth = width - avatarBoneWidth; - - return `M0 0 h${width} v${height} h-${width}z M${paddingSmall} ${ - height / 2 - } a${circleRadius} ${circleRadius} 0 1 0 ${ - circleRadius * 2 - } 0 a${circleRadius} ${circleRadius} 0 1 0 -${circleRadius * 2} 0z M${ - avatarBoneWidth + boneRadius - } ${paddingMedium} a${boneRadius} ${boneRadius} 0 1 0 0 ${boneHeight}z M${ - avatarBoneWidth - boneRadius + detailsBonesWidth * 0.25 - } ${paddingMedium} h-${detailsBonesWidth * 0.25 - boneRadius * 2} v${boneHeight} h${ - detailsBonesWidth * 0.25 - boneRadius * 2 - }z M${avatarBoneWidth - boneRadius + detailsBonesWidth * 0.25} ${ - paddingMedium + boneHeight - } a${boneRadius} ${boneRadius} 0 1 0 0 -${boneHeight}z M${avatarBoneWidth + boneRadius} ${ - paddingMedium + boneHeight + paddingSmall - } a${boneRadius} ${boneRadius} 0 1 0 0 ${boneHeight}z M${ - avatarBoneWidth + detailsBonesWidth * 0.8 - boneRadius - } ${paddingMedium + boneHeight + paddingSmall} h-${ - detailsBonesWidth * 0.8 - boneRadius * 2 - } v${boneHeight} h${detailsBonesWidth * 0.8 - boneRadius * 2}z M${ - avatarBoneWidth + detailsBonesWidth * 0.8 - boneRadius - } ${height - paddingMedium} a${boneRadius} ${boneRadius} 0 1 0 0 -${boneHeight}z M${ - avatarBoneWidth + detailsBonesWidth * 0.8 + boneRadius + paddingLarge - } ${ - paddingMedium + boneHeight + paddingSmall - } a${boneRadius} ${boneRadius} 0 1 0 0 ${boneHeight}z M${width - paddingSmall - boneRadius} ${ - paddingMedium + boneHeight + paddingSmall - } h-${ - width - - paddingSmall - - boneRadius - - (avatarBoneWidth + detailsBonesWidth * 0.8 + boneRadius + paddingLarge) - } v${boneHeight} h${ - width - - paddingSmall - - boneRadius - - (avatarBoneWidth + detailsBonesWidth * 0.8 + boneRadius + paddingLarge) - }z M${width - paddingSmall * 2} ${ - height - paddingMedium - } a${boneRadius} ${boneRadius} 0 1 0 0 -${boneHeight}z`; - }, []); - - const svgAnimatedProps = useAnimatedProps( - () => ({ - d: d.value, - }), - [d], - ); - return ( - - - - - + + + + {/* Mask */} + + + + + {/* Gradients */} + + + + + - - - + + + + + + + + + + + + + + {/* ClipPaths */} + + + + + + + + + + + + + {/* Avatar */} + + + + + + {/* Title */} + + + + + + {/* Badge */} + + + + + + {/* Subtitle */} + + - - - ); }; Skeleton.displayName = 'Skeleton{channelListSkeleton}'; + +const useStyles = () => { + const { + theme: { semantics }, + } = useTheme(); + + return useMemo(() => { + return StyleSheet.create({ + container: { + borderBottomWidth: 1, + flexDirection: 'row', + borderBottomColor: semantics.borderCoreDefault, + }, + }); + }, [semantics]); +}; diff --git a/package/src/components/ChannelList/hooks/useCreateChannelsContext.ts b/package/src/components/ChannelList/hooks/useCreateChannelsContext.ts index dbe7e8c554..df882f7228 100644 --- a/package/src/components/ChannelList/hooks/useCreateChannelsContext.ts +++ b/package/src/components/ChannelList/hooks/useCreateChannelsContext.ts @@ -35,6 +35,7 @@ export const useCreateChannelsContext = ({ reloadList, setFlatListRef, Skeleton, + mutedStatusPosition, }: ChannelsContextValue) => { const channelValueString = channels ?.map( @@ -80,6 +81,7 @@ export const useCreateChannelsContext = ({ reloadList, setFlatListRef, Skeleton, + mutedStatusPosition, }), // eslint-disable-next-line react-hooks/exhaustive-deps [ @@ -91,6 +93,7 @@ export const useCreateChannelsContext = ({ loadingNextPage, channelListInitialized, refreshing, + mutedStatusPosition, ], ); diff --git a/package/src/components/ChannelPreview/ChannelListMessageDeliveryStatus.tsx b/package/src/components/ChannelPreview/ChannelListMessageDeliveryStatus.tsx new file mode 100644 index 0000000000..b7ebb4afdb --- /dev/null +++ b/package/src/components/ChannelPreview/ChannelListMessageDeliveryStatus.tsx @@ -0,0 +1,108 @@ +import React, { useMemo } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; + +import { LocalMessage } from 'stream-chat'; + +import { ChannelPreviewProps } from './ChannelPreview'; +import { LastMessageType } from './hooks/useChannelPreviewData'; + +import { MessageDeliveryStatus, useMessageDeliveryStatus } from './hooks/useMessageDeliveryStatus'; + +import { useChatContext } from '../../contexts/chatContext/ChatContext'; +import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { useTranslationContext } from '../../contexts/translationContext/TranslationContext'; +import { Check, CheckAll, Time } from '../../icons'; +import { primitives } from '../../theme'; +import { MessageStatusTypes } from '../../utils/utils'; + +export type ChannelListMessageDeliveryStatusProps = Pick & { + lastMessage: LastMessageType; +}; + +export const ChannelListMessageDeliveryStatus = ({ + channel, + lastMessage, +}: ChannelListMessageDeliveryStatusProps) => { + const { client } = useChatContext(); + const { t } = useTranslationContext(); + const channelConfigExists = typeof channel?.getConfig === 'function'; + const styles = useStyles(); + const { + theme: { + channelPreview: { + messageDeliveryStatus: { checkAllIcon, checkIcon, timeIcon }, + }, + semantics, + }, + } = useTheme(); + + const isLastMessageByCurrentUser = useMemo(() => { + return lastMessage?.user?.id === client.user?.id; + }, [lastMessage, client.user?.id]); + + const readEvents = useMemo(() => { + if (!channelConfigExists) { + return true; + } + const read_events = !channel.disconnected && !!channel?.id && channel.getConfig()?.read_events; + if (typeof read_events !== 'boolean') { + return true; + } + return read_events; + }, [channelConfigExists, channel]); + + const { status } = useMessageDeliveryStatus({ + channel, + lastMessage: lastMessage as LocalMessage, + isReadEventsEnabled: readEvents, + }); + + if (!isLastMessageByCurrentUser) { + return null; + } + + return ( + + {lastMessage.status === MessageStatusTypes.SENDING ? ( + + ) : lastMessage.status === MessageStatusTypes.RECEIVED && + status === MessageDeliveryStatus.READ ? ( + + ) : status === MessageDeliveryStatus.DELIVERED ? ( + + ) : status === MessageDeliveryStatus.SENT ? ( + + ) : null} + {t('You')}: + + ); +}; + +const useStyles = () => { + const { + theme: { + semantics, + channelPreview: { + messageDeliveryStatus: { container, text }, + }, + }, + } = useTheme(); + + return useMemo(() => { + return StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + gap: primitives.spacingXxs, + ...container, + }, + text: { + color: semantics.textTertiary, + fontSize: primitives.typographyFontSizeSm, + fontWeight: primitives.typographyFontWeightSemiBold, + lineHeight: primitives.typographyLineHeightNormal, + ...text, + }, + }); + }, [semantics, text, container]); +}; diff --git a/package/src/components/ChannelPreview/ChannelPreview.tsx b/package/src/components/ChannelPreview/ChannelPreview.tsx index 003c9c6875..4cb69ad634 100644 --- a/package/src/components/ChannelPreview/ChannelPreview.tsx +++ b/package/src/components/ChannelPreview/ChannelPreview.tsx @@ -9,6 +9,7 @@ import { useChannelsContext, } from '../../contexts/channelsContext/ChannelsContext'; import { ChatContextValue, useChatContext } from '../../contexts/chatContext/ChatContext'; +import { useTranslatedMessage } from '../../hooks/useTranslatedMessage'; export type ChannelPreviewProps = Partial> & Partial> & { @@ -27,18 +28,11 @@ export const ChannelPreview = (props: ChannelPreviewProps) => { const client = propClient || contextClient; const Preview = propPreview || contextPreview; - const { latestMessagePreview, muted, unread } = useChannelPreviewData( - channel, - client, - propForceUpdate, - ); - - return ( - - ); + const { muted, unread, lastMessage } = useChannelPreviewData(channel, client, propForceUpdate); + + const translatedLastMessage = useTranslatedMessage(lastMessage); + + const message = translatedLastMessage ? translatedLastMessage : lastMessage; + + return ; }; diff --git a/package/src/components/ChannelPreview/ChannelPreviewMessage.tsx b/package/src/components/ChannelPreview/ChannelPreviewMessage.tsx index 797192e7fd..2cde795860 100644 --- a/package/src/components/ChannelPreview/ChannelPreviewMessage.tsx +++ b/package/src/components/ChannelPreview/ChannelPreviewMessage.tsx @@ -1,38 +1,173 @@ -import React from 'react'; -import { StyleSheet, View } from 'react-native'; +import React, { useCallback, useMemo } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; -import type { LatestMessagePreview } from './hooks/useLatestMessagePreview'; +import { DraftMessage, LocalMessage, MessageResponse } from 'stream-chat'; -import { useTheme } from '../../contexts'; +import { ChannelPreviewProps } from './ChannelPreview'; + +import { ChannelTypingIndicatorPreview } from './ChannelTypingIndicatorPreview'; +import { LastMessageType } from './hooks/useChannelPreviewData'; + +import { useChannelPreviewDraftMessage } from './hooks/useChannelPreviewDraftMessage'; +import { useChannelPreviewPollLabel } from './hooks/useChannelPreviewPollLabel'; + +import { useChannelTypingState } from './hooks/useChannelTypingState'; + +import { useChatContext } from '../../contexts/chatContext/ChatContext'; +import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { useTranslationContext } from '../../contexts/translationContext/TranslationContext'; + +import { NewPoll } from '../../icons/NewPoll'; +import { primitives } from '../../theme'; +import { MessageStatusTypes } from '../../utils/utils'; import { MessagePreview } from '../MessagePreview/MessagePreview'; +import { MessagePreviewUserDetails } from '../MessagePreview/MessagePreviewUserDetails'; +import { ErrorBadge } from '../ui'; -export type ChannelPreviewMessageProps = { - /** - * Latest message on a channel, formatted for preview. - */ - latestMessagePreview: LatestMessagePreview; +export type ChannelPreviewMessageProps = Pick & { + lastMessage?: LastMessageType; }; export const ChannelPreviewMessage = (props: ChannelPreviewMessageProps) => { - const { latestMessagePreview } = props; - + const { channel, lastMessage } = props; const { - theme: { - channelPreview: { - message: { container }, - }, - }, + theme: { semantics }, } = useTheme(); + const isMessageDeleted = lastMessage?.type === 'deleted'; + const styles = useStyles({ isMessageDeleted }); + const { client } = useChatContext(); + const { t } = useTranslationContext(); - return ( - - - + const { usersTyping } = useChannelTypingState({ channel }); + + const draftMessage = useChannelPreviewDraftMessage({ channel }); + + const pollLabel = useChannelPreviewPollLabel({ pollId: lastMessage?.poll_id ?? '' }); + + const membersWithoutSelf = useMemo(() => { + return Object.values(channel.state.members).filter( + (member) => member.user?.id !== client.user?.id, + ); + }, [channel.state.members, client.user?.id]); + + const isFailedMessage = + lastMessage?.status === MessageStatusTypes.FAILED || lastMessage?.type === 'error'; + + const textStyle = useMemo(() => { + return [styles.subtitle]; + }, [styles.subtitle]); + + const iconProps = useMemo(() => { + return { + width: 16, + height: 16, + stroke: isMessageDeleted ? semantics.textTertiary : semantics.textSecondary, + }; + }, [isMessageDeleted, semantics.textTertiary, semantics.textSecondary]); + + const renderMessagePreview = useCallback( + (message: LocalMessage | MessageResponse | DraftMessage) => { + return ; + }, + [textStyle, iconProps], ); + + if (usersTyping.length > 0) { + return ; + } + + if (draftMessage) { + return ( + + {t('Draft:')} + {renderMessagePreview(draftMessage)} + + ); + } + + // If there are no messages yet, show a message saying "No messages yet" + if (!lastMessage) { + return ( + + {t('No messages yet')} + + ); + } + + if (pollLabel) { + return ( + + + {pollLabel} + + ); + } + + if (isFailedMessage) { + return ( + + + {t('Message failed to send')} + + ); + } + + if (channel.data?.name || membersWithoutSelf.length > 1) { + return ( + + + {renderMessagePreview(lastMessage)} + + ); + } else { + return ( + + + {renderMessagePreview(lastMessage)} + + ); + } }; -const styles = StyleSheet.create({ - container: { - flexShrink: 1, - }, -}); +const useStyles = ({ isMessageDeleted = false }: { isMessageDeleted?: boolean }) => { + const { + theme: { semantics }, + } = useTheme(); + + return useMemo(() => { + return StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + gap: primitives.spacingXxs, + flexShrink: 1, + }, + messagePreviewContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: primitives.spacingXxs, + flexShrink: 1, + }, + subtitle: { + color: isMessageDeleted ? semantics.textTertiary : semantics.textSecondary, + fontSize: primitives.typographyFontSizeSm, + fontWeight: primitives.typographyFontWeightRegular, + includeFontPadding: false, + lineHeight: primitives.typographyLineHeightNormal, + }, + draftText: { + color: semantics.accentPrimary, + fontSize: primitives.typographyFontSizeSm, + fontWeight: primitives.typographyFontWeightSemiBold, + lineHeight: primitives.typographyLineHeightNormal, + }, + errorText: { + color: semantics.accentError, + fontSize: primitives.typographyFontSizeSm, + fontWeight: primitives.typographyFontWeightRegular, + includeFontPadding: false, + lineHeight: primitives.typographyLineHeightNormal, + }, + }); + }, [semantics, isMessageDeleted]); +}; diff --git a/package/src/components/ChannelPreview/ChannelPreviewMessenger.tsx b/package/src/components/ChannelPreview/ChannelPreviewMessenger.tsx index 4193be9778..a96bfbaf35 100644 --- a/package/src/components/ChannelPreview/ChannelPreviewMessenger.tsx +++ b/package/src/components/ChannelPreview/ChannelPreviewMessenger.tsx @@ -1,47 +1,23 @@ -import React from 'react'; -import { StyleSheet, TouchableOpacity, View } from 'react-native'; +import React, { useMemo } from 'react'; +import { Pressable, StyleSheet, View } from 'react-native'; -import type { ChannelPreviewProps } from './ChannelPreview'; +import { ChannelPreviewProps } from './ChannelPreview'; import { ChannelPreviewMessage } from './ChannelPreviewMessage'; import { ChannelPreviewMutedStatus } from './ChannelPreviewMutedStatus'; import { ChannelPreviewStatus } from './ChannelPreviewStatus'; import { ChannelPreviewTitle } from './ChannelPreviewTitle'; import { ChannelPreviewUnreadCount } from './ChannelPreviewUnreadCount'; -import { useChannelPreviewDisplayName } from './hooks/useChannelPreviewDisplayName'; -import type { LatestMessagePreview } from './hooks/useLatestMessagePreview'; +import { LastMessageType } from './hooks/useChannelPreviewData'; import { ChannelsContextValue, useChannelsContext, } from '../../contexts/channelsContext/ChannelsContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; -import { useViewport } from '../../hooks/useViewport'; +import { primitives } from '../../theme'; import { ChannelAvatar } from '../ui/Avatar/ChannelAvatar'; -const styles = StyleSheet.create({ - container: { - borderBottomWidth: 1, - flex: 1, - flexDirection: 'row', - paddingHorizontal: 8, - paddingVertical: 12, - }, - contentContainer: { flex: 1 }, - row: { - alignItems: 'center', - flex: 1, - flexDirection: 'row', - justifyContent: 'space-between', - paddingLeft: 8, - }, - statusContainer: { - display: 'flex', - flexDirection: 'row', - }, - title: { fontSize: 14, fontWeight: '700' }, -}); - export type ChannelPreviewMessengerPropsWithContext = Pick & Pick< ChannelsContextValue, @@ -53,33 +29,8 @@ export type ChannelPreviewMessengerPropsWithContext = Pick & { - /** - * Latest message on a channel, formatted for preview - * - * e.g., - * - * ```json - * { - * created_at: '' , - * messageObject: { ... }, - * previews: { - * bold: true, - * text: 'This is the message preview text' - * }, - * status: 0 | 1 | 2 // read states of the latest message. - * } - * ``` - * - * The read status is either of the following: - * - * 0: The message was not sent by the current user - * 1: The message was sent by the current user and is unread - * 2: The message was sent by the current user and is read - * - * @overrideType object - */ - latestMessagePreview: LatestMessagePreview; /** * Formatter function for date of latest message. * @param date Message date @@ -94,13 +45,13 @@ export type ChannelPreviewMessengerPropsWithContext = Pick { const { channel, formatLatestMessageDate, - latestMessagePreview, maxUnreadCount, muted, onSelect, @@ -111,68 +62,80 @@ const ChannelPreviewMessengerWithContext = (props: ChannelPreviewMessengerPropsW PreviewTitle = ChannelPreviewTitle, PreviewUnreadCount = ChannelPreviewUnreadCount, unread, + mutedStatusPosition, + lastMessage, } = props; - const { vw } = useViewport(); - - const maxWidth = vw(80) - 16 - 40; const { theme: { - channelPreview: { container, contentContainer, row, title }, - colors: { white_snow }, + channelPreview: { + container, + contentContainer, + lowerRow, + statusContainer, + upperRow, + titleContainer, + wrapper, + }, semantics, }, } = useTheme(); - - const displayName = useChannelPreviewDisplayName( - channel, - Math.floor(maxWidth / ((title.fontSize || styles.title.fontSize) / 2)), - ); + const styles = useStyles(); return ( - { - if (onSelect) { - onSelect(channel); - } - }} - style={[ - // { opacity: pressed ? 0.5 : 1 }, - styles.container, - { backgroundColor: white_snow, borderBottomColor: semantics.borderCoreDefault }, - container, - ]} - testID='channel-preview-button' - > - - + { + if (onSelect) { + onSelect(channel); + } + }} + style={({ pressed }) => [ + styles.container, + { backgroundColor: pressed ? semantics.backgroundCorePressed : 'transparent' }, + container, + ]} + testID='channel-preview-button' > - - - - {muted && } - + + + + + + {muted && mutedStatusPosition === 'inlineTitle' ? : null} + + + + + + + + + + + {muted && mutedStatusPosition === 'trailingBottom' ? : null} - - - - - - + + ); }; export type ChannelPreviewMessengerProps = Partial< - Omit + Omit > & - Pick; + Pick; const MemoizedChannelPreviewMessengerWithContext = React.memo( ChannelPreviewMessengerWithContext, @@ -192,6 +155,7 @@ export const ChannelPreviewMessenger = (props: ChannelPreviewMessengerProps) => PreviewStatus, PreviewTitle, PreviewUnreadCount, + mutedStatusPosition, } = useChannelsContext(); return ( PreviewStatus, PreviewTitle, PreviewUnreadCount, + mutedStatusPosition, }} {...props} /> @@ -211,3 +176,54 @@ export const ChannelPreviewMessenger = (props: ChannelPreviewMessengerProps) => }; ChannelPreviewMessenger.displayName = 'ChannelPreviewMessenger{channelPreview}'; + +const useStyles = () => { + const { + theme: { semantics }, + } = useTheme(); + return useMemo(() => { + return StyleSheet.create({ + container: { + alignItems: 'center', + flexDirection: 'row', + padding: primitives.spacingSm, + borderRadius: primitives.radiusLg, + gap: primitives.spacingMd, + }, + contentContainer: { flex: 1, gap: primitives.spacingXxs }, + upperRow: { + alignItems: 'center', + flex: 1, + flexDirection: 'row', + justifyContent: 'space-between', + }, + lowerRow: { + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'space-between', + }, + innerRow: { + alignItems: 'center', + flexDirection: 'row', + gap: primitives.spacingXxs, + }, + statusContainer: { + alignItems: 'center', + flexDirection: 'row', + gap: primitives.spacingXs, + }, + titleContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: primitives.spacingXxs, + flexShrink: 1, + }, + wrapper: { + flex: 1, + padding: primitives.spacingXxs, + borderBottomWidth: 1, + borderBottomColor: semantics.borderCoreSubtle, + }, + }); + }, [semantics]); +}; diff --git a/package/src/components/ChannelPreview/ChannelPreviewMutedStatus.tsx b/package/src/components/ChannelPreview/ChannelPreviewMutedStatus.tsx index 6cecff6ed5..d8bccb8563 100644 --- a/package/src/components/ChannelPreview/ChannelPreviewMutedStatus.tsx +++ b/package/src/components/ChannelPreview/ChannelPreviewMutedStatus.tsx @@ -1,30 +1,18 @@ import React from 'react'; -import { StyleSheet } from 'react-native'; - import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { Mute } from '../../icons'; -const styles = StyleSheet.create({ - iconStyle: { - marginRight: 8, - }, -}); - /** * This UI component displays an avatar for a particular channel. */ export const ChannelPreviewMutedStatus = () => { const { theme: { - channelPreview: { - mutedStatus: { height, iconStyle, width }, - }, - colors: { grey }, + channelPreview: { mutedStatus }, + semantics, }, } = useTheme(); - return ( - - ); + return ; }; diff --git a/package/src/components/ChannelPreview/ChannelPreviewStatus.tsx b/package/src/components/ChannelPreview/ChannelPreviewStatus.tsx index bb98581f67..643e9cef99 100644 --- a/package/src/components/ChannelPreview/ChannelPreviewStatus.tsx +++ b/package/src/components/ChannelPreview/ChannelPreviewStatus.tsx @@ -1,45 +1,27 @@ import React, { useMemo } from 'react'; -import { StyleSheet, Text, View } from 'react-native'; +import { StyleSheet, Text } from 'react-native'; import { ChannelPreviewProps } from './ChannelPreview'; import type { ChannelPreviewMessengerPropsWithContext } from './ChannelPreviewMessenger'; -import { MessageDeliveryStatus } from './hooks/useMessageDeliveryStatus'; - import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { useTranslationContext } from '../../contexts/translationContext/TranslationContext'; -import { Check, CheckAll } from '../../icons'; +import { primitives } from '../../theme'; import { getDateString } from '../../utils/i18n/getDateString'; -const styles = StyleSheet.create({ - date: { - fontSize: 12, - marginLeft: 2, - textAlign: 'right', - }, - flexRow: { - flexDirection: 'row', - }, -}); - export type ChannelPreviewStatusProps = Pick< ChannelPreviewMessengerPropsWithContext, - 'latestMessagePreview' | 'formatLatestMessageDate' + 'formatLatestMessageDate' | 'lastMessage' > & Pick; export const ChannelPreviewStatus = (props: ChannelPreviewStatusProps) => { - const { formatLatestMessageDate, latestMessagePreview } = props; + const { formatLatestMessageDate, lastMessage } = props; const { t, tDateTimeParser } = useTranslationContext(); - const { - theme: { - channelPreview: { checkAllIcon, checkIcon, date }, - colors: { accent_blue, grey }, - }, - } = useTheme(); + const styles = useStyles(); - const created_at = latestMessagePreview?.created_at; + const created_at = lastMessage?.created_at; const latestMessageDate = created_at ? new Date(created_at) : new Date(); const formattedDate = useMemo( @@ -52,22 +34,32 @@ export const ChannelPreviewStatus = (props: ChannelPreviewStatusProps) => { }), [created_at, t, tDateTimeParser], ); - const status = latestMessagePreview.status; return ( - - {status === MessageDeliveryStatus.READ ? ( - - ) : status === MessageDeliveryStatus.DELIVERED ? ( - - ) : status === MessageDeliveryStatus.SENT ? ( - - ) : null} - - {formatLatestMessageDate && latestMessageDate - ? formatLatestMessageDate(latestMessageDate).toString() - : formattedDate} - - + + {formatLatestMessageDate && latestMessageDate + ? formatLatestMessageDate(latestMessageDate).toString() + : formattedDate} + ); }; + +const useStyles = () => { + const { + theme: { + channelPreview: { date }, + semantics, + }, + } = useTheme(); + return useMemo(() => { + return StyleSheet.create({ + date: { + color: semantics.textTertiary, + fontSize: primitives.typographyFontSizeSm, + fontWeight: primitives.typographyFontWeightRegular, + lineHeight: primitives.typographyLineHeightNormal, + ...date, + }, + }); + }, [semantics, date]); +}; diff --git a/package/src/components/ChannelPreview/ChannelPreviewTitle.tsx b/package/src/components/ChannelPreview/ChannelPreviewTitle.tsx index fd248f17c6..e91d33b41f 100644 --- a/package/src/components/ChannelPreview/ChannelPreviewTitle.tsx +++ b/package/src/components/ChannelPreview/ChannelPreviewTitle.tsx @@ -1,33 +1,49 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { StyleSheet, Text } from 'react-native'; import type { ChannelPreviewProps } from './ChannelPreview'; -import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { useChannelPreviewDisplayName } from './hooks/useChannelPreviewDisplayName'; -const styles = StyleSheet.create({ - title: { fontSize: 14, fontWeight: '700' }, -}); +import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { primitives } from '../../theme'; export type ChannelPreviewTitleProps = Pick & { /** * Formatted name for the previewed channel. */ - displayName: string; + title?: string; }; -export const ChannelPreviewTitle = (props: ChannelPreviewTitleProps) => { - const { displayName } = props; +export const ChannelPreviewTitle = ({ channel, title }: ChannelPreviewTitleProps) => { + const styles = useStyles(); + + const displayName = useChannelPreviewDisplayName(channel); + + return ( + + {title ?? displayName} + + ); +}; + +const useStyles = () => { const { theme: { channelPreview: { title }, - colors: { black }, + semantics, }, } = useTheme(); - - return ( - - {displayName} - - ); + return useMemo(() => { + return StyleSheet.create({ + title: { + color: semantics.textPrimary, + fontSize: primitives.typographyFontSizeMd, + fontWeight: primitives.typographyFontWeightSemiBold, + lineHeight: primitives.typographyLineHeightNormal, + flexShrink: 1, + ...title, + }, + }); + }, [semantics, title]); }; diff --git a/package/src/components/ChannelPreview/ChannelPreviewUnreadCount.tsx b/package/src/components/ChannelPreview/ChannelPreviewUnreadCount.tsx index 59212ec218..e44ecd302d 100644 --- a/package/src/components/ChannelPreview/ChannelPreviewUnreadCount.tsx +++ b/package/src/components/ChannelPreview/ChannelPreviewUnreadCount.tsx @@ -22,7 +22,7 @@ export const ChannelPreviewUnreadCount = (props: ChannelPreviewUnreadCountProps) return ( maxUnreadCount ? maxUnreadCount : unread} - size='md' + size='sm' type='primary' /> ); diff --git a/package/src/components/ChannelPreview/ChannelTypingIndicatorPreview.tsx b/package/src/components/ChannelPreview/ChannelTypingIndicatorPreview.tsx new file mode 100644 index 0000000000..5e0ab591ad --- /dev/null +++ b/package/src/components/ChannelPreview/ChannelTypingIndicatorPreview.tsx @@ -0,0 +1,113 @@ +import React, { useMemo } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; + +import { UserResponse } from 'stream-chat'; + +import { ChannelPreviewProps } from './ChannelPreview'; + +import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { + TranslationContextValue, + useTranslationContext, +} from '../../contexts/translationContext/TranslationContext'; +import { primitives } from '../../theme'; +import { LoadingDots } from '../Indicators/LoadingDots'; + +const getFirstName = (name?: string) => { + if (!name) { + return ''; + } + return name?.split(' ')[0]; +}; + +const getTypingString = ({ + usersTyping, + channelName, + t, +}: { + usersTyping: UserResponse[]; + channelName?: string; + t: TranslationContextValue['t']; +}) => { + if (channelName && usersTyping.length > 0) { + if (usersTyping.length === 1) { + return t('{{ user }} is typing', { user: getFirstName(usersTyping[0]?.name) }); + } else if (usersTyping.length === 2) { + return t('{{ firstUser }} and {{ secondUser }} are typing', { + firstUser: getFirstName(usersTyping[0]?.name), + secondUser: getFirstName(usersTyping[1]?.name), + }); + } + return t('{{ numberOfUsers }} people are typing', { numberOfUsers: usersTyping.length }); + } else { + if (!usersTyping.length) { + return null; + } + if (usersTyping.length === 1) { + return t('Typing'); + } else if (usersTyping.length === 2) { + return t('{{ firstUser }} and {{ secondUser }} are typing', { + firstUser: getFirstName(usersTyping[0]?.name), + secondUser: getFirstName(usersTyping[1]?.name), + }); + } else { + return t('{{ numberOfUsers }} people are typing', { numberOfUsers: usersTyping.length }); + } + } +}; + +export type ChannelTypingIndicatorPreviewProps = Pick & { + usersTyping: UserResponse[]; +}; + +export const ChannelTypingIndicatorPreview = ({ + usersTyping, + channel, +}: ChannelTypingIndicatorPreviewProps) => { + const styles = useStyles(); + const { t } = useTranslationContext(); + + const userTypingLabel = useMemo(() => { + return getTypingString({ usersTyping, channelName: channel.data?.name, t }); + }, [channel.data?.name, usersTyping, t]); + return ( + + + {userTypingLabel} + + + + ); +}; + +const useStyles = () => { + const { + theme: { + semantics, + channelPreview: { + typingIndicatorPreview: { container, text }, + }, + }, + } = useTheme(); + + return useMemo(() => { + return StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + gap: primitives.spacingXs, + flexShrink: 1, + ...container, + }, + text: { + color: semantics.textSecondary, + fontSize: primitives.typographyFontSizeSm, + fontWeight: primitives.typographyFontWeightRegular, + includeFontPadding: false, + lineHeight: primitives.typographyLineHeightNormal, + flexShrink: 1, + ...text, + }, + }); + }, [semantics, text, container]); +}; diff --git a/package/src/components/ChannelPreview/__tests__/ChannelPreview.test.tsx b/package/src/components/ChannelPreview/__tests__/ChannelPreview.test.tsx index 8bcc63199c..c8f1b8135a 100644 --- a/package/src/components/ChannelPreview/__tests__/ChannelPreview.test.tsx +++ b/package/src/components/ChannelPreview/__tests__/ChannelPreview.test.tsx @@ -1,4 +1,4 @@ -import React, { ComponentType } from 'react'; +import React from 'react'; import { Text } from 'react-native'; import { act, render, waitFor } from '@testing-library/react-native'; @@ -19,33 +19,30 @@ import { generateUser } from '../../../mock-builders/generator/user'; import { getTestClientWithUser } from '../../../mock-builders/mock'; import { Chat } from '../../Chat/Chat'; import { ChannelPreview } from '../ChannelPreview'; -import type { ChannelPreviewMessengerProps } from '../ChannelPreviewMessenger'; import '@testing-library/jest-native/extend-expect'; +import { LastMessageType } from '../hooks/useChannelPreviewData'; type ChannelPreviewUIComponentProps = { channel: { id: string; }; - latestMessagePreview: { - messageObject: { - text: string; - }; - }; + lastMessage: LastMessageType; unread: number; + muted: boolean; }; -const ChannelPreviewUIComponent = (props: ChannelPreviewUIComponentProps) => ( - <> - {props.channel.id} - {props.unread} - - {props.latestMessagePreview && - props.latestMessagePreview.messageObject && - props.latestMessagePreview.messageObject.text} - - -); +const channelOnMock = jest.fn().mockReturnValue({ unsubscribe: jest.fn() }); + +const ChannelPreviewUIComponent = (props: ChannelPreviewUIComponentProps) => { + return ( + <> + {props.channel.id} + {props.unread} + {props.lastMessage?.text} + + ); +}; const initChannelFromData = async ( chatClient: StreamChat, @@ -60,6 +57,7 @@ const initChannelFromData = async ( channel.initialized = true; channel.lastMessage = jest.fn().mockReturnValue(generateMessage()); channel.muteStatus = jest.fn().mockReturnValue({ muted: false }); + channel.state.messages = [generateMessage()]; return channel; }; @@ -80,7 +78,7 @@ describe('ChannelPreview', () => { {...props} channel={channel} client={chatClient} - Preview={ChannelPreviewUIComponent as ComponentType} + Preview={ChannelPreviewUIComponent} /> ); @@ -113,8 +111,6 @@ describe('ChannelPreview', () => { describe('notification.mark_read event', () => { it("should not update the unread count if the event's cid does not match the channel's cid", async () => { - const channelOnMock = jest.fn().mockReturnValue({ unsubscribe: jest.fn() }); - channel = await initChannelFromData(chatClient); channel.countUnread = jest.fn().mockReturnValue(10); @@ -342,7 +338,7 @@ describe('ChannelPreview', () => { }); }); - it('should update the lastest message on "message.new" event', async () => { + it('should update the latest message on "message.new" event', async () => { const c = generateChannelResponse(); await useInitializeChannel(c); diff --git a/package/src/components/ChannelPreview/hooks/__tests__/useChannelPreviewDisplayName.test.tsx b/package/src/components/ChannelPreview/hooks/__tests__/useChannelPreviewDisplayName.test.tsx deleted file mode 100644 index 664f3e5f0d..0000000000 --- a/package/src/components/ChannelPreview/hooks/__tests__/useChannelPreviewDisplayName.test.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import React from 'react'; -import { Text } from 'react-native'; - -import { render, screen, waitFor } from '@testing-library/react-native'; - -import type { Channel, ChannelMemberResponse, StreamChat } from 'stream-chat'; - -import { - GROUP_CHANNEL_MEMBERS_MOCK, - ONE_CHANNEL_MEMBER_MOCK, - ONE_MEMBER_WITH_EMPTY_USER_MOCK, -} from '../../../../mock-builders/api/queryMembers'; -import { generateUser } from '../../../../mock-builders/generator/user'; -import { getTestClientWithUser } from '../../../../mock-builders/mock'; - -import { - getChannelPreviewDisplayName, - useChannelPreviewDisplayName, -} from '../useChannelPreviewDisplayName'; - -describe('useChannelPreviewDisplayName', () => { - const clientUser = generateUser(); - let chatClient: StreamChat | StreamChat; - const CHARACTER_LENGTH = 15; - - beforeEach(async () => { - chatClient = await getTestClientWithUser(clientUser); - }); - - it('should return a channel display name', async () => { - const channelName = 'okechukwu'; - - const TestComponent = () => { - const channelDisplayName = useChannelPreviewDisplayName( - { - data: { name: channelName }, - } as unknown as Channel, - CHARACTER_LENGTH, - ); - return {channelDisplayName}; - }; - - render(); - - await waitFor(() => { - expect(screen.getByText(channelName)).toBeTruthy(); - }); - }); - - it('should return the full channelName when channelName length is less than characterLength', () => { - const channelName = 'okechukwu'; - const currentUserId = chatClient.userID; - - const displayName = getChannelPreviewDisplayName({ - channelName, - characterLimit: CHARACTER_LENGTH, - currentUserId, - members: ONE_CHANNEL_MEMBER_MOCK, - }); - - expect(displayName).toEqual(channelName); - }); - - it.each([ - [CHARACTER_LENGTH, GROUP_CHANNEL_MEMBERS_MOCK, 'okechukwu nwagba', 'ben, nick, qat,...+2'], - [CHARACTER_LENGTH, ONE_CHANNEL_MEMBER_MOCK, 'okechukwu nwagba', 'okechukwu nw...'], - [CHARACTER_LENGTH, ONE_MEMBER_WITH_EMPTY_USER_MOCK, 'okechukwu nwagba', 'Unknown User'], - ])( - 'getChannelPreviewDisplayName(%i, %p, %s) result in %s', - (characterLength, members, currentUserId, expected) => { - const displayName = getChannelPreviewDisplayName({ - characterLimit: characterLength, - currentUserId, - members: members as unknown as Record, - }); - - expect(displayName).toEqual(expected); - }, - ); -}); diff --git a/package/src/components/ChannelPreview/hooks/__tests__/useLatestMessagePreview.test.tsx b/package/src/components/ChannelPreview/hooks/__tests__/useLatestMessagePreview.test.tsx deleted file mode 100644 index e9835dbd24..0000000000 --- a/package/src/components/ChannelPreview/hooks/__tests__/useLatestMessagePreview.test.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import React, { FC } from 'react'; - -import { renderHook, waitFor } from '@testing-library/react-native'; - -import { ChannelResponse, MessageResponse, StreamChat } from 'stream-chat'; - -import { ChatContext, ChatContextValue } from '../../../../contexts/chatContext/ChatContext'; -import { - CHANNEL_WITH_DELETED_MESSAGES, - CHANNEL_WITH_EMPTY_MESSAGE, - CHANNEL_WITH_MENTIONED_USERS, - CHANNEL_WITH_MESSAGES_ATTACHMENTS, - CHANNEL_WITH_MESSAGES_COMMAND, - CHANNEL_WITH_MESSAGES_TEXT, - CHANNEL_WITH_NO_MESSAGES, - LATEST_MESSAGE, -} from '../../../../mock-builders/api/channelMocks'; - -import { getOrCreateChannelApi } from '../../../../mock-builders/api/getOrCreateChannel'; -import { useMockedApis } from '../../../../mock-builders/api/useMockedApis'; -import { generateChannelResponse } from '../../../../mock-builders/generator/channel'; -import { generateUser } from '../../../../mock-builders/generator/user'; -import { getTestClientWithUser } from '../../../../mock-builders/mock'; -import { useLatestMessagePreview } from '../useLatestMessagePreview'; - -const initChannelFromData = async (chatClient: StreamChat, channelData: ChannelResponse) => { - const mockedChannel = generateChannelResponse(channelData); - useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); - const channel = chatClient.channel('messaging', mockedChannel.channel.id); - await channel.watch(); - - return channel; -}; - -describe('useLatestMessagePreview', () => { - const FORCE_UPDATE = 15; - const clientUser = generateUser(); - let chatClient: StreamChat | StreamChat; - - beforeEach(async () => { - chatClient = await getTestClientWithUser(clientUser); - }); - - const ChatProvider: FC<{ children: React.ReactNode }> = ({ children }) => ( - - {children} - - ); - - it('should return a deleted message preview if the latest message is deleted', async () => { - const channel = await initChannelFromData( - chatClient, - CHANNEL_WITH_DELETED_MESSAGES as unknown as ChannelResponse, - ); - - const latestMessage = { cid: 'test', type: 'deleted' } as unknown as MessageResponse; - - const { result } = renderHook( - () => useLatestMessagePreview(channel, FORCE_UPDATE, latestMessage), - { wrapper: ChatProvider }, - ); - await waitFor(() => { - expect(result.current.previews).toEqual([{ bold: false, text: 'Message deleted' }]); - }); - }); - - it('should return an "Nothing yet..." message preview if channel has no messages', async () => { - const channel = await initChannelFromData( - chatClient, - CHANNEL_WITH_NO_MESSAGES as unknown as ChannelResponse, - ); - const latestMessage = undefined; - - const { result } = renderHook( - () => useLatestMessagePreview(channel, FORCE_UPDATE, latestMessage), - { wrapper: ChatProvider }, - ); - await waitFor(() => { - expect(result.current.previews).toEqual([{ bold: false, text: 'Nothing yet...' }]); - }); - }); - - it('should use latestMessage if provided', async () => { - const channel = await initChannelFromData( - chatClient, - CHANNEL_WITH_MESSAGES_TEXT as unknown as ChannelResponse, - ); - const { result } = renderHook( - () => useLatestMessagePreview(channel, FORCE_UPDATE, LATEST_MESSAGE), - { wrapper: ChatProvider }, - ); - - await waitFor(() => { - expect(result.current.previews).toEqual([ - { bold: true, text: '@okechukwu: ' }, - { bold: false, text: 'jkbkbiubicbi' }, - ]); - }); - }); - - it('should return a channel with an empty message preview', async () => { - const channel = await initChannelFromData( - chatClient, - CHANNEL_WITH_EMPTY_MESSAGE as unknown as ChannelResponse, - ); - const latestMessage = {} as unknown as MessageResponse; - - const { result } = renderHook( - () => useLatestMessagePreview(channel, FORCE_UPDATE, latestMessage), - { wrapper: ChatProvider }, - ); - - await waitFor(() => { - expect(result.current.previews).toEqual([ - { bold: false, text: '' }, - { bold: false, text: 'Empty message...' }, - ]); - }); - }); - - it('should return a mentioned user (@Max) message preview', async () => { - const channel = await initChannelFromData( - chatClient, - CHANNEL_WITH_MENTIONED_USERS as unknown as ChannelResponse, - ); - const latestMessage = { - mentioned_users: [{ id: 'Max', name: 'Max' }], - text: 'Max', - user: { - id: 'okechukwu', - }, - } as unknown as MessageResponse; - - const { result } = renderHook( - () => useLatestMessagePreview(channel, FORCE_UPDATE, latestMessage), - { wrapper: ChatProvider }, - ); - await waitFor(() => { - expect(result.current.previews).toEqual([ - { bold: false, text: '' }, - { bold: false, text: 'Max' }, - ]); - }); - }); - - it('should return the latest command preview', async () => { - const channel = await initChannelFromData( - chatClient, - CHANNEL_WITH_MESSAGES_COMMAND as unknown as ChannelResponse, - ); - - const latestMessage = { - command: 'giphy', - user: { - id: 'okechukwu', - }, - } as unknown as MessageResponse; - - const { result } = renderHook( - () => useLatestMessagePreview(channel, FORCE_UPDATE, latestMessage), - { wrapper: ChatProvider }, - ); - await waitFor(() => { - expect(result.current.previews).toEqual([ - { bold: true, text: '@okechukwu: ' }, - { bold: false, text: '/giphy' }, - ]); - }); - }); - - it('should return an attachment preview', async () => { - const channel = await initChannelFromData( - chatClient, - CHANNEL_WITH_MESSAGES_ATTACHMENTS as unknown as ChannelResponse, - ); - const latestMessage = { - attachments: ['arbitrary value'], - user: { - id: 'okechukwu', - }, - } as unknown as MessageResponse; - - const { result } = renderHook( - () => useLatestMessagePreview(channel, FORCE_UPDATE, latestMessage), - { wrapper: ChatProvider }, - ); - - await waitFor(() => { - expect(result.current.previews).toEqual([ - { bold: true, text: '@okechukwu: ' }, - { bold: false, text: '🏙 Attachment...' }, - ]); - }); - }); - - it('should default to messages from the channel state if latestMessage is undefined', async () => { - const channel = await initChannelFromData( - chatClient, - CHANNEL_WITH_MESSAGES_TEXT as unknown as ChannelResponse, - ); - const latestMessage = undefined; - - const { result } = renderHook( - () => useLatestMessagePreview(channel, FORCE_UPDATE, latestMessage), - { wrapper: ChatProvider }, - ); - - await waitFor(() => { - expect(result.current.previews).toEqual([ - { bold: true, text: '@okechukwu: ' }, - { bold: false, text: 'jkbkbiubicbi' }, - ]); - }); - }); -}); diff --git a/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts b/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts index 8222e746f7..29b1be7e76 100644 --- a/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts +++ b/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts @@ -5,8 +5,6 @@ import type { Channel, Event, LocalMessage, MessageResponse, StreamChat } from ' import { useIsChannelMuted } from './useIsChannelMuted'; -import { useLatestMessagePreview } from './useLatestMessagePreview'; - import { useChannelsContext } from '../../../contexts'; import { useStableCallback } from '../../../hooks'; @@ -16,7 +14,7 @@ const setLastMessageThrottleOptions = { leading: true, trailing: true }; const refreshUnreadCountThrottleTimeout = 400; const refreshUnreadCountThrottleOptions = setLastMessageThrottleOptions; -type LastMessageType = LocalMessage | MessageResponse; +export type LastMessageType = LocalMessage | MessageResponse; export const useChannelPreviewData = ( channel: Channel, @@ -172,11 +170,5 @@ export const useChannelPreviewData = ( return () => listeners.forEach((l) => l.unsubscribe()); }, [channel, refreshUnreadCount, forceUpdate, channelListForceUpdate, setLastMessage]); - const latestMessagePreview = useLatestMessagePreview( - channel, - forceUpdate, - lastMessage as LocalMessage, - ); - - return { latestMessagePreview, muted, unread }; + return { lastMessage, muted, unread }; }; diff --git a/package/src/components/ChannelPreview/hooks/useChannelPreviewDisplayAvatar.ts b/package/src/components/ChannelPreview/hooks/useChannelPreviewDisplayAvatar.ts deleted file mode 100644 index 390112b91b..0000000000 --- a/package/src/components/ChannelPreview/hooks/useChannelPreviewDisplayAvatar.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { useMemo } from 'react'; - -import type { Channel, StreamChat } from 'stream-chat'; - -import { useChatContext } from '../../../contexts/chatContext/ChatContext'; - -export const getChannelPreviewDisplayAvatar = (channel: Channel, client: StreamChat) => { - const currentUserId = client?.user?.id; - const channelId = channel?.id; - const channelData = channel?.data; - const channelName = channelData?.name; - const channelImage = channelData?.image; - - if (channelImage) { - return { - id: channelId, - image: channelImage, - name: channelName, - }; - } else if (currentUserId) { - const members = Object.values(channel.state?.members); - const otherMembers = members.filter((member) => member.user?.id !== currentUserId); - - if (otherMembers.length === 1) { - return { - id: otherMembers[0].user?.id, - image: otherMembers[0].user?.image, - name: channelName || otherMembers[0].user?.name, - }; - } - - return { - ids: otherMembers.slice(0, 4).map((member) => member.user?.id || ''), - images: otherMembers.slice(0, 4).map((member) => member.user?.image || ''), - names: otherMembers.slice(0, 4).map((member) => member.user?.name || ''), - }; - } - return { - id: channelId, - name: channelName, - }; -}; - -/** - * Hook to set the display avatar for channel preview - * @param {*} channel - * - * @returns {object} e.g., { image: 'http://dummyurl.com/test.png', name: 'Uhtred Bebbanburg' } - */ -export const useChannelPreviewDisplayAvatar = (channel: Channel) => { - const { client } = useChatContext(); - - const displayAvatar = useMemo( - () => getChannelPreviewDisplayAvatar(channel, client), - [channel, client], - ); - - return displayAvatar; -}; diff --git a/package/src/components/ChannelPreview/hooks/useChannelPreviewDisplayName.ts b/package/src/components/ChannelPreview/hooks/useChannelPreviewDisplayName.ts index c9bd19b2ff..7b51f56370 100644 --- a/package/src/components/ChannelPreview/hooks/useChannelPreviewDisplayName.ts +++ b/package/src/components/ChannelPreview/hooks/useChannelPreviewDisplayName.ts @@ -3,98 +3,40 @@ import { useMemo } from 'react'; import type { Channel, ChannelMemberResponse } from 'stream-chat'; import { useChatContext } from '../../../contexts/chatContext/ChatContext'; -import { useViewport } from '../../../hooks/useViewport'; - -const ELLIPSIS = '...'; +import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; const getMemberName = (member: ChannelMemberResponse) => member.user?.name || member.user?.id || 'Unknown User'; -export const getChannelPreviewDisplayName = ({ - channelName, - characterLimit, - currentUserId, - members, -}: { - characterLimit: number; - channelName?: string; - currentUserId?: string; - members?: Channel['state']['members']; -}): string => { - if (channelName) { - return channelName.length > characterLimit - ? `${channelName.slice(0, characterLimit - ELLIPSIS.length)}${ELLIPSIS}` - : channelName; - } - - const channelMembers = Object.values(members || {}); - - const otherMembers = channelMembers.filter((member) => member.user?.id !== currentUserId); - otherMembers.sort((prevUser, nextUser) => - (prevUser?.user?.name ?? '') - .toLowerCase() - .localeCompare((nextUser?.user?.name ?? '').toLocaleUpperCase()), - ); - - const createChannelNameSuffix = (remainingNumberOfMembers: number) => - remainingNumberOfMembers <= 1 ? `${ELLIPSIS}` : `,${ELLIPSIS}+${remainingNumberOfMembers}`; - - if (otherMembers.length === 1) { - const name = getMemberName(otherMembers[0]); - return name.length > characterLimit - ? `${name.slice(0, characterLimit - ELLIPSIS.length)}${ELLIPSIS}` - : name; - } - - const name = otherMembers.reduce((result, currentMember, index, originalArray) => { - if (result.length >= characterLimit) { - return result; - } - - const currentMemberName = getMemberName(currentMember); - - const resultHasSpaceForCurrentMemberName = - result.length + (currentMemberName.length + ELLIPSIS.length) < characterLimit; - - if (resultHasSpaceForCurrentMemberName) { - return result.length > 0 ? `${result}, ${currentMemberName}` : `${currentMemberName}`; - } else { - const remainingNumberOfMembers = originalArray.length - index; - const truncateLimit = characterLimit - (ELLIPSIS.length + result.length); - const tuncatedCurrentMemberName = `, ${currentMemberName.slice(0, truncateLimit)}`; - - const channelNameSuffix = createChannelNameSuffix(remainingNumberOfMembers); - - return `${result}${tuncatedCurrentMemberName}${channelNameSuffix}`; - } - }, ''); - - return name; -}; - -export const useChannelPreviewDisplayName = (channel?: Channel, characterLength?: number) => { +export const useChannelPreviewDisplayName = (channel?: Channel) => { const { client } = useChatContext(); - const { vw } = useViewport(); - - const DEFAULT_MAX_CHARACTER_LENGTH = Math.floor((vw(100) - 16) / 6); - + const { t } = useTranslationContext(); const currentUserId = client?.userID; const members = channel?.state?.members; const channelName = channel?.data?.name; - const characterLimit = characterLength || DEFAULT_MAX_CHARACTER_LENGTH; - const numOfMembers = Object.keys(members || {}).length; - const displayName = useMemo( - () => - getChannelPreviewDisplayName({ - channelName, - characterLimit, - currentUserId, - members, - }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [channelName, characterLimit, currentUserId, members, numOfMembers], - ); + const displayName = useMemo(() => { + if (channelName) { + return channelName; + } + // Get first name of the members without the current user. + const membersWithoutSelf = Object.values(members || {}).filter( + (member) => member.user?.id !== currentUserId, + ); + + if (membersWithoutSelf.length === 1) { + return getMemberName(membersWithoutSelf[0]); + } else { + const names = membersWithoutSelf + .map((member) => getMemberName(member)) + .map((name) => name.split(' ')[0]); + const sortedMembers = names.sort((a, b) => a.localeCompare(b)); + // Now show the first 2 members and the rest as +N. Don't show the +N if the remaining members are 0. + const firstTwoMembers = sortedMembers.slice(0, 2); + const remainingMembers = sortedMembers.slice(2); + return `${firstTwoMembers.join(', ').concat(' ')}${remainingMembers.length > 0 ? t('and {{ count }} others', { count: remainingMembers.length }) : ''}`; + } + }, [channelName, currentUserId, members, t]); return displayName; }; diff --git a/package/src/components/ChannelPreview/hooks/useChannelPreviewDraftMessage.ts b/package/src/components/ChannelPreview/hooks/useChannelPreviewDraftMessage.ts new file mode 100644 index 0000000000..374d9e6794 --- /dev/null +++ b/package/src/components/ChannelPreview/hooks/useChannelPreviewDraftMessage.ts @@ -0,0 +1,50 @@ +import { useMemo } from 'react'; + +import { AttachmentManagerState, Channel, DraftMessage, TextComposerState } from 'stream-chat'; + +import { useStateStore } from '../../../hooks/useStateStore'; + +const textComposerStateSelector = (state: TextComposerState) => ({ + text: state.text, +}); + +const stateSelector = (state: AttachmentManagerState) => ({ + attachments: state.attachments, +}); + +export type UseChannelPreviewDraftMessageProps = { + channel: Channel; +}; + +/** + * Hook to get the draft message for a channel preview. + * @param {UseChannelPreviewDraftMessageProps} param0 + * @returns {DraftMessage | undefined} + */ +export const useChannelPreviewDraftMessage = ({ channel }: UseChannelPreviewDraftMessageProps) => { + const { text: draftText } = useStateStore( + channel.messageComposer.textComposer.state, + textComposerStateSelector, + ); + + const { attachments } = useStateStore( + channel.messageComposer.attachmentManager.state, + stateSelector, + ); + + const draftMessage: DraftMessage | undefined = useMemo( + () => + !channel.messageComposer.compositionIsEmpty + ? attachments && draftText + ? { + attachments, + id: channel.messageComposer.id, + text: draftText, + } + : undefined + : undefined, + [channel.messageComposer, attachments, draftText], + ); + + return draftMessage; +}; diff --git a/package/src/components/ChannelPreview/hooks/useChannelPreviewPollLabel.ts b/package/src/components/ChannelPreview/hooks/useChannelPreviewPollLabel.ts new file mode 100644 index 0000000000..af1048ad4b --- /dev/null +++ b/package/src/components/ChannelPreview/hooks/useChannelPreviewPollLabel.ts @@ -0,0 +1,55 @@ +import { useMemo } from 'react'; + +import { PollState } from 'stream-chat'; + +import { useChatContext } from '../../../contexts/chatContext/ChatContext'; +import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; +import { useStateStore } from '../../../hooks/useStateStore'; + +export type UseChannelPreviewPollLabelProps = { + pollId: string; +}; + +const selector = (nextValue: PollState) => ({ + latest_votes_by_option: nextValue.latest_votes_by_option, + options: nextValue.options, +}); + +/** + * Hook to get the label of the poll + * @param pollId - The id of the poll + * @returns The label of the poll + */ +export const useChannelPreviewPollLabel = ({ pollId }: UseChannelPreviewPollLabelProps) => { + const { client } = useChatContext(); + const { t } = useTranslationContext(); + const poll = client.polls.fromState(pollId); + const pollState = useStateStore(poll?.state, selector) ?? ({} as PollState); + const { latest_votes_by_option: latestVotesByOption, options: pollOptions } = pollState; + + const latestVotes = useMemo( + () => + latestVotesByOption + ? Object.values(latestVotesByOption) + .map((votes) => votes?.[0]) + .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) + : [], + [latestVotesByOption], + ); + + return useMemo(() => { + if (!(latestVotes && latestVotes.length && latestVotes[0].user)) return; + { + const option = pollOptions?.find((option) => option.id === latestVotes[0].option_id); + const voteUser = latestVotes[0].user; + if (voteUser.id === client.user?.id) { + return t('You voted: {{ option }}', { option: option?.text }); + } else { + return t('{{ user }} voted: {{ option }}', { + user: voteUser.name || voteUser.id, + option: option?.text, + }); + } + } + }, [latestVotes, pollOptions, client.user?.id, t]); +}; diff --git a/package/src/components/ChannelPreview/hooks/useChannelTypingState.ts b/package/src/components/ChannelPreview/hooks/useChannelTypingState.ts new file mode 100644 index 0000000000..a4b8ea5196 --- /dev/null +++ b/package/src/components/ChannelPreview/hooks/useChannelTypingState.ts @@ -0,0 +1,48 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { Channel, Event, UserResponse } from 'stream-chat'; + +import { useChatContext } from '../../../contexts/chatContext/ChatContext'; + +type UseChannelTypingStateProps = { + channel: Channel; +}; + +// Listen to the typing.start and typing.stop events and update the typing state +export const useChannelTypingState = ({ channel }: UseChannelTypingStateProps) => { + const { client } = useChatContext(); + const [usersTyping, setUsersTyping] = useState([]); + + const handleTypingStart = useCallback( + (event: Event) => { + if (channel.cid !== event.cid || event.user?.id === client?.user?.id) return; + setUsersTyping((prev) => { + if (!event.user) return prev; + if (prev.some((user) => user.id === event.user?.id)) return prev; + return [...prev, event.user]; + }); + }, + [channel.cid, client?.user?.id], + ); + + const handleTypingStop = useCallback( + (event: Event) => { + if (channel.cid !== event.cid || event.user?.id === client?.user?.id) return; + setUsersTyping((prev) => prev.filter((user) => user.id !== event.user?.id)); + }, + [channel.cid, client?.user?.id], + ); + + useEffect(() => { + const listeners = [ + channel.on('typing.start', handleTypingStart), + channel.on('typing.stop', handleTypingStop), + ]; + + return () => { + listeners.forEach((listener) => listener.unsubscribe()); + }; + }, [channel, handleTypingStart, handleTypingStop]); + + return { usersTyping }; +}; diff --git a/package/src/components/ChannelPreview/hooks/useLatestMessagePreview.ts b/package/src/components/ChannelPreview/hooks/useLatestMessagePreview.ts deleted file mode 100644 index c731344c7b..0000000000 --- a/package/src/components/ChannelPreview/hooks/useLatestMessagePreview.ts +++ /dev/null @@ -1,343 +0,0 @@ -import { useMemo } from 'react'; - -import { TFunction } from 'i18next'; -import type { - AttachmentManagerState, - Channel, - DraftMessage, - LocalMessage, - PollState, - PollVote, - StreamChat, - TextComposerState, - UserResponse, -} from 'stream-chat'; - -import { MessageDeliveryStatus, useMessageDeliveryStatus } from './useMessageDeliveryStatus'; - -import { useChatContext } from '../../../contexts/chatContext/ChatContext'; -import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; - -import { useStateStore } from '../../../hooks'; -import { useTranslatedMessage } from '../../../hooks/useTranslatedMessage'; - -import { stringifyMessage } from '../../../utils/utils'; - -export type LatestMessagePreview = { - messageObject: LocalMessage | undefined; - previews: { - bold: boolean; - text: string; - draft?: boolean; - }[]; - status?: MessageDeliveryStatus; - created_at?: string | Date; -}; - -export type LatestMessagePreviewSelectorReturnType = { - createdBy?: UserResponse | null; - latestVotesByOption?: Record; - name?: string; -}; - -const selector = (nextValue: PollState): LatestMessagePreviewSelectorReturnType => ({ - createdBy: nextValue.created_by, - latestVotesByOption: nextValue.latest_votes_by_option, - name: nextValue.name, -}); - -const getMessageSenderName = ( - message: LocalMessage | undefined, - currentUserId: string | undefined, - t: (key: string) => string, - membersLength: number, -) => { - if (message?.user?.id === currentUserId) { - return t('You'); - } - - if (membersLength > 2) { - return message?.user?.name || message?.user?.username || message?.user?.id || ''; - } - - return ''; -}; - -const getMentionUsers = (mentionedUser: UserResponse[] | undefined) => { - if (Array.isArray(mentionedUser)) { - const mentionUserString = mentionedUser.reduce((acc, cur) => { - const userName = cur.name || cur.id || ''; - if (userName) { - acc += `${acc.length ? '|' : ''}@${userName}`; - } - return acc; - }, ''); - - // escape special characters - return mentionUserString.replace(/[.*+?^${}()|[\]\\]/g, function (match) { - return '\\' + match; - }); - } - - return ''; -}; - -const getLatestMessageDisplayText = ( - channel: Channel, - client: StreamChat, - draftMessage: DraftMessage | undefined, - message: LocalMessage | undefined, - t: (key: string) => string, - pollState: LatestMessagePreviewSelectorReturnType | undefined, -) => { - if (!message) { - return [{ bold: false, text: t('Nothing yet...') }]; - } - const isMessageTypeDeleted = message.type === 'deleted'; - if (isMessageTypeDeleted) { - return [{ bold: false, text: t('Message deleted') }]; - } - const currentUserId = client?.userID; - const members = Object.keys(channel.state.members); - - const messageSender = getMessageSenderName(message, currentUserId, t, members.length); - const messageSenderText = messageSender - ? `${messageSender === t('You') ? '' : '@'}${messageSender}: ` - : ''; - const boldOwner = messageSenderText.includes('@'); - if (draftMessage) { - if (draftMessage.attachments?.length) { - return [ - { bold: true, draft: true, text: 'Draft:' }, - { bold: false, text: t('🏙 Attachment...') }, - ]; - } - if (draftMessage.text) { - return [ - { bold: true, draft: true, text: 'Draft:' }, - { bold: false, text: draftMessage.text }, - ]; - } - } - // Location messages - if (message.shared_location) { - return [ - { bold: false, text: '📍' }, - { bold: false, text: t('Location') }, - ]; - } - - if (message.text) { - // rough guess optimization to limit string preview to max 100 characters - const shortenedText = message.text.substring(0, 100).replace(/\n/g, ' '); - const mentionedUsers = getMentionUsers(message.mentioned_users); - const regEx = new RegExp(`^(${mentionedUsers})`); - return [ - { bold: boldOwner, text: messageSenderText }, - ...shortenedText.split('').reduce( - (acc, cur, index) => { - if (cur === '@' && mentionedUsers && regEx.test(shortenedText.substring(index))) { - acc.push({ bold: true, text: cur }); - } else if (mentionedUsers && regEx.test(acc[acc.length - 1].text)) { - acc.push({ bold: false, text: cur }); - } else { - acc[acc.length - 1].text += cur; - } - return acc; - }, - [{ bold: false, text: '' }], - ), - ]; - } - if (message.command) { - return [ - { bold: boldOwner, text: messageSenderText }, - { bold: false, text: `/${message.command}` }, - ]; - } - if (message.attachments?.length) { - return [ - { bold: boldOwner, text: messageSenderText }, - { bold: false, text: t('🏙 Attachment...') }, - ]; - } - if (message.poll_id && pollState) { - const { createdBy, latestVotesByOption, name } = pollState; - let latestVotes; - if (latestVotesByOption) { - latestVotes = Object.values(latestVotesByOption) - .map((votes) => votes?.[0]) - .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); - } - let previewAction = 'created'; - let previewUser = createdBy; - if (latestVotes && latestVotes.length && latestVotes[0]?.user) { - previewAction = 'voted'; - previewUser = latestVotes[0]?.user; - } - const previewMessage = `${ - client.userID === previewUser?.id ? 'You' : previewUser?.name - } ${previewAction}: ${name}`; - return [ - { bold: false, text: '📊 ' }, - { bold: false, text: previewMessage }, - ]; - } - return [ - { bold: boldOwner, text: messageSenderText }, - { bold: false, text: t('Empty message...') }, - ]; -}; - -export enum MessageReadStatus { - NOT_SENT_BY_CURRENT_USER = 0, - UNREAD = 1, - READ = 2, - DELIVERED = 3, -} - -const getLatestMessagePreview = (params: { - channel: Channel; - client: StreamChat; - draftMessage?: DraftMessage; - pollState: LatestMessagePreviewSelectorReturnType | undefined; - /** - * @deprecated This parameter is no longer used and will be removed in the next major release. - */ - readEvents?: boolean; - lastMessage?: LocalMessage; - status?: MessageDeliveryStatus; - t: TFunction; -}) => { - const { channel, client, draftMessage, lastMessage, pollState, status, t } = params; - - const messages = channel.state.messages; - - if (!messages.length && !lastMessage) { - return { - created_at: '', - messageObject: undefined, - previews: [ - { - bold: false, - text: t('Nothing yet...'), - }, - ], - status: MessageDeliveryStatus.NOT_SENT_BY_CURRENT_USER, - }; - } - - const channelStateLastMessage = messages.length ? messages[messages.length - 1] : undefined; - - const message = lastMessage !== undefined ? lastMessage : channelStateLastMessage; - - return { - created_at: message?.created_at, - messageObject: message, - previews: getLatestMessageDisplayText(channel, client, draftMessage, message, t, pollState), - status, - }; -}; - -const textComposerStateSelector = (state: TextComposerState) => ({ - text: state.text, -}); - -const stateSelector = (state: AttachmentManagerState) => ({ - attachments: state.attachments, -}); - -/** - * Hook to set the display preview for latest message on channel. - * - * FIXME: This hook is very poorly implemented and needs to be refactored with granular hooks - * to avoid unnecessary re-renders and to make the code more readable. - * - * @param {*} channel Channel object - * - * @returns {object} latest message preview e.g.. { text: 'this was last message ...', created_at: '11/12/2020', messageObject: { originalMessageObject } } - */ -export const useLatestMessagePreview = ( - channel: Channel, - forceUpdate: number, - lastMessage?: LocalMessage, -) => { - const { client } = useChatContext(); - const { t } = useTranslationContext(); - - const { text: draftText } = useStateStore( - channel.messageComposer.textComposer.state, - textComposerStateSelector, - ); - - const { attachments } = useStateStore( - channel.messageComposer.attachmentManager.state, - stateSelector, - ); - - const draftMessage: DraftMessage | undefined = useMemo( - () => - !channel.messageComposer.compositionIsEmpty - ? { - attachments, - id: channel.messageComposer.id, - text: draftText, - } - : undefined, - [channel.messageComposer, attachments, draftText], - ); - - const channelConfigExists = typeof channel?.getConfig === 'function'; - - const translatedLastMessage = useTranslatedMessage(lastMessage); - - const channelLastMessageString = translatedLastMessage - ? stringifyMessage({ message: translatedLastMessage }) - : ''; - - const readEvents = useMemo(() => { - if (!channelConfigExists) { - return true; - } - const read_events = !channel.disconnected && !!channel?.id && channel.getConfig()?.read_events; - if (typeof read_events !== 'boolean') { - return true; - } - return read_events; - }, [channelConfigExists, channel]); - - const { status } = useMessageDeliveryStatus({ - channel, - isReadEventsEnabled: readEvents, - lastMessage: lastMessage as LocalMessage, - }); - - const pollId = lastMessage?.poll_id ?? ''; - const poll = client.polls.fromState(pollId); - const pollState: LatestMessagePreviewSelectorReturnType = - useStateStore(poll?.state, selector) ?? {}; - const { createdBy, latestVotesByOption, name } = pollState; - - const latestMessagePreview = useMemo(() => { - return getLatestMessagePreview({ - channel, - client, - draftMessage, - lastMessage: translatedLastMessage, - pollState, - status, - t, - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - channelLastMessageString, - status, - draftMessage, - forceUpdate, - latestVotesByOption, - createdBy, - name, - ]); - - return latestMessagePreview; -}; diff --git a/package/src/components/Indicators/LoadingDot.tsx b/package/src/components/Indicators/LoadingDot.tsx index 6aa45b1835..409d464fde 100644 --- a/package/src/components/Indicators/LoadingDot.tsx +++ b/package/src/components/Indicators/LoadingDot.tsx @@ -19,14 +19,14 @@ type Props = { }; export const LoadingDot = (props: Props) => { - const { diameter = 4, duration = 1500, offset = 0, style } = props; + const { diameter = 5, duration = 1500, offset = 0, style } = props; const halfDuration = duration / 2; const startingOffset = halfDuration - offset; const { theme: { - colors: { black }, loadingDots: { loadingDot }, + semantics, }, } = useTheme(); const opacity = useSharedValue(startingOffset / halfDuration); @@ -56,7 +56,7 @@ export const LoadingDot = (props: Props) => { { - const { diameter = 4, duration = 1500, numberOfDots = 3, spacing: spacingProp, style } = props; - const { theme: { loadingDots: { container, spacing }, }, } = useTheme(); + const { + diameter = 5, + duration = 1500, + numberOfDots = 3, + spacing: spacingProp = spacing, + style, + } = props; const halfSpacing = spacingProp ? spacingProp / 2 : spacing / 2; const offsetLength = duration / numberOfDots; diff --git a/package/src/components/Message/MessageSimple/__tests__/__snapshots__/MessageAvatar.test.js.snap b/package/src/components/Message/MessageSimple/__tests__/__snapshots__/MessageAvatar.test.js.snap index c611896625..a048ebfaaf 100644 --- a/package/src/components/Message/MessageSimple/__tests__/__snapshots__/MessageAvatar.test.js.snap +++ b/package/src/components/Message/MessageSimple/__tests__/__snapshots__/MessageAvatar.test.js.snap @@ -28,6 +28,7 @@ exports[`MessageAvatar should render message avatar 1`] = ` "borderColor": "rgba(0, 0, 0, 0.1)", "borderWidth": 1, }, + undefined, ] } testID="avatar-image" diff --git a/package/src/components/Message/hooks/useMessageActions.tsx b/package/src/components/Message/hooks/useMessageActions.tsx index 049b6eb7d8..5e9bbc1db9 100644 --- a/package/src/components/Message/hooks/useMessageActions.tsx +++ b/package/src/components/Message/hooks/useMessageActions.tsx @@ -296,7 +296,7 @@ export const useMessageActions = ({ } }, actionType: 'muteUser', - icon: , + icon: , title: isMuted ? t('Unmute User') : t('Mute User'), type: 'standard', }; diff --git a/package/src/components/MessageList/ScrollToBottomButton.tsx b/package/src/components/MessageList/ScrollToBottomButton.tsx index 3c867275d1..358f293f34 100644 --- a/package/src/components/MessageList/ScrollToBottomButton.tsx +++ b/package/src/components/MessageList/ScrollToBottomButton.tsx @@ -54,7 +54,7 @@ export const ScrollToBottomButton = (props: ScrollToBottomButtonProps) => { {unreadCount ? ( - + ) : null} diff --git a/package/src/components/MessageList/__tests__/__snapshots__/TypingIndicator.test.js.snap b/package/src/components/MessageList/__tests__/__snapshots__/TypingIndicator.test.js.snap index 6f5ecd9ac1..e730305ecb 100644 --- a/package/src/components/MessageList/__tests__/__snapshots__/TypingIndicator.test.js.snap +++ b/package/src/components/MessageList/__tests__/__snapshots__/TypingIndicator.test.js.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`TypingIndicator should match typing indicator snapshot 1`] = ` ({ + name: nextValue.name, +}); + +const MessagePreviewText = React.memo( + ({ + message, + style, + }: { + message?: LocalMessage | MessageResponse | DraftMessage | null; + style?: StyleProp; + }) => { + const { client } = useChatContext(); + const poll = client.polls.fromState(message?.poll_id ?? ''); + const { name: pollName } = useStateStore(poll?.state, selector) ?? {}; + const styles = useStyles(); + const { giphys, audios, images, videos, files, voiceRecordings } = useGroupedAttachments( + message?.attachments, + ); + const attachmentsLength = message?.attachments?.length; + + const subtitle = useMemo(() => { + const onlyImages = images?.length && images?.length === attachmentsLength; + const onlyVideos = videos?.length && videos?.length === attachmentsLength; + const onlyFiles = files?.length && files?.length === attachmentsLength; + const onlyAudio = audios?.length === attachmentsLength; + const onlyVoiceRecordings = + voiceRecordings?.length && voiceRecordings?.length === attachmentsLength; + + if (message?.type === 'deleted') { + return 'Message deleted'; + } + + if (pollName) { + return pollName; + } + + if (message?.shared_location) { + if ( + // There is a problem with types in Draft Message, and its not able to infer the type of `end_at` correctly, so the `as` is used. + (message?.shared_location as LiveLocationPayload)?.end_at && + new Date((message?.shared_location as LiveLocationPayload)?.end_at) > new Date() + ) { + return 'Live Location'; + } + return 'Location'; + } + + if (message?.text) { + return message?.text; + } + + if (onlyImages) { + if (images?.length === 1) { + return 'Photo'; + } else { + return `${images?.length} Photos`; + } + } + + if (onlyVideos) { + if (videos?.length === 1) { + return 'Video'; + } else { + return `${videos?.length} Videos`; + } + } + + if (onlyAudio) { + if (audios?.length === 1) { + return 'Audio'; + } else { + return `${audios?.length} Audios`; + } + } + + if (onlyVoiceRecordings) { + if (voiceRecordings?.length === 1) { + return `Voice message (${dayjs.duration(voiceRecordings?.[0]?.duration ?? 0, 'seconds').format('m:ss')})`; + } else { + return `${voiceRecordings?.length} Voice messages`; + } + } + + if (giphys?.length) { + return 'Giphy'; + } + + if (onlyFiles && files?.length === 1) { + return files?.[0]?.title; + } + + return `${attachmentsLength} Files`; + }, [ + attachmentsLength, + audios?.length, + files, + giphys?.length, + images?.length, + message?.shared_location, + message?.text, + message?.type, + pollName, + videos?.length, + voiceRecordings, + ]); + + if (!subtitle) { + return null; + } + + return ( + + {subtitle} + + ); + }, +); + +const MessagePreviewIcon = React.memo( + (props: { + message?: LocalMessage | MessageResponse | DraftMessage | null; + iconProps?: IconProps; + }) => { + const { message, iconProps } = props; + const { + theme: { semantics }, + } = useTheme(); + const styles = useStyles(); + const { giphys, audios, images, videos, files, voiceRecordings } = useGroupedAttachments( + message?.attachments, + ); + const attachmentsLength = message?.attachments?.length; + if (!message) { + return null; + } + + const onlyImages = images?.length && images?.length === attachmentsLength; + const onlyAudio = audios?.length && audios?.length === attachmentsLength; + const onlyVideos = videos?.length && videos?.length === attachmentsLength; + const onlyVoiceRecordings = + voiceRecordings?.length && voiceRecordings?.length === attachmentsLength; + const hasLink = message?.attachments?.some( + (attachment) => attachment.type === FileTypes.Image && attachment.og_scrape_url, + ); + + if (message.type === 'deleted') { + return ( + + ); + } + + if (message.poll_id) { + return ( + + ); + } + + if (message.shared_location) { + return ( + + ); + } + + if (hasLink) { + return ( + + ); + } + + if (onlyAudio || onlyVoiceRecordings) { + return ( + + ); + } + + if (onlyVideos) { + return ( + + ); + } + + if (onlyImages) { + return ( + + ); + } + + if (giphys?.length) { + return ( + + ); + } + + if (files?.length || images?.length || videos?.length || audios?.length) { + return ( + + ); + } + + return null; + }, +); export type MessagePreviewProps = { - previews: MessagePreviewSkeletonProps[]; + message: LocalMessage | MessageResponse | DraftMessage; + iconProps?: IconProps; + textStyle?: StyleProp; + containerStyle?: StyleProp; }; -export const MessagePreview = ({ previews }: MessagePreviewProps) => { - const { - theme: { - messagePreview: { message }, - colors: { accent_blue, grey }, - }, - } = useTheme(); +export const MessagePreview = ({ + message, + iconProps, + textStyle, + containerStyle, +}: MessagePreviewProps) => { + const styles = useStyles(); return ( - - {previews?.map((preview, index) => - preview.text ? ( - - {preview.text} - - ) : null, - )} + + + ); }; -const styles = StyleSheet.create({ - bold: { fontWeight: '500' }, - container: { - flexDirection: 'row', - flexShrink: 1, - }, - message: { - flexShrink: 1, - marginRight: 2, - }, -}); +const useStyles = () => { + return useMemo(() => { + return StyleSheet.create({ + subtitle: { + fontSize: primitives.typographyFontSizeXs, + fontWeight: primitives.typographyFontWeightRegular, + lineHeight: primitives.typographyLineHeightTight, + includeFontPadding: false, + }, + container: { + alignItems: 'center', + flexDirection: 'row', + gap: primitives.spacingXxs, + flexShrink: 1, + }, + iconStyle: {}, + }); + }, []); +}; diff --git a/package/src/components/MessagePreview/MessagePreviewUserDetails.tsx b/package/src/components/MessagePreview/MessagePreviewUserDetails.tsx new file mode 100644 index 0000000000..ff953e8c4a --- /dev/null +++ b/package/src/components/MessagePreview/MessagePreviewUserDetails.tsx @@ -0,0 +1,41 @@ +import React, { useMemo } from 'react'; +import { Text, StyleSheet } from 'react-native'; + +import { Channel, LocalMessage, MessageResponse } from 'stream-chat'; + +import { useChatContext } from '../../contexts/chatContext/ChatContext'; +import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { primitives } from '../../theme'; +import { ChannelListMessageDeliveryStatus } from '../ChannelPreview/ChannelListMessageDeliveryStatus'; + +export type MessagePreviewUserDetailsProps = { + message: LocalMessage | MessageResponse; + channel: Channel; +}; + +export const MessagePreviewUserDetails = ({ channel, message }: MessagePreviewUserDetailsProps) => { + const styles = useStyles(); + const { client } = useChatContext(); + + return message?.user?.id === client.user?.id ? ( + + ) : ( + {message?.user?.name || message?.user?.id}: + ); +}; + +const useStyles = () => { + const { + theme: { semantics }, + } = useTheme(); + return useMemo(() => { + return StyleSheet.create({ + username: { + color: semantics.textTertiary, + fontSize: primitives.typographyFontSizeSm, + fontWeight: primitives.typographyFontWeightSemiBold, + lineHeight: primitives.typographyLineHeightNormal, + }, + }); + }, [semantics]); +}; diff --git a/package/src/components/MessagePreview/hook/useGroupedAttachments.ts b/package/src/components/MessagePreview/hook/useGroupedAttachments.ts new file mode 100644 index 0000000000..16cf6ef914 --- /dev/null +++ b/package/src/components/MessagePreview/hook/useGroupedAttachments.ts @@ -0,0 +1,68 @@ +import { useMemo } from 'react'; + +import { + Attachment, + isImageAttachment, + isAudioAttachment, + isGiphyAttachment, + isVideoAttachment, + isFileAttachment, + isVoiceRecordingAttachment, +} from 'stream-chat'; + +type GroupedAttachments = { + giphys: Attachment[]; + audios: Attachment[]; + images: Attachment[]; + videos: Attachment[]; + files: Attachment[]; + voiceRecordings: Attachment[]; +}; + +/** + * Hook to group attachments by type. + * @param {Attachment[]} attachments + * @returns {GroupedAttachments} + */ +export const useGroupedAttachments = (attachments?: Attachment[]) => { + return useMemo(() => { + if (!attachments?.length) { + return { + giphys: [], + audios: [], + images: [], + videos: [], + files: [], + voiceRecordings: [], + }; + } + + return attachments.reduce( + (acc, attachment) => { + if (isGiphyAttachment(attachment)) { + acc.giphys.push(attachment); + } else if (isAudioAttachment(attachment)) { + acc.audios.push(attachment); + } else if (isImageAttachment(attachment)) { + acc.images.push(attachment); + } else if (isVideoAttachment(attachment)) { + acc.videos.push(attachment); + } else if (isFileAttachment(attachment)) { + acc.files.push(attachment); + } else if (isVoiceRecordingAttachment(attachment)) { + acc.voiceRecordings.push(attachment); + } + + return acc; + }, + { + giphys: [], + audios: [], + images: [], + videos: [], + files: [], + voiceRecordings: [], + }, + ); + }, [attachments]); +}; diff --git a/package/src/components/Reply/Reply.tsx b/package/src/components/Reply/Reply.tsx index 8057decc48..ad67fa1626 100644 --- a/package/src/components/Reply/Reply.tsx +++ b/package/src/components/Reply/Reply.tsx @@ -1,14 +1,11 @@ import React, { useCallback, useMemo } from 'react'; import { Image, StyleSheet, Text, TextStyle, View, ViewStyle } from 'react-native'; -import dayjs from 'dayjs'; import { isFileAttachment, isImageAttachment, isVideoAttachment, - LocalMessage, MessageComposerState, - PollState, } from 'stream-chat'; import { ChatContextValue, useChatContext } from '../../contexts/chatContext/ChatContext'; @@ -20,28 +17,18 @@ import { useMessageComposer } from '../../contexts/messageInputContext/hooks/use import { MessagesContextValue } from '../../contexts/messagesContext/MessagesContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { useStateStore } from '../../hooks'; -import { NewFile } from '../../icons/NewFile'; -import { NewLink } from '../../icons/NewLink'; -import { NewMapPin } from '../../icons/NewMapPin'; -import { NewMic } from '../../icons/NewMic'; -import { NewPhoto } from '../../icons/NewPhoto'; -import { NewPoll } from '../../icons/NewPoll'; -import { NewVideo } from '../../icons/NewVideo'; import { primitives } from '../../theme'; import { FileTypes } from '../../types/types'; import { checkQuotedMessageEquality } from '../../utils/utils'; import { FileIcon } from '../Attachment/FileIcon'; import { AttachmentRemoveControl } from '../MessageInput/components/AttachmentPreview/AttachmentRemoveControl'; +import { MessagePreview } from '../MessagePreview/MessagePreview'; import { VideoPlayIndicator } from '../ui/VideoPlayIndicator'; const messageComposerStateStoreSelector = (state: MessageComposerState) => ({ quotedMessage: state.quotedMessage, }); -const selector = (nextValue: PollState) => ({ - name: nextValue.name, -}); - const RightContent = React.memo( (props: Pick) => { const { ImageComponent, message } = props; @@ -86,232 +73,6 @@ const RightContent = React.memo( }, ); -const SubtitleText = React.memo(({ message }: { message?: LocalMessage | null }) => { - const { client } = useChatContext(); - const poll = client.polls.fromState(message?.poll_id ?? ''); - const { name: pollName } = useStateStore(poll?.state, selector) ?? {}; - const { - theme: { - reply: { subtitle: subtitleStyle }, - }, - } = useTheme(); - const styles = useStyles(); - - const subtitle = useMemo(() => { - const attachments = message?.attachments; - const audioAttachments = attachments?.filter( - (attachment) => attachment.type === FileTypes.Audio, - ); - const imageAttachments = attachments?.filter( - (attachment) => attachment.type === FileTypes.Image, - ); - const videoAttachments = attachments?.filter( - (attachment) => attachment.type === FileTypes.Video, - ); - const fileAttachments = attachments?.filter((attachment) => attachment.type === FileTypes.File); - const voiceRecordingAttachments = attachments?.filter( - (attachment) => attachment.type === FileTypes.VoiceRecording, - ); - const onlyImages = imageAttachments?.length && imageAttachments?.length === attachments?.length; - const onlyVideos = videoAttachments?.length && videoAttachments?.length === attachments?.length; - const onlyFiles = fileAttachments?.length && fileAttachments?.length === attachments?.length; - const onlyAudio = audioAttachments?.length === attachments?.length; - const onlyVoiceRecordings = - voiceRecordingAttachments?.length && - voiceRecordingAttachments?.length === attachments?.length; - - if (pollName) { - return pollName; - } - - if (message?.shared_location) { - if ( - message?.shared_location?.end_at && - new Date(message?.shared_location?.end_at) > new Date() - ) { - return 'Live Location'; - } - return 'Location'; - } - - if (message?.text) { - return message?.text; - } - - if (onlyImages) { - if (imageAttachments?.length === 1) { - return 'Photo'; - } else { - return `${imageAttachments?.length} Photos`; - } - } - - if (onlyVideos) { - if (videoAttachments?.length === 1) { - return 'Video'; - } else { - return `${videoAttachments?.length} Videos`; - } - } - - if (onlyAudio) { - if (audioAttachments?.length === 1) { - return 'Audio'; - } else { - return `${audioAttachments?.length} Audios`; - } - } - - if (onlyVoiceRecordings) { - if (voiceRecordingAttachments?.length === 1) { - return `Voice message (${dayjs.duration(voiceRecordingAttachments?.[0]?.duration ?? 0, 'seconds').format('m:ss')})`; - } else { - return `${voiceRecordingAttachments?.length} Voice messages`; - } - } - - if (onlyFiles && fileAttachments?.length === 1) { - return fileAttachments?.[0]?.title; - } - - return `${attachments?.length} Files`; - }, [message?.attachments, message?.shared_location, message?.text, pollName]); - - if (!subtitle) { - return null; - } - - return ( - - {subtitle} - - ); -}); - -const SubtitleIcon = React.memo((props: { message?: LocalMessage | null }) => { - const { message } = props; - const { - theme: { - semantics, - reply: { pollIcon, locationIcon, linkIcon, audioIcon, fileIcon, videoIcon, photoIcon }, - }, - } = useTheme(); - const styles = useStyles(); - - if (!message) { - return null; - } - - const attachments = message?.attachments; - const audioAttachments = attachments?.filter((attachment) => attachment.type === FileTypes.Audio); - const imageAttachments = attachments?.filter((attachment) => attachment.type === FileTypes.Image); - const videoAttachments = attachments?.filter((attachment) => attachment.type === FileTypes.Video); - const voiceRecordingAttachments = attachments?.filter( - (attachment) => attachment.type === FileTypes.VoiceRecording, - ); - const fileAttachments = attachments?.filter((attachment) => attachment.type === FileTypes.File); - const onlyImages = imageAttachments?.length && imageAttachments?.length === attachments?.length; - const onlyAudio = audioAttachments?.length && audioAttachments?.length === attachments?.length; - const onlyVideos = videoAttachments?.length && videoAttachments?.length === attachments?.length; - const onlyVoiceRecordings = - voiceRecordingAttachments?.length && voiceRecordingAttachments?.length === attachments?.length; - const hasLink = attachments?.some( - (attachment) => attachment.type === FileTypes.Image && attachment.og_scrape_url, - ); - - if (message.poll_id) { - return ( - - ); - } - - if (message.shared_location) { - return ( - - ); - } - - if (hasLink) { - return ( - - ); - } - - if (onlyAudio || onlyVoiceRecordings) { - return ( - - ); - } - - if (onlyVideos) { - return ( - - ); - } - - if (onlyImages) { - return ( - - ); - } - - if ( - fileAttachments?.length || - imageAttachments?.length || - videoAttachments?.length || - audioAttachments?.length - ) { - return ( - - ); - } - - return null; -}); - export type ReplyPropsWithContext = Pick & Pick & Pick & { @@ -347,7 +108,6 @@ export const ReplyWithContext = (props: ReplyPropsWithContext) => { leftContainer, rightContainer, title: titleStyle, - subtitleContainer, dismissWrapper, }, }, @@ -380,12 +140,7 @@ export const ReplyWithContext = (props: ReplyPropsWithContext) => { - - - - + @@ -524,21 +279,16 @@ const useStyles = () => { borderLeftColor: isMyMessage ? semantics.chatReplyIndicatorOutgoing : semantics.chatReplyIndicatorIncoming, + gap: primitives.spacingXxxs, }, rightContainer: {}, subtitle: { - color: semantics.textPrimary, + color: isMyMessage ? semantics.chatTextOutgoing : semantics.chatTextIncoming, flexShrink: 1, fontSize: primitives.typographyFontSizeXs, fontWeight: primitives.typographyFontWeightRegular, includeFontPadding: false, - lineHeight: 16, - }, - subtitleContainer: { - alignItems: 'center', - flexDirection: 'row', - gap: primitives.spacingXxs, - paddingTop: primitives.spacingXxs, + lineHeight: primitives.typographyLineHeightTight, }, titleContainer: { alignItems: 'center', diff --git a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap index 238d82c45c..bb14094db9 100644 --- a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap +++ b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap @@ -429,6 +429,7 @@ exports[`Thread should match thread snapshot 1`] = ` "borderColor": "rgba(0, 0, 0, 0.1)", "borderWidth": 1, }, + undefined, ] } testID="avatar-image" @@ -782,6 +783,7 @@ exports[`Thread should match thread snapshot 1`] = ` "borderColor": "rgba(0, 0, 0, 0.1)", "borderWidth": 1, }, + undefined, ] } testID="avatar-image" @@ -1160,6 +1162,7 @@ exports[`Thread should match thread snapshot 1`] = ` "borderColor": "rgba(0, 0, 0, 0.1)", "borderWidth": 1, }, + undefined, ] } testID="avatar-image" @@ -1519,6 +1522,7 @@ exports[`Thread should match thread snapshot 1`] = ` "borderColor": "rgba(0, 0, 0, 0.1)", "borderWidth": 1, }, + undefined, ] } testID="avatar-image" diff --git a/package/src/components/ThreadList/ThreadListItem.tsx b/package/src/components/ThreadList/ThreadListItem.tsx index 86ce5615a6..f02794c6bd 100644 --- a/package/src/components/ThreadList/ThreadListItem.tsx +++ b/package/src/components/ThreadList/ThreadListItem.tsx @@ -1,23 +1,9 @@ import React, { useCallback, useEffect, useMemo } from 'react'; import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; -import { - AttachmentManagerState, - ChannelState, - DraftMessage, - LocalMessage, - MessageResponse, - TextComposerState, - Thread, - ThreadState, -} from 'stream-chat'; +import { LocalMessage, Thread, ThreadState } from 'stream-chat'; -import { - TranslationContextValue, - useChatContext, - useTheme, - useTranslationContext, -} from '../../contexts'; +import { useChatContext, useTheme, useTranslationContext } from '../../contexts'; import { ThreadListItemProvider, useThreadListItemContext, @@ -25,7 +11,6 @@ import { import { useThreadsContext } from '../../contexts/threadsContext/ThreadsContext'; import { useStateStore } from '../../hooks'; import { MessageBubble } from '../../icons'; -import { FileTypes } from '../../types/types'; import { getDateString } from '../../utils/i18n/getDateString'; import { useChannelPreviewDisplayName } from '../ChannelPreview/hooks/useChannelPreviewDisplayName'; import { MessagePreview } from '../MessagePreview/MessagePreview'; @@ -85,169 +70,6 @@ export const attachmentTypeIconMap = { voiceRecording: '🎙️', } as const; -type LatestMessage = ReturnType | MessageResponse; - -const getMessageSenderName = ( - message: LatestMessage | undefined, - currentUserId: string | undefined, - t: (key: string) => string, -) => { - if (message?.user?.id === currentUserId) { - return t('You'); - } - - return message?.user?.name || message?.user?.username || message?.user?.id || ''; -}; - -const getPreviewFromMessage = ({ - t, - currentUserId, - draftMessage, - parentMessage = false, - message, -}: { - t: TranslationContextValue['t']; - currentUserId?: string; - draftMessage?: DraftMessage; - parentMessage?: boolean; - message?: LocalMessage; -}) => { - if (draftMessage) { - if (draftMessage.attachments?.length) { - const attachment = draftMessage?.attachments?.at(0); - - const attachmentIcon = attachment - ? `${ - attachmentTypeIconMap[ - (attachment.type as keyof typeof attachmentTypeIconMap) ?? 'file' - ] ?? attachmentTypeIconMap.file - } ` - : ''; - - if (attachment?.type === FileTypes.VoiceRecording) { - return [ - { bold: true, draft: true, text: 'Draft: ' }, - { bold: false, text: attachmentIcon }, - { - bold: false, - text: t('Voice message'), - }, - ]; - } - return [ - { bold: true, draft: true, text: 'Draft: ' }, - { bold: false, text: attachmentIcon }, - { - bold: false, - text: - attachment?.type === FileTypes.Image - ? attachment?.fallback - ? attachment?.fallback - : 'N/A' - : attachment?.title - ? attachment?.title - : 'N/A', - }, - ]; - } - - if (draftMessage.text) { - return [ - { bold: true, draft: true, text: 'Draft: ' }, - { - bold: false, - text: draftMessage.text, - }, - ]; - } - } - - if (message) { - const messageSender = getMessageSenderName(message, currentUserId, t); - const messageSenderText = !parentMessage ? (messageSender ? `${messageSender}: ` : '') : ''; - const isNotOwner = message.user?.id !== currentUserId; - - if (message.text) { - return [ - { bold: isNotOwner, text: messageSenderText }, - { bold: false, text: message.text || 'N/A' }, - ]; - } - if (message.command) { - return [ - { bold: isNotOwner, text: messageSenderText }, - { bold: false, text: `/${message.command}` }, - ]; - } - - if (message?.deleted_at && message.parent_id) { - return [ - { bold: isNotOwner, text: messageSenderText }, - { bold: false, text: `${t('This reply was deleted')}.` }, - ]; - } - - if (message?.deleted_at && !message.parent_id) { - return [ - { bold: isNotOwner, text: messageSenderText }, - { bold: false, text: `${t('The source message was deleted')}.` }, - ]; - } - - if (message?.attachments?.length) { - const attachment = message?.attachments?.at(0); - - const attachmentIcon = attachment - ? `${ - attachmentTypeIconMap[ - (attachment.type as keyof typeof attachmentTypeIconMap) ?? 'file' - ] ?? attachmentTypeIconMap.file - } ` - : ''; - - if (attachment?.type === FileTypes.VoiceRecording) { - return [ - { bold: false, text: attachmentIcon }, - { - bold: isNotOwner, - text: messageSenderText, - }, - { - bold: false, - text: t('Voice message'), - }, - ]; - } - - return [ - { bold: false, text: attachmentIcon }, - { bold: isNotOwner, text: messageSenderText }, - { - bold: false, - text: - attachment?.type === FileTypes.Image - ? attachment?.fallback - ? attachment?.fallback - : 'N/A' - : attachment?.title - ? attachment?.title - : 'N/A', - }, - ]; - } - } - - return [{ bold: false, text: t('Empty message...') }]; -}; - -const textComposerStateSelector = (state: TextComposerState) => ({ - text: state.text, -}); - -const stateSelector = (state: AttachmentManagerState) => ({ - attachments: state.attachments, -}); - export const ThreadListItemComponent = () => { const { channel, @@ -260,65 +82,18 @@ export const ThreadListItemComponent = () => { } = useThreadListItemContext(); const displayName = useChannelPreviewDisplayName(channel); const { onThreadSelect } = useThreadsContext(); - const { client } = useChatContext(); - const { t } = useTranslationContext(); const { theme: { colors: { accent_red, text_low_emphasis, white }, threadListItem, }, } = useTheme(); - const { text: draftText } = useStateStore( - thread.messageComposer.textComposer.state, - textComposerStateSelector, - ); - - const { attachments } = useStateStore( - thread.messageComposer.attachmentManager.state, - stateSelector, - ); useEffect(() => { const unsubscribe = thread.messageComposer.registerDraftEventSubscriptions(); return () => unsubscribe(); }, [thread.messageComposer]); - const draftMessage: DraftMessage | undefined = useMemo( - () => - !thread.messageComposer.compositionIsEmpty - ? { - attachments, - id: thread.messageComposer.id, - text: draftText, - } - : undefined, - [thread.messageComposer, attachments, draftText], - ); - - const previews = useMemo(() => { - return getPreviewFromMessage({ - currentUserId: client.userID, - draftMessage, - message: lastReply as LocalMessage, - t, - }); - }, [client.userID, draftMessage, lastReply, t]); - - const parentMessagePreview = useMemo(() => { - return [ - { - bold: true, - text: `${t('replied to')}: `, - }, - ...getPreviewFromMessage({ - currentUserId: client.userID, - message: parentMessage as LocalMessage, - parentMessage: true, - t, - }), - ]; - }, [client.userID, parentMessage, t]); - return ( { @@ -348,7 +123,7 @@ export const ThreadListItemComponent = () => { threadListItem.parentMessagePreviewContainer, ]} > - + {ownUnreadMessageCount > 0 && !deletedAtDateString ? ( { - + diff --git a/package/src/components/index.ts b/package/src/components/index.ts index ca1df85f52..146fe3b655 100644 --- a/package/src/components/index.ts +++ b/package/src/components/index.ts @@ -48,14 +48,11 @@ export * from './ChannelList/Skeleton'; export * from './ChannelPreview/ChannelPreview'; export * from './ChannelPreview/ChannelPreviewMessenger'; -export * from './ChannelPreview/ChannelPreviewMessage'; export * from './ChannelPreview/ChannelPreviewStatus'; export * from './ChannelPreview/ChannelPreviewTitle'; export * from './ChannelPreview/ChannelPreviewUnreadCount'; -export * from './ChannelPreview/hooks/useChannelPreviewDisplayAvatar'; export * from './ChannelPreview/hooks/useChannelPreviewDisplayName'; export * from './ChannelPreview/hooks/useChannelPreviewDisplayPresence'; -export * from './ChannelPreview/hooks/useLatestMessagePreview'; export * from './ChannelPreview/hooks/useChannelPreviewData'; export * from './ChannelPreview/hooks/useIsChannelMuted'; export * from './ChannelPreview/hooks/useMessageDeliveryStatus'; diff --git a/package/src/components/ui/Avatar/Avatar.tsx b/package/src/components/ui/Avatar/Avatar.tsx index a869d095c3..2ab75b1b2e 100644 --- a/package/src/components/ui/Avatar/Avatar.tsx +++ b/package/src/components/ui/Avatar/Avatar.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useMemo, useState } from 'react'; -import { ColorValue, StyleSheet, View } from 'react-native'; +import { ColorValue, StyleProp, StyleSheet, View, ViewStyle } from 'react-native'; import { avatarSizes } from './constants'; @@ -8,11 +8,12 @@ import { useTheme } from '../../../contexts/themeContext/ThemeContext'; import { primitives } from '../../../theme'; export type AvatarProps = { - size: 'xs' | 'sm' | 'md' | 'lg'; + size: '2xl' | 'xl' | 'lg' | 'md' | 'sm' | 'xs'; imageUrl?: string; placeholder?: React.ReactNode; showBorder?: boolean; backgroundColor?: ColorValue; + style?: StyleProp; }; export const Avatar = (props: AvatarProps) => { @@ -22,7 +23,14 @@ export const Avatar = (props: AvatarProps) => { } = useTheme(); const { ImageComponent } = useChatContext(); const defaultAvatarBg = semantics.avatarPaletteBg1; - const { backgroundColor = defaultAvatarBg, size, imageUrl, placeholder, showBorder } = props; + const { + backgroundColor = defaultAvatarBg, + size, + imageUrl, + placeholder, + showBorder, + style, + } = props; const styles = useStyles(); const onHandleError = useCallback(() => { @@ -36,6 +44,7 @@ export const Avatar = (props: AvatarProps) => { avatarSizes[size], { backgroundColor }, showBorder ? styles.border : undefined, + style, ]} testID='avatar-image' > diff --git a/package/src/components/ui/Avatar/AvatarGroup.tsx b/package/src/components/ui/Avatar/AvatarGroup.tsx index 6a7ef8e29e..8ec7c763a1 100644 --- a/package/src/components/ui/Avatar/AvatarGroup.tsx +++ b/package/src/components/ui/Avatar/AvatarGroup.tsx @@ -3,37 +3,42 @@ import { StyleSheet, View } from 'react-native'; import { UserResponse } from 'stream-chat'; -import { Avatar } from './Avatar'; +import { Avatar, AvatarProps } from './Avatar'; import { iconSizes } from './constants'; -import { UserAvatar } from './UserAvatar'; +import { UserAvatar, UserAvatarProps } from './UserAvatar'; import { useTheme } from '../../../contexts/themeContext/ThemeContext'; import { PeopleIcon } from '../../../icons/PeopleIcon'; import { primitives } from '../../../theme'; -import { BadgeCount, OnlineIndicator } from '../Badge'; +import { BadgeCount, BadgeCountProps, OnlineIndicator, OnlineIndicatorProps } from '../Badge'; export type AvatarGroupProps = { /** * The size of the avatar group. */ - size: 'lg' | 'xl'; + size: 'lg' | 'xl' | '2xl'; /** * The items to display in the avatar group. */ items: React.ReactNode[]; }; +// Sizes accounts for the border width as well const sizes = { - lg: { - width: 40, - height: 40, - }, - xl: { + '2xl': { width: 64, height: 64, }, + xl: { + width: 48, + height: 48, + }, + lg: { + width: 44, + height: 44, + }, }; const buildForTwo = (items: React.ReactNode[]) => { @@ -66,33 +71,46 @@ const buildForFour = (items: React.ReactNode[]) => { ); }; +const avatarSize: Record = { + '2xl': 'lg', + xl: 'md', + lg: 'sm', +}; + +const badgeCountSize: Record = { + '2xl': 'lg', + xl: 'md', + lg: 'sm', +}; + export const AvatarGroup = (props: AvatarGroupProps) => { const { size, items = [] } = props; const { theme: { semantics }, } = useTheme(); - - const avatarSize = size === 'lg' ? 'sm' : 'lg'; - const badgeCountSize = size === 'lg' ? 'xs' : 'md'; + const avatarGroupStyles = useUserAvatarGroupStyles(); const buildForOne = useCallback( (item: React.ReactNode) => { return buildForTwo([ + item, } - size={avatarSize} + size={avatarSize[size]} />, - item, ]); }, - [semantics.avatarTextDefault, avatarSize], + [avatarGroupStyles.userAvatarWrapper, semantics, size], ); const buildForMore = useCallback( @@ -103,12 +121,12 @@ export const AvatarGroup = (props: AvatarGroupProps) => { {items[0]} {items[1]} - + ); }, - [badgeCountSize], + [size], ); const renderItems = useMemo(() => { @@ -146,27 +164,42 @@ export type UserAvatarGroupProps = Pick & { showOnlineIndicator?: boolean; }; +const userAvatarSize: Record = { + '2xl': 'lg', + xl: 'md', + lg: 'sm', +}; + +const onlineIndicatorSize: Record = { + '2xl': 'xl', + xl: 'xl', + lg: 'lg', +}; + export const UserAvatarGroup = ({ users, showOnlineIndicator = true, size, }: UserAvatarGroupProps) => { const styles = useUserAvatarGroupStyles(); - const userAvatarSize = size === 'lg' ? 'sm' : 'lg'; - const onlineIndicatorSize = size === 'xl' ? 'xl' : 'lg'; + return ( ( - - - + ))} /> {showOnlineIndicator ? ( - + ) : null} @@ -183,7 +216,7 @@ const useUserAvatarGroupStyles = () => { StyleSheet.create({ userAvatarWrapper: { borderWidth: 2, - borderColor: semantics.borderCoreOnAccent, + borderColor: semantics.borderCoreOnDark, borderRadius: primitives.radiusMax, }, onlineIndicatorWrapper: { @@ -196,11 +229,8 @@ const useUserAvatarGroupStyles = () => { ); }; -// TODO V9: Add theming support here. const styles = StyleSheet.create({ - container: { - padding: 2, - }, + container: {}, topStart: { position: 'absolute', top: 0, diff --git a/package/src/components/ui/Avatar/ChannelAvatar.tsx b/package/src/components/ui/Avatar/ChannelAvatar.tsx index 335e22c840..53c7a36d4a 100644 --- a/package/src/components/ui/Avatar/ChannelAvatar.tsx +++ b/package/src/components/ui/Avatar/ChannelAvatar.tsx @@ -6,18 +6,22 @@ import { Avatar } from './Avatar'; import { UserAvatarGroup } from './AvatarGroup'; +import { UserAvatar } from './UserAvatar'; + import { useChannelPreviewDisplayPresence } from '../../../components/ChannelPreview/hooks/useChannelPreviewDisplayPresence'; +import { useChatContext } from '../../../contexts/chatContext/ChatContext'; import { useTheme } from '../../../contexts/themeContext/ThemeContext'; import { hashStringToNumber } from '../../../utils/utils'; export type ChannelAvatarProps = { channel: Channel; showOnlineIndicator?: boolean; - size: 'xs' | 'sm' | 'md' | 'lg'; + size: 'lg' | 'xl' | '2xl'; showBorder?: boolean; }; export const ChannelAvatar = (props: ChannelAvatarProps) => { + const { client } = useChatContext(); const { channel } = props; const online = useChannelPreviewDisplayPresence(channel); const { showOnlineIndicator = online, size, showBorder = true } = props; @@ -36,19 +40,34 @@ export const ChannelAvatar = (props: ChannelAvatarProps) => { () => Object.values(channel.state.members).map((member) => member.user as UserResponse), [channel.state.members], ); + const usersWithoutSelf = useMemo( + () => usersForGroup.filter((user) => user.id !== client.user?.id), + [usersForGroup, client.user?.id], + ); - if (!channelImage) { + if (channelImage) { return ( - + ); } - return ( - - ); + if (usersWithoutSelf.length > 1) { + return ( + + ); + } else { + return ( + + ); + } }; diff --git a/package/src/components/ui/Avatar/UserAvatar.tsx b/package/src/components/ui/Avatar/UserAvatar.tsx index 0a8685f69f..2c0f60de77 100644 --- a/package/src/components/ui/Avatar/UserAvatar.tsx +++ b/package/src/components/ui/Avatar/UserAvatar.tsx @@ -1,10 +1,10 @@ import React, { useMemo } from 'react'; -import { StyleSheet, Text, View } from 'react-native'; +import { StyleProp, StyleSheet, Text, View, ViewStyle } from 'react-native'; import { UserResponse } from 'stream-chat'; -import { Avatar } from './Avatar'; +import { Avatar, AvatarProps } from './Avatar'; import { fontSizes, iconSizes, indicatorSizes, numberOfInitials } from './constants'; import { useTheme } from '../../../contexts/themeContext/ThemeContext'; @@ -15,23 +15,24 @@ import { OnlineIndicator } from '../Badge'; export type UserAvatarProps = { user: UserResponse; showOnlineIndicator?: boolean; - size: 'xs' | 'sm' | 'md' | 'lg'; + size: AvatarProps['size']; showBorder?: boolean; + style?: StyleProp; }; export const UserAvatar = (props: UserAvatarProps) => { - const { user, size, showBorder = !!user.image, showOnlineIndicator } = props; + const { user, size, showBorder = !!user.image, showOnlineIndicator, style } = props; const { theme: { semantics }, } = useTheme(); const styles = useStyles(); - const hashedValue = hashStringToNumber(user.id); + const hashedValue = hashStringToNumber(user?.id || ''); const index = ((hashedValue % 5) + 1) as 1 | 2 | 3 | 4 | 5; const avatarBackgroundColor = semantics[`avatarPaletteBg${index}`]; const avatarTextColor = semantics[`avatarPaletteText${index}`]; const placeholder = useMemo(() => { - if (user.name) { + if (user?.name) { return ( {getInitialsFromName(user.name, numberOfInitials[size])} @@ -42,16 +43,17 @@ export const UserAvatar = (props: UserAvatarProps) => { ); } - }, [user.name, size, avatarTextColor]); + }, [user?.name, size, avatarTextColor]); return ( {showOnlineIndicator ? ( diff --git a/package/src/components/ui/Avatar/constants.ts b/package/src/components/ui/Avatar/constants.ts index fb7e612c04..4f249de732 100644 --- a/package/src/components/ui/Avatar/constants.ts +++ b/package/src/components/ui/Avatar/constants.ts @@ -2,9 +2,18 @@ import { TextStyle } from 'react-native'; import { UserAvatarProps } from './UserAvatar'; +import { primitives } from '../../../theme'; import { OnlineIndicatorProps } from '../Badge'; const avatarSizes = { + '2xl': { + height: 64, + width: 64, + }, + xl: { + height: 48, + width: 48, + }, lg: { height: 40, width: 40, @@ -24,10 +33,12 @@ const avatarSizes = { }; const indicatorSizes: Record = { - xs: 'sm', - sm: 'sm', - md: 'md', + '2xl': 'xl', + xl: 'xl', lg: 'lg', + md: 'md', + sm: 'sm', + xs: 'sm', }; const iconSizes: Record = { @@ -35,16 +46,48 @@ const iconSizes: Record = { sm: 12, md: 16, lg: 20, + xl: 24, + '2xl': 32, }; const fontSizes: Record< UserAvatarProps['size'], - { fontSize: number; lineHeight: number; fontWeight: TextStyle['fontWeight'] } + { + fontSize: TextStyle['fontSize']; + lineHeight: TextStyle['lineHeight']; + fontWeight: TextStyle['fontWeight']; + } > = { - xs: { fontSize: 12, lineHeight: 16, fontWeight: '600' }, - sm: { fontSize: 13, lineHeight: 16, fontWeight: '600' }, - md: { fontSize: 13, lineHeight: 16, fontWeight: '600' }, - lg: { fontSize: 15, lineHeight: 20, fontWeight: '600' }, + xs: { + fontSize: primitives.typographyFontSizeXs, + fontWeight: primitives.typographyFontWeightSemiBold, + lineHeight: primitives.typographyLineHeightTight, + }, + sm: { + fontSize: primitives.typographyFontSizeSm, + fontWeight: primitives.typographyFontWeightSemiBold, + lineHeight: primitives.typographyLineHeightNormal, + }, + md: { + fontSize: primitives.typographyFontSizeSm, + fontWeight: primitives.typographyFontWeightSemiBold, + lineHeight: primitives.typographyLineHeightNormal, + }, + lg: { + fontSize: primitives.typographyFontSizeMd, + fontWeight: primitives.typographyFontWeightSemiBold, + lineHeight: primitives.typographyLineHeightNormal, + }, + xl: { + fontSize: primitives.typographyFontSizeLg, + fontWeight: primitives.typographyFontWeightSemiBold, + lineHeight: primitives.typographyLineHeightRelaxed, + }, + '2xl': { + fontSize: primitives.typographyFontSizeXl, + fontWeight: primitives.typographyFontWeightSemiBold, + lineHeight: primitives.typographyLineHeightRelaxed, + }, }; const numberOfInitials: Record = { @@ -52,6 +95,8 @@ const numberOfInitials: Record = { sm: 1, md: 2, lg: 2, + xl: 2, + '2xl': 2, }; export { indicatorSizes, iconSizes, fontSizes, numberOfInitials, avatarSizes }; diff --git a/package/src/components/ui/Badge/BadgeCount.tsx b/package/src/components/ui/Badge/BadgeCount.tsx index 10a3e28a52..6939059bb6 100644 --- a/package/src/components/ui/Badge/BadgeCount.tsx +++ b/package/src/components/ui/Badge/BadgeCount.tsx @@ -6,50 +6,60 @@ import { primitives } from '../../../theme'; export type BadgeCountProps = { count: string | number; - size: 'sm' | 'xs' | 'md'; + size: 'sm' | 'md' | 'lg'; }; const sizes = { - md: { + lg: { minWidth: 32, height: 32, }, - sm: { + md: { minWidth: 24, height: 24, }, - xs: { + sm: { minWidth: 20, height: 20, }, }; const textStyles = { - md: { - fontSize: primitives.typographyFontSizeSm, + sm: { + fontSize: primitives.typographyFontSizeXxs, fontWeight: primitives.typographyFontWeightBold, - lineHeight: 14, + lineHeight: primitives.typographyLineHeightTight, }, - sm: { + md: { fontSize: primitives.typographyFontSizeSm, fontWeight: primitives.typographyFontWeightBold, - lineHeight: 14, + lineHeight: primitives.typographyLineHeightNormal, }, - xs: { - fontSize: primitives.typographyFontSizeXxs, + lg: { + fontSize: primitives.typographyFontSizeSm, fontWeight: primitives.typographyFontWeightBold, - lineHeight: 10, + lineHeight: primitives.typographyLineHeightNormal, }, }; +const paddingHorizontal: Record = { + sm: primitives.spacingXxs, + md: primitives.spacingXs, + lg: primitives.spacingXs, +}; + export const BadgeCount = (props: BadgeCountProps) => { const { count, size = 'sm' } = props; const styles = useStyles(); - const paddingHorizontal = size === 'xs' ? primitives.spacingXxs : primitives.spacingXs; return ( {count} diff --git a/package/src/components/ui/Badge/BadgeNotification.tsx b/package/src/components/ui/Badge/BadgeNotification.tsx index 0ca3acdb05..8bcc599dbf 100644 --- a/package/src/components/ui/Badge/BadgeNotification.tsx +++ b/package/src/components/ui/Badge/BadgeNotification.tsx @@ -7,40 +7,28 @@ import { primitives } from '../../../theme'; export type BadgeNotificationProps = { type: 'primary' | 'error' | 'neutral'; count: number; - size: 'sm' | 'md' | 'lg'; + size: 'sm' | 'xs'; testID?: string; }; const sizes = { - lg: { - height: 24, - minWidth: 24, - borderWidth: 2, - }, - md: { + sm: { height: 20, minWidth: 20, - borderWidth: 2, }, - sm: { + xs: { height: 16, minWidth: 16, - borderWidth: 1, }, }; const textStyles = { - lg: { - fontSize: primitives.typographyFontSizeSm, - fontWeight: primitives.typographyFontWeightBold, - lineHeight: 14, - }, - md: { + sm: { fontSize: primitives.typographyFontSizeSm, fontWeight: primitives.typographyFontWeightBold, lineHeight: 14, }, - sm: { + xs: { fontSize: primitives.typographyFontSizeXxs, fontWeight: primitives.typographyFontWeightBold, lineHeight: 10, @@ -48,7 +36,7 @@ const textStyles = { }; export const BadgeNotification = (props: BadgeNotificationProps) => { - const { type, count, size = 'md', testID } = props; + const { type, count, size = 'sm', testID } = props; const styles = useStyles(); const { theme: { semantics }, @@ -61,10 +49,12 @@ export const BadgeNotification = (props: BadgeNotificationProps) => { }; return ( - - - {count} - + + + + {count} + + ); }; @@ -79,10 +69,14 @@ const useStyles = () => { StyleSheet.create({ container: { paddingHorizontal: primitives.spacingXxs, - borderColor: semantics.badgeBorder, borderRadius: primitives.radiusMax, justifyContent: 'center', }, + border: { + borderWidth: 2, + borderColor: semantics.badgeBorder, + borderRadius: primitives.radiusMax, + }, text: { color: semantics.badgeTextOnAccent, includeFontPadding: false, diff --git a/package/src/contexts/channelsContext/ChannelsContext.tsx b/package/src/contexts/channelsContext/ChannelsContext.tsx index 1d67605f70..08ec95a5d9 100644 --- a/package/src/contexts/channelsContext/ChannelsContext.tsx +++ b/package/src/contexts/channelsContext/ChannelsContext.tsx @@ -7,7 +7,7 @@ import type { Channel } from 'stream-chat'; import type { HeaderErrorProps } from '../../components/ChannelList/ChannelListHeaderErrorIndicator'; import type { QueryChannels } from '../../components/ChannelList/hooks/usePaginatedChannels'; -import type { ChannelPreviewMessageProps } from '../../components/ChannelPreview/ChannelPreviewMessage'; +import { ChannelPreviewMessageProps } from '../../components/ChannelPreview/ChannelPreviewMessage'; import type { ChannelPreviewMessengerProps } from '../../components/ChannelPreview/ChannelPreviewMessenger'; import type { ChannelPreviewStatusProps } from '../../components/ChannelPreview/ChannelPreviewStatus'; import type { ChannelPreviewTitleProps } from '../../components/ChannelPreview/ChannelPreviewTitle'; @@ -206,6 +206,8 @@ export type ChannelsContextValue = { * **Default** [ChannelPreviewUnreadCount](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/ChannelPreview/ChannelPreviewUnreadCount.tsx) */ PreviewUnreadCount?: React.ComponentType; + + mutedStatusPosition?: 'trailingBottom' | 'inlineTitle'; }; export const ChannelsContext = React.createContext( diff --git a/package/src/contexts/themeContext/utils/theme.ts b/package/src/contexts/themeContext/utils/theme.ts index 5c5ce1602c..1354150737 100644 --- a/package/src/contexts/themeContext/utils/theme.ts +++ b/package/src/contexts/themeContext/utils/theme.ts @@ -1,8 +1,8 @@ import { type ColorValue, type ImageStyle, type TextStyle, type ViewStyle } from 'react-native'; -import type { CircleProps, StopProps } from 'react-native-svg'; +import type { CircleProps } from 'react-native-svg'; import type { IconProps } from '../../../icons/utils/base'; -import { semantics } from '../../../theme'; +import { primitives, semantics } from '../../../theme'; export const DEFAULT_STATUS_ICON_SIZE = 16; // TODO: Handle this better later depending on the size of the avatar used @@ -164,35 +164,37 @@ export type Theme = { }; channelListSkeleton: { animationTime: number; - background: ViewStyle; container: ViewStyle; - gradientStart: StopProps; - gradientStop: StopProps; height: number; - maskFillColor?: ColorValue; }; colors: typeof Colors; channelPreview: { - avatar: { - size: number; - }; - checkAllIcon: IconProps; - checkIcon: IconProps; container: ViewStyle; contentContainer: ViewStyle; date: TextStyle; - mutedStatus: { - height: number; - iconStyle: ViewStyle; - width: number; - }; + mutedStatus: IconProps; message: { container: ViewStyle; }; - row: ViewStyle; + messageDeliveryStatus: { + container: ViewStyle; + text: TextStyle; + checkAllIcon: IconProps; + checkIcon: IconProps; + timeIcon: IconProps; + }; + lowerRow: ViewStyle; title: TextStyle; unreadContainer: ViewStyle; unreadText: TextStyle; + typingIndicatorPreview: { + container: ViewStyle; + text: TextStyle; + }; + upperRow: ViewStyle; + statusContainer: ViewStyle; + titleContainer: ViewStyle; + wrapper: ViewStyle; }; dateHeader: { container: ViewStyle; @@ -1001,44 +1003,37 @@ export const defaultTheme: Theme = { flatListContent: {}, }, channelListSkeleton: { - animationTime: 1800, // in milliseconds - background: {}, + animationTime: 1500, // in milliseconds container: {}, - gradientStart: { - stopOpacity: 0, - }, - gradientStop: { - stopOpacity: 0.5, - }, - height: 64, + height: 80, }, channelPreview: { - avatar: { - size: 40, - }, - checkAllIcon: { - height: DEFAULT_STATUS_ICON_SIZE, - width: DEFAULT_STATUS_ICON_SIZE, - }, - checkIcon: { - height: DEFAULT_STATUS_ICON_SIZE, - width: DEFAULT_STATUS_ICON_SIZE, - }, container: {}, contentContainer: {}, date: {}, message: { container: {}, }, - mutedStatus: { - height: 20, - iconStyle: {}, - width: 20, + messageDeliveryStatus: { + container: {}, + text: {}, + checkAllIcon: {}, + checkIcon: {}, + timeIcon: {}, }, - row: {}, + mutedStatus: {}, + lowerRow: {}, title: {}, unreadContainer: {}, unreadText: {}, + typingIndicatorPreview: { + container: {}, + text: {}, + }, + upperRow: {}, + statusContainer: {}, + titleContainer: {}, + wrapper: {}, }, colors: Colors, dateHeader: { @@ -1109,7 +1104,7 @@ export const defaultTheme: Theme = { loadingDots: { container: {}, loadingDot: {}, - spacing: 4, + spacing: primitives.spacingXxs, }, loadingErrorIndicator: { container: {}, diff --git a/package/src/hooks/useTranslatedMessage.ts b/package/src/hooks/useTranslatedMessage.ts index a0684516a5..6cbe9d9229 100644 --- a/package/src/hooks/useTranslatedMessage.ts +++ b/package/src/hooks/useTranslatedMessage.ts @@ -1,10 +1,10 @@ -import type { LocalMessage, TranslationLanguages } from 'stream-chat'; +import type { LocalMessage, MessageResponse, TranslationLanguages } from 'stream-chat'; import { useTranslationContext } from '../contexts/translationContext/TranslationContext'; type TranslationKey = `${TranslationLanguages}_text`; -export const useTranslatedMessage = (message?: LocalMessage) => { +export const useTranslatedMessage = (message?: LocalMessage | MessageResponse) => { const { userLanguage } = useTranslationContext(); const translationKey: TranslationKey = `${userLanguage}_text`; diff --git a/package/src/i18n/en.json b/package/src/i18n/en.json index df0eca1ed6..6f12a2f8e9 100644 --- a/package/src/i18n/en.json +++ b/package/src/i18n/en.json @@ -148,6 +148,14 @@ "{{ index }} of {{ photoLength }}": "{{ index }} of {{ photoLength }}", "{{ replyCount }} Replies": "{{ replyCount }} Replies", "{{ user }} is typing": "{{ user }} is typing", + "You voted: {{ option }}": "You voted: {{ option }}", + "{{ firstUser }} and {{ secondUser }} are typing": "{{ firstUser }} and {{ secondUser }} are typing", + "{{ numberOfUsers }} people are typing": "{{ numberOfUsers }} people are typing", + "Typing": "Typing", + "No messages yet": "No messages yet", + "Message failed to send": "Message failed to send", + "and {{ count }} others": "and {{ count }} others", + "{{ user }} voted: {{ option }}": "{{ user }} voted: {{ option }}", "{{count}} votes_many": "{{count}} votes", "{{count}} votes_one": "{{count}} vote", "{{count}} votes_other": "{{count}} votes", @@ -163,5 +171,6 @@ "{{count}} Reactions_many": "{{count}} Reactions", "{{count}} Reactions_one": "{{count}} Reaction", "{{count}} Reactions_other": "{{count}} Reactions", - "Tap to remove": "Tap to remove" + "Tap to remove": "Tap to remove", + "Draft": "Draft" } diff --git a/package/src/i18n/es.json b/package/src/i18n/es.json index ca54acdddc..8073e73845 100644 --- a/package/src/i18n/es.json +++ b/package/src/i18n/es.json @@ -148,6 +148,14 @@ "{{ index }} of {{ photoLength }}": "{{ index }} de {{ photoLength }}", "{{ replyCount }} Replies": "{{ replyCount }} Respuestas", "{{ user }} is typing": "{{ user }} está escribiendo", + "You voted: {{ option }}": "Has votado: {{ option }}", + "{{ firstUser }} and {{ secondUser }} are typing": "{{ firstUser }} y {{ secondUser }} están escribiendo", + "{{ numberOfUsers }} people are typing": "{{ numberOfUsers }} personas están escribiendo", + "Typing": "Escribiendo", + "No messages yet": "No hay mensajes todavía", + "Message failed to send": "El mensaje no se pudo enviar", + "and {{ count }} others": "y {{ count }} más", + "{{ user }} voted: {{ option }}": "{{ user }} votó: {{ option }}", "{{count}} votes_many": "{{count}} votos", "{{count}} votes_one": "{{count}} voto", "{{count}} votes_other": "{{count}} votos", @@ -163,5 +171,6 @@ "{{count}} Reactions_many": "{{count}} reacciones", "{{count}} Reactions_one": "{{count}} reacción", "{{count}} Reactions_other": "{{count}} reacciones", - "Tap to remove": "Toca para quitar" + "Tap to remove": "Toca para quitar", + "Draft": "Borrador" } diff --git a/package/src/i18n/fr.json b/package/src/i18n/fr.json index 519bc99785..eb065f56ff 100644 --- a/package/src/i18n/fr.json +++ b/package/src/i18n/fr.json @@ -148,6 +148,14 @@ "{{ index }} of {{ photoLength }}": "{{ index }} sur {{ photoLength }}", "{{ replyCount }} Replies": "{{ replyCount }} Réponses", "{{ user }} is typing": "{{ user }} est en train d'écrire", + "You voted: {{ option }}": "Vous avez voté: {{ option }}", + "{{ firstUser }} and {{ secondUser }} are typing": "{{ firstUser }} et {{ secondUser }} sont en train d'écrire", + "{{ numberOfUsers }} people are typing": "{{ numberOfUsers }} personnes sont en train d'écrire", + "Typing": "Écrivant", + "No messages yet": "Aucun message pour le moment", + "Message failed to send": "Le message n'a pas pu être envoyé", + "and {{ count }} others": "et {{ count }} autres", + "{{ user }} voted: {{ option }}": "{{ user }} a voté: {{ option }}", "{{count}} votes_many": "{{count}} votes", "{{count}} votes_one": "{{count}} vote", "{{count}} votes_other": "{{count}} votes", @@ -163,5 +171,6 @@ "{{count}} Reactions_many": "{{count}} réactions", "{{count}} Reactions_one": "{{count}} réaction", "{{count}} Reactions_other": "{{count}} réactions", - "Tap to remove": "Appuyez pour retirer" + "Tap to remove": "Appuyez pour retirer", + "Draft": "Brouillon" } diff --git a/package/src/i18n/he.json b/package/src/i18n/he.json index 2712b45e80..dd10ce8bf6 100644 --- a/package/src/i18n/he.json +++ b/package/src/i18n/he.json @@ -148,6 +148,14 @@ "{{ index }} of {{ photoLength }}": "{{ index }} מתוך {{ photoLength }}", "{{ replyCount }} Replies": "{{ replyCount }} תגובות", "{{ user }} is typing": "{{ user }} מקליד/ה", + "You voted: {{ option }}": "הצבעת: {{ option }}", + "{{ firstUser }} and {{ secondUser }} are typing": "{{ firstUser }} ו-{{ secondUser }} מקלידים", + "{{ numberOfUsers }} people are typing": "{{ numberOfUsers }} משתמש/ים מקלידים", + "Typing": "מקליד/ה", + "No messages yet": "אין הודעות עדיין", + "Message failed to send": "ההודעה לא נשלחה", + "and {{ count }} others": "ועוד {{ count }} משתמש/ים", + "{{ user }} voted: {{ option }}": "{{ user }} הצבע: {{ option }}", "{{count}} votes_many": "{{count}} הצבעות", "{{count}} votes_one": "{{count}} הצבעה", "{{count}} votes_other": "{{count}} הצבעות", @@ -163,5 +171,6 @@ "{{count}} Reactions_many": "{{count}} תגובות", "{{count}} Reactions_one": "{{count}} תגובה", "{{count}} Reactions_other": "{{count}} תגובות", - "Tap to remove": "הקש כדי להסיר" + "Tap to remove": "הקש כדי להסיר", + "Draft": "טיוטה" } diff --git a/package/src/i18n/hi.json b/package/src/i18n/hi.json index 8350b7ad8d..46ee2096be 100644 --- a/package/src/i18n/hi.json +++ b/package/src/i18n/hi.json @@ -148,6 +148,14 @@ "{{ index }} of {{ photoLength }}": "{{ index }} / {{ photoLength }}", "{{ replyCount }} Replies": "{{ replyCount }} रिप्लाई", "{{ user }} is typing": "{{ user }} टाइप कर रहा है", + "You voted: {{ option }}": "आपने वोट दिया: {{ option }}", + "{{ firstUser }} and {{ secondUser }} are typing": "{{ firstUser }} और {{ secondUser }} लिख रहे हैं", + "{{ numberOfUsers }} people are typing": "{{ numberOfUsers }} लोग लिख रहे हैं", + "Typing": "लिख रहा है", + "No messages yet": "अभी तक कोई मैसेज नहीं है", + "Message failed to send": "मैसेज भेजने में विफल", + "and {{ count }} others": "और {{ count }} अन्य", + "{{ user }} voted: {{ option }}": "{{ user }} वोट दिया: {{ option }}", "{{count}} votes_many": "{{count}} वोट", "{{count}} votes_one": "{{count}} वोट", "{{count}} votes_other": "{{count}} वोट", @@ -163,5 +171,6 @@ "{{count}} Reactions_many": "{{count}} प्रतिक्रियाएँ", "{{count}} Reactions_one": "{{count}} प्रतिक्रिया", "{{count}} Reactions_other": "{{count}} प्रतिक्रियाएँ", - "Tap to remove": "हटाने के लिए टैप करें" + "Tap to remove": "हटाने के लिए टैप करें", + "Draft": "ड्राफ्ट" } diff --git a/package/src/i18n/it.json b/package/src/i18n/it.json index 5acf9cfb64..93bb20f5d7 100644 --- a/package/src/i18n/it.json +++ b/package/src/i18n/it.json @@ -148,6 +148,14 @@ "{{ index }} of {{ photoLength }}": "{{ index }} di {{ photoLength }}", "{{ replyCount }} Replies": "{{ replyCount }} Risposte", "{{ user }} is typing": "{{ user }} sta scrivendo", + "You voted: {{ option }}": "Hai votato: {{ option }}", + "{{ firstUser }} and {{ secondUser }} are typing": "{{ firstUser }} e {{ secondUser }} stanno scrivendo", + "{{ numberOfUsers }} people are typing": "{{ numberOfUsers }} persone stanno scrivendo", + "Typing": "Scrivendo", + "No messages yet": "Ancora nessun messaggio", + "Message failed to send": "Il messaggio non è stato inviato", + "and {{ count }} others": "e {{ count }} altri", + "{{ user }} voted: {{ option }}": "{{ user }} ha votato: {{ option }}", "{{count}} votes_many": "{{count}} voti", "{{count}} votes_one": "{{count}} voto", "{{count}} votes_other": "{{count}} voti", @@ -163,5 +171,6 @@ "{{count}} Reactions_many": "{{count}} reazioni", "{{count}} Reactions_one": "{{count}} reazione", "{{count}} Reactions_other": "{{count}} reazioni", - "Tap to remove": "Tocca per rimuovere" + "Tap to remove": "Tocca per rimuovere", + "Draft": "Borrador" } diff --git a/package/src/i18n/ja.json b/package/src/i18n/ja.json index 84b99efae6..6277931098 100644 --- a/package/src/i18n/ja.json +++ b/package/src/i18n/ja.json @@ -148,6 +148,14 @@ "{{ index }} of {{ photoLength }}": "{{ index }} / {{ photoLength }}", "{{ replyCount }} Replies": "{{ replyCount }}件の返信", "{{ user }} is typing": "{{ user }}はタイピング中", + "You voted: {{ option }}": "あなたが投票しました: {{ option }}", + "{{ firstUser }} and {{ secondUser }} are typing": "{{ firstUser }}と{{ secondUser }}がタイピングしています", + "{{ numberOfUsers }} people are typing": "{{ numberOfUsers }}人がタイピングしています", + "Typing": "タイピング中", + "No messages yet": "まだメッセージがありません", + "Message failed to send": "メッセージを送信できませんでした", + "and {{ count }} others": "{{ count }}人がタイピングしています", + "{{ user }} voted: {{ option }}": "{{ user }} が投票しました: {{ option }}", "{{count}} votes_many": "{{count}}票", "{{count}} votes_one": "{{count}} 票", "{{count}} votes_other": "{{count}} 票", @@ -163,5 +171,6 @@ "{{count}} Reactions_many": "{{count}}件のリアクション", "{{count}} Reactions_one": "{{count}}件のリアクション", "{{count}} Reactions_other": "{{count}}件のリアクション", - "Tap to remove": "タップして削除" + "Tap to remove": "タップして削除", + "Draft": "下書き" } diff --git a/package/src/i18n/ko.json b/package/src/i18n/ko.json index ddd3c3a8c3..b186d0a0cc 100644 --- a/package/src/i18n/ko.json +++ b/package/src/i18n/ko.json @@ -148,6 +148,14 @@ "{{ index }} of {{ photoLength }}": "{{ index }} / {{ photoLength }}", "{{ replyCount }} Replies": "{{ replyCount }} 답글", "{{ user }} is typing": "{{ user }} 타이핑 중", + "You voted: {{ option }}": "당신이 투표했습니다: {{ option }}", + "{{ firstUser }} and {{ secondUser }} are typing": "{{ firstUser }}와 {{ secondUser }}가 타이핑 중입니다", + "{{ numberOfUsers }} people are typing": "{{ numberOfUsers }}명이 타이핑 중입니다", + "Typing": "타이핑 중", + "No messages yet": "아직 메시지가 없습니다", + "Message failed to send": "메시지 전송 실패", + "and {{ count }} others": "{{ count }}명 이상", + "{{ user }} voted: {{ option }}": "{{ user }} 투표했습니다: {{ option }}", "{{count}} votes_many": "{{count}} 표", "{{count}} votes_one": "{{count}} 표", "{{count}} votes_other": "{{count}} 표", @@ -163,5 +171,6 @@ "{{count}} Reactions_many": "{{count}}개의 반응", "{{count}} Reactions_one": "{{count}}개의 반응", "{{count}} Reactions_other": "{{count}}개의 반응", - "Tap to remove": "탭하여 제거" + "Tap to remove": "탭하여 제거", + "Draft": "초안" } diff --git a/package/src/i18n/nl.json b/package/src/i18n/nl.json index a1db6fbec7..1ab63f14a5 100644 --- a/package/src/i18n/nl.json +++ b/package/src/i18n/nl.json @@ -148,6 +148,14 @@ "{{ index }} of {{ photoLength }}": "{{ index }} van {{ photoLength }}", "{{ replyCount }} Replies": "{{ replyCount }} Antwoorden", "{{ user }} is typing": "{{ user }} is aan het typen", + "You voted: {{ option }}": "Je hebt gestemd: {{ option }}", + "{{ firstUser }} and {{ secondUser }} are typing": "{{ firstUser }} en {{ secondUser }} zijn aan het typen", + "{{ numberOfUsers }} people are typing": "{{ numberOfUsers }} mensen zijn aan het typen", + "Typing": "Typen", + "No messages yet": "Nog geen berichten", + "Message failed to send": "Bericht niet verzonden", + "and {{ count }} others": "{{ count }} anderen", + "{{ user }} voted: {{ option }}": "{{ user }} heeft gestemd: {{ option }}", "{{count}} votes_many": "{{count}} stemmen", "{{count}} votes_one": "{{count}} stem", "{{count}} votes_other": "{{count}} stemmen", @@ -163,5 +171,6 @@ "{{count}} Reactions_many": "{{count}} reacties", "{{count}} Reactions_one": "{{count}} reactie", "{{count}} Reactions_other": "{{count}} reacties", - "Tap to remove": "Tik om te verwijderen" + "Tap to remove": "Tik om te verwijderen", + "Draft": "Ontwerp" } diff --git a/package/src/i18n/pt-br.json b/package/src/i18n/pt-br.json index d7f5384f08..cf7157b8d8 100644 --- a/package/src/i18n/pt-br.json +++ b/package/src/i18n/pt-br.json @@ -148,6 +148,14 @@ "{{ index }} of {{ photoLength }}": "{{ index }} de {{ photoLength }}", "{{ replyCount }} Replies": "{{ replyCount }} Respostas", "{{ user }} is typing": "{{ user }} está digitando", + "You voted: {{ option }}": "Você votou: {{ option }}", + "{{ firstUser }} and {{ secondUser }} are typing": "{{ firstUser }} e {{ secondUser }} estão digitando", + "{{ numberOfUsers }} people are typing": "{{ numberOfUsers }} pessoas estão digitando", + "Typing": "Digitando", + "No messages yet": "Ainda não há mensagens", + "Message failed to send": "Mensagem não enviada", + "and {{ count }} others": "{{ count }} outros", + "{{ user }} voted: {{ option }}": "{{ user }} votou: {{ option }}", "{{count}} votes_many": "{{count}} votos", "{{count}} votes_one": "{{count}} voto", "{{count}} votes_other": "{{count}} votos", @@ -163,5 +171,6 @@ "{{count}} Reactions_many": "{{count}} reações", "{{count}} Reactions_one": "{{count}} reação", "{{count}} Reactions_other": "{{count}} reações", - "Tap to remove": "Toque para remover" + "Tap to remove": "Toque para remover", + "Draft": "Rascunho" } diff --git a/package/src/i18n/ru.json b/package/src/i18n/ru.json index b400e7167a..e1818175ff 100644 --- a/package/src/i18n/ru.json +++ b/package/src/i18n/ru.json @@ -148,6 +148,14 @@ "{{ index }} of {{ photoLength }}": "{{ index }} из {{ photoLength }}", "{{ replyCount }} Replies": "{{ replyCount }} Ответов", "{{ user }} is typing": "{{ user }} пишет", + "You voted: {{ option }}": "Вы проголосовали: {{ option }}", + "{{ firstUser }} and {{ secondUser }} are typing": "{{ firstUser }} и {{ secondUser }} пишут", + "{{ numberOfUsers }} people are typing": "{{ numberOfUsers }} людей пишут", + "Typing": "Пишет", + "No messages yet": "Нет сообщений", + "Message failed to send": "Сообщение не отправлено", + "and {{ count }} others": "{{ count }} других", + "{{ user }} voted: {{ option }}": "{{ user }} проголосовал: {{ option }}", "{{count}} votes_many": "{{count}} голосов", "{{count}} votes_one": "{{count}} голос", "{{count}} votes_other": "{{count}} голосов", @@ -163,5 +171,6 @@ "{{count}} Reactions_many": "{{count}} реакций", "{{count}} Reactions_one": "{{count}} реакция", "{{count}} Reactions_other": "{{count}} реакций", - "Tap to remove": "Нажмите, чтобы удалить" + "Tap to remove": "Нажмите, чтобы удалить", + "Draft": "Черновик" } diff --git a/package/src/i18n/tr.json b/package/src/i18n/tr.json index ec52b0a270..5d0147efa1 100644 --- a/package/src/i18n/tr.json +++ b/package/src/i18n/tr.json @@ -148,6 +148,14 @@ "{{ index }} of {{ photoLength }}": "{{ index }} / {{ photoLength }}", "{{ replyCount }} Replies": "{{ replyCount }} Cevap", "{{ user }} is typing": "{{ user }} yazıyor", + "You voted: {{ option }}": "Oy verdiniz: {{ option }}", + "{{ firstUser }} and {{ secondUser }} are typing": "{{ firstUser }} ve {{ secondUser }} yazıyor", + "{{ numberOfUsers }} people are typing": "{{ numberOfUsers }} kişi yazıyor", + "Typing": "Yazıyor", + "No messages yet": "Henüz mesaj yok", + "Message failed to send": "Mesaj gönderimi başarısız", + "and {{ count }} others": "{{ count }} kişi daha", + "{{ user }} voted: {{ option }}": "{{ user }} oy verdi: {{ option }}", "{{count}} votes_many": "{{count}} oy", "{{count}} votes_one": "{{count}} oy", "{{count}} votes_other": "{{count}} oy", @@ -163,5 +171,6 @@ "{{count}} Reactions_many": "{{count}} tepki", "{{count}} Reactions_one": "{{count}} tepki", "{{count}} Reactions_other": "{{count}} tepki", - "Tap to remove": "Kaldırmak için dokunun" + "Tap to remove": "Kaldırmak için dokunun", + "Draft": "Taslak" } diff --git a/package/src/icons/Mute.tsx b/package/src/icons/Mute.tsx index f25292bacb..8b38911dd0 100644 --- a/package/src/icons/Mute.tsx +++ b/package/src/icons/Mute.tsx @@ -1,23 +1,20 @@ import React from 'react'; -import Svg, { Mask, Path } from 'react-native-svg'; +import Svg, { Path } from 'react-native-svg'; import { IconProps } from './utils/base'; -export const Mute = (props: IconProps) => ( - - - - - +export const Mute = ({ height, width, ...rest }: IconProps) => ( + + );