From 02a8b64f2b634495d5a081f99ce94cc25711a232 Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Mon, 16 Sep 2024 16:59:38 +0200 Subject: [PATCH 1/6] Show message time, and format according to locale --- components/Chat/Message/Message.tsx | 255 +++++++++++++++------------- utils/date.ts | 9 + 2 files changed, 150 insertions(+), 114 deletions(-) diff --git a/components/Chat/Message/Message.tsx b/components/Chat/Message/Message.tsx index 7b0b1551f..856991210 100644 --- a/components/Chat/Message/Message.tsx +++ b/components/Chat/Message/Message.tsx @@ -33,7 +33,7 @@ import { } from "../../../data/store/accountsStore"; import { XmtpMessage } from "../../../data/store/chatStore"; import { isAttachmentMessage } from "../../../utils/attachment/helpers"; -import { getRelativeDate } from "../../../utils/date"; +import { getLocalizedTime, getRelativeDate } from "../../../utils/date"; import { isDesktop } from "../../../utils/device"; import { converseEventEmitter } from "../../../utils/events"; import { @@ -242,6 +242,14 @@ function ChatMessage({ message, colorScheme, isGroup, isFrame }: Props) { const swipeableRef = useRef(null); + const messageTime = useMemo(() => { + console.log( + "getLocalizedTime(message.sent):", + getLocalizedTime(message.sent) + ); + return getLocalizedTime(message.sent); + }, [message.sent]); + return ( - - {!message.fromMe && } - - {isGroup && - !message.fromMe && - !message.hasPreviousMessageInSeries && - isChatMessage && } - - + + {!message.fromMe && } + + {isGroup && + !message.fromMe && + !message.hasPreviousMessageInSeries && + isChatMessage && } + - {isContentType("text", message.contentType) && ( - - )} - {replyingToMessage ? ( - - { - converseEventEmitter.emit("scrollChatToMessage", { - messageId: replyingToMessage.id, - animated: false, - }); - setTimeout(() => { - converseEventEmitter.emit( - "highlightMessage", - replyingToMessage.id - ); - }, 350); - }} - > - + {isContentType("text", message.contentType) && ( + + )} + {replyingToMessage ? ( + + { + converseEventEmitter.emit("scrollChatToMessage", { + messageId: replyingToMessage.id, + animated: false, + }); + setTimeout(() => { + converseEventEmitter.emit( + "highlightMessage", + replyingToMessage.id + ); + }, 350); + }} > - {replyingToProfileName} - - - + + {replyingToProfileName} + + + + + {messageContent} + + + ) : ( - {messageContent} + {messageContent} - - ) : ( - - {messageContent} - - )} - {shouldShowReactionsInside && ( - - - - )} - - {shouldShowOutsideContentRow ? ( - - {isFrame && ( - handleUrlPress(message.content)} - delayLongPress={platformTouchableLongPressDelay} - onLongPress={platformTouchableOnLongPress} - > - - {getUrlToRender(message.content)} - - )} - {shouldShowReactionsOutside && ( - + {shouldShowReactionsInside && ( + )} - {isFrame && message.fromMe && !hasReactions && ( - - )} - - ) : ( - message.fromMe && - !hasReactions && - )} + + {shouldShowOutsideContentRow ? ( + + {isFrame && ( + handleUrlPress(message.content)} + delayLongPress={platformTouchableLongPressDelay} + onLongPress={platformTouchableOnLongPress} + > + + {getUrlToRender(message.content)} + + + )} + {shouldShowReactionsOutside && ( + + + + )} + {isFrame && message.fromMe && !hasReactions && ( + + )} + + ) : ( + message.fromMe && + !hasReactions && + )} + + + {messageTime} + )} @@ -615,5 +622,25 @@ const useStyles = () => { outsideReactionsContainer: { flex: 1, }, + + messageContainer: { + flexDirection: "row", + width: "100%", + }, + messageContent: { + flexDirection: "row", + alignItems: "flex-end", + flex: 1, + paddingRight: 55, // Reduce width for time + }, + timeColumn: { + width: 55, + justifyContent: "center", + alignItems: "flex-end", + }, + timeText: { + fontSize: 12, + color: textSecondaryColor(colorScheme), + }, }); }; diff --git a/utils/date.ts b/utils/date.ts index 415fb9a25..4a9d7f141 100644 --- a/utils/date.ts +++ b/utils/date.ts @@ -79,3 +79,12 @@ export const getMinimalDate = (date: number) => { if (minutes > 0) return `${minutes}m`; return `${Math.max(seconds, 0)}s`; }; + +export const getLocalizedTime = (date: number | Date): string => { + if (!date) return ""; + + const locale = getLocale(); + const inputDate = new Date(date); + + return format(inputDate, "p", { locale }); +}; From bdc1201033c647425d30d09f520be268a2a1ddff Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Tue, 17 Sep 2024 12:15:45 +0200 Subject: [PATCH 2/6] Show time when single-tapping on a message --- components/Chat/Message/Message.tsx | 370 ++++++++++++++++------------ 1 file changed, 210 insertions(+), 160 deletions(-) diff --git a/components/Chat/Message/Message.tsx b/components/Chat/Message/Message.tsx index 856991210..7220318cf 100644 --- a/components/Chat/Message/Message.tsx +++ b/components/Chat/Message/Message.tsx @@ -7,7 +7,15 @@ import { } from "@styles/colors"; import { AvatarSizes } from "@styles/sizes"; import * as Haptics from "expo-haptics"; -import React, { ReactNode, useCallback, useMemo, useRef } from "react"; +import React, { + ReactNode, + useCallback, + useMemo, + useState, + forwardRef, + useRef, + useEffect, +} from "react"; import { Animated, ColorSchemeName, @@ -133,9 +141,35 @@ const MessageSenderAvatar = ({ message }: { message: MessageToDisplay }) => { ); }; -function ChatMessage({ message, colorScheme, isGroup, isFrame }: Props) { +interface ChatMessageMethods { + toggleTime: () => void; +} + +const ChatMessage = forwardRef< + ChatMessageMethods, + Props & { showTime: boolean; toggleTime: () => void } +>(function ChatMessage(props, ref) { + const { + account, + message, + colorScheme, + isGroup, + isFrame, + showTime, + toggleTime, + } = props; + const styles = useStyles(); + const messageDate = useMemo( + () => getRelativeDate(message.sent), + [message.sent] + ); + const messageTime = useMemo( + () => getLocalizedTime(message.sent), + [message.sent] + ); + let messageContent: ReactNode; const contentType = getMessageContentType(message.contentType); @@ -241,15 +275,6 @@ function ChatMessage({ message, colorScheme, isGroup, isFrame }: Props) { }, [replyingToMessage?.senderAddress]); const swipeableRef = useRef(null); - - const messageTime = useMemo(() => { - console.log( - "getLocalizedTime(message.sent):", - getLocalizedTime(message.sent) - ); - return getLocalizedTime(message.sent); - }, [message.sent]); - return ( {message.dateChange && ( - {getRelativeDate(message.sent)} + + {messageDate} {showTime && `– ${messageTime}`} + + )} + {!message.dateChange && showTime && ( + {messageTime} )} {isGroupUpdated && messageContent} {isChatMessage && ( @@ -317,139 +347,136 @@ function ChatMessage({ message, colorScheme, isGroup, isFrame }: Props) { ref={swipeableRef} > - - {!message.fromMe && } - - {isGroup && - !message.fromMe && - !message.hasPreviousMessageInSeries && - isChatMessage && } - } + + {isGroup && + !message.fromMe && + !message.hasPreviousMessageInSeries && + isChatMessage && } + + - - {isContentType("text", message.contentType) && ( - - )} - {replyingToMessage ? ( - - + )} + {replyingToMessage ? ( + + { + converseEventEmitter.emit("scrollChatToMessage", { + messageId: replyingToMessage.id, + animated: false, + }); + setTimeout(() => { + converseEventEmitter.emit( + "highlightMessage", + replyingToMessage.id + ); + }, 350); + }} + > + { - converseEventEmitter.emit("scrollChatToMessage", { - messageId: replyingToMessage.id, - animated: false, - }); - setTimeout(() => { - converseEventEmitter.emit( - "highlightMessage", - replyingToMessage.id - ); - }, 350); - }} - > - - {replyingToProfileName} - - - - - {messageContent} - - - ) : ( + {replyingToProfileName} + + + - {messageContent} + {messageContent} - )} - {shouldShowReactionsInside && ( - + ) : ( + + + {messageContent} + + + )} + {shouldShowReactionsInside && ( + + + + )} + + {shouldShowOutsideContentRow ? ( + + {isFrame && ( + handleUrlPress(message.content)} + delayLongPress={platformTouchableLongPressDelay} + onLongPress={platformTouchableOnLongPress} > + + {getUrlToRender(message.content)} + + + )} + {shouldShowReactionsOutside && ( + )} - - {shouldShowOutsideContentRow ? ( - - {isFrame && ( - handleUrlPress(message.content)} - delayLongPress={platformTouchableLongPressDelay} - onLongPress={platformTouchableOnLongPress} - > - - {getUrlToRender(message.content)} - - - )} - {shouldShowReactionsOutside && ( - - - - )} - {isFrame && message.fromMe && !hasReactions && ( - - )} - - ) : ( - message.fromMe && - !hasReactions && - )} - + {isFrame && message.fromMe && !hasReactions && ( + + )} + + ) : ( + message.fromMe && + !hasReactions && + )} - - {messageTime} - )} ); -} +}); // We use a cache for chat messages so that it doesn't rerender too often. // Indeed, since we use an inverted FlashList for chat, when a new message @@ -474,6 +501,17 @@ export default function CachedChatMessage({ isGroup, isFrame = false, }: Props) { + const chatMessageRef = useRef(null); + const [showTime, setShowTime] = useState(false); // State to trigger re-renders + + // TODO Review this + const toggleTime = useCallback(() => { + // Toggle the showTime state + setShowTime((prev) => !prev); + // Call the method in child component + chatMessageRef.current?.toggleTime(); + }, []); + const keysChangesToRerender: (keyof MessageToDisplay)[] = [ "id", "sent", @@ -488,39 +526,63 @@ export default function CachedChatMessage({ "nextMessageIsLoadingAttachment", "reactions", ]; - const alreadyRenderedMessage = renderedMessages.get( - `${account}-${message.id}` - ); + + const cacheKey = `${account}-${message.id}`; + const alreadyRenderedMessage = renderedMessages.get(cacheKey); + const shouldRerender = !alreadyRenderedMessage || alreadyRenderedMessage.colorScheme !== colorScheme || keysChangesToRerender.some( (k) => message[k] !== alreadyRenderedMessage.message[k] ); - if (shouldRerender) { - const renderedMessage = ChatMessage({ - account, - message, - colorScheme, - isGroup, - isFrame, - }); - renderedMessages.set(`${account}-${message.id}`, { + + const renderedMessage = useMemo( + () => ( + + ), + [account, message, colorScheme, isGroup, isFrame, showTime, toggleTime] + ); + + useEffect(() => { + renderedMessages.set(cacheKey, { message, renderedMessage, colorScheme, isGroup, isFrame, }); - return renderedMessage; - } else { - return alreadyRenderedMessage.renderedMessage; - } + }, [ + cacheKey, + message, + renderedMessage, + colorScheme, + isGroup, + isFrame, + shouldRerender, + toggleTime, + ]); + + return renderedMessage; } const useStyles = () => { const colorScheme = useColorScheme(); return StyleSheet.create({ + messageContainer: { + flexDirection: "row", + width: "100%", + alignItems: "flex-end", + }, innerBubble: { backgroundColor: messageInnerBubbleColor(colorScheme), borderRadius: 14, @@ -563,6 +625,14 @@ const useStyles = () => { marginBottom: 8, fontWeight: "bold", }, + time: { + flexBasis: "100%", + textAlign: "center", + fontSize: 12, + color: textSecondaryColor(colorScheme), + marginTop: 4, + marginBottom: 4, + }, replyToUsername: { fontSize: 12, marginBottom: 4, @@ -622,25 +692,5 @@ const useStyles = () => { outsideReactionsContainer: { flex: 1, }, - - messageContainer: { - flexDirection: "row", - width: "100%", - }, - messageContent: { - flexDirection: "row", - alignItems: "flex-end", - flex: 1, - paddingRight: 55, // Reduce width for time - }, - timeColumn: { - width: 55, - justifyContent: "center", - alignItems: "flex-end", - }, - timeText: { - fontSize: 12, - color: textSecondaryColor(colorScheme), - }, }); }; From 54a48c68acdc0a2a54f1b23887a80de7ebc16fb6 Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Tue, 17 Sep 2024 14:40:28 +0200 Subject: [PATCH 3/6] Remove `forwardRef`, simplify the `showTime` and `toggleTime` implementation by calling a state change to trigger a re-render --- components/Chat/Message/Message.tsx | 37 +++++++++++------------------ 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/components/Chat/Message/Message.tsx b/components/Chat/Message/Message.tsx index 7220318cf..7d50a4229 100644 --- a/components/Chat/Message/Message.tsx +++ b/components/Chat/Message/Message.tsx @@ -12,7 +12,6 @@ import React, { useCallback, useMemo, useState, - forwardRef, useRef, useEffect, } from "react"; @@ -145,20 +144,15 @@ interface ChatMessageMethods { toggleTime: () => void; } -const ChatMessage = forwardRef< - ChatMessageMethods, - Props & { showTime: boolean; toggleTime: () => void } ->(function ChatMessage(props, ref) { - const { - account, - message, - colorScheme, - isGroup, - isFrame, - showTime, - toggleTime, - } = props; - +const ChatMessage = ({ + account, + message, + colorScheme, + isGroup, + isFrame, + showTime, + toggleTime, +}: Props & { showTime: boolean; toggleTime: () => void }) => { const styles = useStyles(); const messageDate = useMemo( @@ -275,6 +269,7 @@ const ChatMessage = forwardRef< }, [replyingToMessage?.senderAddress]); const swipeableRef = useRef(null); + return ( ); -}); +}; // We use a cache for chat messages so that it doesn't rerender too often. // Indeed, since we use an inverted FlashList for chat, when a new message @@ -501,15 +496,12 @@ export default function CachedChatMessage({ isGroup, isFrame = false, }: Props) { - const chatMessageRef = useRef(null); - const [showTime, setShowTime] = useState(false); // State to trigger re-renders + // State to trigger re-renders + const [showTime, setShowTime] = useState(false); - // TODO Review this + // Toggle the showTime state const toggleTime = useCallback(() => { - // Toggle the showTime state setShowTime((prev) => !prev); - // Call the method in child component - chatMessageRef.current?.toggleTime(); }, []); const keysChangesToRerender: (keyof MessageToDisplay)[] = [ @@ -540,7 +532,6 @@ export default function CachedChatMessage({ const renderedMessage = useMemo( () => ( Date: Tue, 17 Sep 2024 15:18:27 +0200 Subject: [PATCH 4/6] Animate time toggle --- components/Chat/Message/Message.tsx | 70 +++++++++++++++++++++-------- 1 file changed, 51 insertions(+), 19 deletions(-) diff --git a/components/Chat/Message/Message.tsx b/components/Chat/Message/Message.tsx index 7d50a4229..627a887ff 100644 --- a/components/Chat/Message/Message.tsx +++ b/components/Chat/Message/Message.tsx @@ -16,7 +16,7 @@ import React, { useEffect, } from "react"; import { - Animated, + Animated as RNAnimated, ColorSchemeName, Linking, Platform, @@ -28,6 +28,11 @@ import { DimensionValue, } from "react-native"; import { Swipeable } from "react-native-gesture-handler"; +import Animated, { + useSharedValue, + useAnimatedStyle, + withTiming, +} from "react-native-reanimated"; import ChatMessageActions from "./MessageActions"; import ChatMessageReactions from "./MessageReactions"; @@ -140,10 +145,6 @@ const MessageSenderAvatar = ({ message }: { message: MessageToDisplay }) => { ); }; -interface ChatMessageMethods { - toggleTime: () => void; -} - const ChatMessage = ({ account, message, @@ -164,6 +165,38 @@ const ChatMessage = ({ [message.sent] ); + // Shared values for height, translateY, and opacity + const height = useSharedValue(0); + const translateY = useSharedValue(20); + const opacity = useSharedValue(0); + + // Define animated styles using shared values + const animatedStyle = useAnimatedStyle(() => { + return { + minHeight: height.value, // Use minHeight for more stable height animations + transform: [{ translateY: translateY.value }], + opacity: opacity.value, + }; + }); + + // Handle animation based on showTime prop + React.useEffect(() => { + if (showTime) { + // Animate to show: increase height, move up, and fade in + height.value = withTiming(34, { duration: 300 }); // Adjust target height as needed + translateY.value = withTiming(0, { duration: 300 }); + opacity.value = withTiming(1, { duration: 300 }); + } else { + // Animate to hide: collapse height, move down, and fade out + opacity.value = withTiming(0, { duration: 300 }); // Start fade out first + height.value = withTiming(0, { duration: 300 }, (finished) => { + if (finished) { + translateY.value = withTiming(20, { duration: 300 }); // Then move down + } + }); + } + }, [showTime, height, opacity, translateY]); + let messageContent: ReactNode; const contentType = getMessageContentType(message.contentType); @@ -283,12 +316,14 @@ const ChatMessage = ({ ]} > {message.dateChange && ( - + {messageDate} {showTime && `– ${messageTime}`} )} {!message.dateChange && showTime && ( - {messageTime} + + {messageTime} + )} {isGroupUpdated && messageContent} {isChatMessage && ( @@ -299,12 +334,12 @@ const ChatMessage = ({ containerStyle={styles.messageSwipeable} childrenContainerStyle={styles.messageSwipeableChildren} renderLeftActions={( - progressAnimatedValue: Animated.AnimatedInterpolation< + progressAnimatedValue: RNAnimated.AnimatedInterpolation< string | number > ) => { return ( - - + ); }} leftThreshold={10000} // Never trigger opening @@ -607,7 +642,12 @@ const useStyles = () => { color: textSecondaryColor(colorScheme), flexGrow: 1, }, - date: { + dateTimeContainer: { + overflow: "hidden", + width: "100%", + minHeight: 20, + }, + dateTime: { flexBasis: "100%", textAlign: "center", fontSize: 12, @@ -616,14 +656,6 @@ const useStyles = () => { marginBottom: 8, fontWeight: "bold", }, - time: { - flexBasis: "100%", - textAlign: "center", - fontSize: 12, - color: textSecondaryColor(colorScheme), - marginTop: 4, - marginBottom: 4, - }, replyToUsername: { fontSize: 12, marginBottom: 4, From e2461ae6f70edbde965c1007c8f37067a91c33ba Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Tue, 17 Sep 2024 15:31:54 +0200 Subject: [PATCH 5/6] Cleanup --- components/Chat/Message/Message.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/components/Chat/Message/Message.tsx b/components/Chat/Message/Message.tsx index 627a887ff..1b3e5734c 100644 --- a/components/Chat/Message/Message.tsx +++ b/components/Chat/Message/Message.tsx @@ -165,12 +165,11 @@ const ChatMessage = ({ [message.sent] ); - // Shared values for height, translateY, and opacity + // Reanimated shared values for height, translateY, and opacity const height = useSharedValue(0); const translateY = useSharedValue(20); const opacity = useSharedValue(0); - // Define animated styles using shared values const animatedStyle = useAnimatedStyle(() => { return { minHeight: height.value, // Use minHeight for more stable height animations @@ -182,16 +181,14 @@ const ChatMessage = ({ // Handle animation based on showTime prop React.useEffect(() => { if (showTime) { - // Animate to show: increase height, move up, and fade in - height.value = withTiming(34, { duration: 300 }); // Adjust target height as needed + height.value = withTiming(34, { duration: 300 }); translateY.value = withTiming(0, { duration: 300 }); opacity.value = withTiming(1, { duration: 300 }); } else { - // Animate to hide: collapse height, move down, and fade out - opacity.value = withTiming(0, { duration: 300 }); // Start fade out first + opacity.value = withTiming(0, { duration: 300 }); height.value = withTiming(0, { duration: 300 }, (finished) => { if (finished) { - translateY.value = withTiming(20, { duration: 300 }); // Then move down + translateY.value = withTiming(20, { duration: 300 }); } }); } From 18dd61da8683d6afaa9caad7007d8270e258e4db Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Wed, 18 Sep 2024 11:14:56 +0200 Subject: [PATCH 6/6] Enhanced and smooth show time animation, prevents re-render --- components/Chat/Message/Message.tsx | 43 ++++++++++------------------- 1 file changed, 15 insertions(+), 28 deletions(-) diff --git a/components/Chat/Message/Message.tsx b/components/Chat/Message/Message.tsx index 1b3e5734c..b4d797cd5 100644 --- a/components/Chat/Message/Message.tsx +++ b/components/Chat/Message/Message.tsx @@ -11,7 +11,6 @@ import React, { ReactNode, useCallback, useMemo, - useState, useRef, useEffect, } from "react"; @@ -151,9 +150,7 @@ const ChatMessage = ({ colorScheme, isGroup, isFrame, - showTime, - toggleTime, -}: Props & { showTime: boolean; toggleTime: () => void }) => { +}: Props) => { const styles = useStyles(); const messageDate = useMemo( @@ -172,19 +169,24 @@ const ChatMessage = ({ const animatedStyle = useAnimatedStyle(() => { return { - minHeight: height.value, // Use minHeight for more stable height animations + height: height.value, + overflow: "hidden", + width: "100%", transform: [{ translateY: translateY.value }], opacity: opacity.value, }; }); - // Handle animation based on showTime prop - React.useEffect(() => { - if (showTime) { + // Handle showTime animation + const showTime = useRef(false); + const animateTime = useCallback(() => { + if (showTime.current === false) { + showTime.current = true; height.value = withTiming(34, { duration: 300 }); translateY.value = withTiming(0, { duration: 300 }); opacity.value = withTiming(1, { duration: 300 }); } else { + showTime.current = false; opacity.value = withTiming(0, { duration: 300 }); height.value = withTiming(0, { duration: 300 }, (finished) => { if (finished) { @@ -192,7 +194,7 @@ const ChatMessage = ({ } }); } - }, [showTime, height, opacity, translateY]); + }, [height, translateY, opacity]); let messageContent: ReactNode; const contentType = getMessageContentType(message.contentType); @@ -318,7 +320,7 @@ const ChatMessage = ({ )} {!message.dateChange && showTime && ( - + {messageTime} )} @@ -449,7 +451,7 @@ const ChatMessage = ({ : undefined, ]} > - + {messageContent} @@ -528,14 +530,6 @@ export default function CachedChatMessage({ isGroup, isFrame = false, }: Props) { - // State to trigger re-renders - const [showTime, setShowTime] = useState(false); - - // Toggle the showTime state - const toggleTime = useCallback(() => { - setShowTime((prev) => !prev); - }, []); - const keysChangesToRerender: (keyof MessageToDisplay)[] = [ "id", "sent", @@ -569,11 +563,9 @@ export default function CachedChatMessage({ colorScheme={colorScheme} isGroup={isGroup} isFrame={isFrame} - showTime={showTime} - toggleTime={toggleTime} /> ), - [account, message, colorScheme, isGroup, isFrame, showTime, toggleTime] + [account, message, colorScheme, isGroup, isFrame] ); useEffect(() => { @@ -592,7 +584,6 @@ export default function CachedChatMessage({ isGroup, isFrame, shouldRerender, - toggleTime, ]); return renderedMessage; @@ -639,11 +630,6 @@ const useStyles = () => { color: textSecondaryColor(colorScheme), flexGrow: 1, }, - dateTimeContainer: { - overflow: "hidden", - width: "100%", - minHeight: 20, - }, dateTime: { flexBasis: "100%", textAlign: "center", @@ -652,6 +638,7 @@ const useStyles = () => { marginTop: 12, marginBottom: 8, fontWeight: "bold", + height: 20, }, replyToUsername: { fontSize: 12,