From 6d1e4fdbc3061efaba83d0a0524f06b342924b61 Mon Sep 17 00:00:00 2001 From: Alex Risch Date: Wed, 21 Aug 2024 14:51:44 -0600 Subject: [PATCH] feat: Unread indicators on Pinned Chats Added unread indicator on pinned chats Added shared function for determining if unread should be shown Added Indicator component --- components/Avatar.tsx | 31 ++++++++---- components/ConversationFlashList.tsx | 21 ++++---- .../GroupConversationItem.tsx | 25 ++++------ components/GroupAvatar.tsx | 48 ++----------------- components/Indicator.tsx | 43 +++++++++++++++++ .../PinnedConversation.tsx | 25 ++++++++-- .../conversation/showUnreadOnConversation.ts | 23 +++++++++ 7 files changed, 131 insertions(+), 85 deletions(-) create mode 100644 components/Indicator.tsx create mode 100644 utils/conversation/showUnreadOnConversation.ts diff --git a/components/Avatar.tsx b/components/Avatar.tsx index 4721ea7c6..c47e0d1bb 100644 --- a/components/Avatar.tsx +++ b/components/Avatar.tsx @@ -1,7 +1,7 @@ import { actionSecondaryColor, textSecondaryColor } from "@styles/colors"; import { AvatarSizes } from "@styles/sizes"; import { getFirstLetterForAvatar } from "@utils/getFirstLetterForAvatar"; -import { Image } from "expo-image"; +import { ImageBackground } from "expo-image"; import React, { useCallback, useState } from "react"; import { ColorSchemeName, @@ -13,12 +13,15 @@ import { View, } from "react-native"; +import { Indicator } from "./Indicator"; + type Props = { uri?: string | undefined; size?: number | undefined; style?: StyleProp; color?: boolean; name?: string | undefined; + showIndicator?: boolean; }; function Avatar({ @@ -27,6 +30,7 @@ function Avatar({ style, color, name, + showIndicator, }: Props) { const colorScheme = useColorScheme(); const styles = getStyles(colorScheme, size); @@ -40,15 +44,19 @@ function Avatar({ const handleImageLoad = useCallback(() => { setDidError(false); }, []); - return uri && !didError ? ( - + <> + + {showIndicator && } + + ) : ( {firstLetter} + {showIndicator && } ); } @@ -65,9 +74,11 @@ function Avatar({ const getStyles = (colorScheme: ColorSchemeName, size: number) => StyleSheet.create({ image: { + borderRadius: size, + }, + imageContainer: { width: size, height: size, - borderRadius: size, }, placeholder: { width: size, diff --git a/components/ConversationFlashList.tsx b/components/ConversationFlashList.tsx index b2e7acb3a..3d4d4b573 100644 --- a/components/ConversationFlashList.tsx +++ b/components/ConversationFlashList.tsx @@ -1,6 +1,7 @@ import { NativeStackScreenProps } from "@react-navigation/native-stack"; import { FlashList } from "@shopify/flash-list"; import { backgroundColor } from "@styles/colors"; +import { showUnreadOnConversation } from "@utils/conversation/showUnreadOnConversation"; import { useCallback, useEffect, useRef } from "react"; import { Platform, StyleSheet, View, useColorScheme } from "react-native"; @@ -129,19 +130,13 @@ export default function ConversationFlashList({ lastMessagePreview?.message?.sent || conversation.createdAt } conversationName={conversationName(conversation)} - showUnread={(() => { - if (!initialLoadDoneOnce) return false; - if (!lastMessagePreview) return false; - // Manually marked as unread - if (topicsData[conversation.topic]?.status === "unread") - return true; - // If not manually markes as unread, we only show badge if last message - // not from me - if (lastMessagePreview.message.senderAddress === userAddress) - return false; - const readUntil = topicsData[conversation.topic]?.readUntil || 0; - return readUntil < lastMessagePreview.message.sent; - })()} + showUnread={showUnreadOnConversation( + initialLoadDoneOnce, + lastMessagePreview, + topicsData, + conversation, + userAddress + )} lastMessagePreview={ conversation.peerAddress && peersStatus[conversation.peerAddress.toLowerCase()] === "blocked" diff --git a/components/ConversationList/GroupConversationItem.tsx b/components/ConversationList/GroupConversationItem.tsx index c7d224914..339e7c670 100644 --- a/components/ConversationList/GroupConversationItem.tsx +++ b/components/ConversationList/GroupConversationItem.tsx @@ -5,6 +5,7 @@ import { useGroupNameQuery } from "@queries/useGroupNameQuery"; import { useGroupPhotoQuery } from "@queries/useGroupPhotoQuery"; import { NativeStackScreenProps } from "@react-navigation/native-stack"; import { actionSheetColors } from "@styles/colors"; +import { showUnreadOnConversation } from "@utils/conversation/showUnreadOnConversation"; import { FC, useCallback } from "react"; import { useColorScheme } from "react-native"; @@ -44,7 +45,7 @@ export const GroupConversationItem: FC = ({ staleTime: Infinity, gcTime: Infinity, }); - const { consent, blockGroup, allowGroup } = useGroupConsent(topic, { + const { consent, blockGroup, allowGroup } = useGroupConsent(topic, { refetchOnMount: false, staleTime: Infinity, gcTime: Infinity, @@ -161,21 +162,13 @@ export const GroupConversationItem: FC = ({ lastMessagePreview?.message?.sent || conversation.createdAt } conversationName={groupName ? groupName : conversationName(conversation)} - showUnread={(() => { - if (!initialLoadDoneOnce) return false; - if (!lastMessagePreview) return false; - // Manually marked as unread - if (topicsData[conversation.topic]?.status === "unread") return true; - // If not manually markes as unread, we only show badge if last message - // not from me - if ( - lastMessagePreview.message.senderAddress.toLowerCase() === - userAddress.toLowerCase() - ) - return false; - const readUntil = topicsData[conversation.topic]?.readUntil || 0; - return readUntil < lastMessagePreview.message.sent; - })()} + showUnread={showUnreadOnConversation( + initialLoadDoneOnce, + lastMessagePreview, + topicsData, + conversation, + userAddress + )} lastMessagePreview={ lastMessagePreview ? lastMessagePreview.contentPreview : "" } diff --git a/components/GroupAvatar.tsx b/components/GroupAvatar.tsx index 5b9029c87..1e1e7a621 100644 --- a/components/GroupAvatar.tsx +++ b/components/GroupAvatar.tsx @@ -17,6 +17,7 @@ import { } from "react-native"; import Avatar from "./Avatar"; +import { Indicator } from "./Indicator"; import { useProfilesStore, useCurrentAccount, @@ -42,6 +43,7 @@ type GroupAvatarProps = { // Converstion List should not make requests across the bridge // Use data from the initial sync, and as the query gets updated onConversationListScreen?: boolean; + showIndicator?: boolean; }; const calculatePositions = ( @@ -79,48 +81,6 @@ const calculatePositions = ( ); }; -const PlaceholderAvatar: React.FC<{ - pos: Position; - firstLetter: string; - colorScheme: ColorSchemeType; - size: number; -}> = ({ pos, firstLetter, colorScheme, size }) => { - const styles = getStyles(colorScheme); - return ( - - - {firstLetter} - - - ); -}; - const ExtraMembersIndicator: React.FC<{ pos: Position; extraMembersCount: number; @@ -171,6 +131,7 @@ const GroupAvatar: React.FC = ({ pendingGroupMembers, excludeSelf = true, onConversationListScreen = false, + showIndicator = false, }) => { const colorScheme = useColorScheme(); const styles = getStyles(colorScheme); @@ -224,7 +185,7 @@ const GroupAvatar: React.FC = ({ return ( {uri ? ( - + ) : ( @@ -261,6 +222,7 @@ const GroupAvatar: React.FC = ({ return null; })} + {showIndicator && } )} diff --git a/components/Indicator.tsx b/components/Indicator.tsx new file mode 100644 index 000000000..0d21f779e --- /dev/null +++ b/components/Indicator.tsx @@ -0,0 +1,43 @@ +import { backgroundColor, badgeColor } from "@styles/colors"; +import React, { memo } from "react"; +import { + useColorScheme, + ColorSchemeName, + StyleSheet, + View, +} from "react-native"; + +const IndicatorInner = ({ size }: { size: number }) => { + const styles = getStyles(useColorScheme(), size); + + return ( + + + + ); +}; + +export const Indicator = memo(IndicatorInner); + +const getStyles = (colorScheme: ColorSchemeName, size: number) => + StyleSheet.create({ + indicator: { + position: "absolute", + right: size / 20, + bottom: size / 20, + height: size / 5, + width: size / 5, + backgroundColor: backgroundColor(colorScheme), + alignItems: "center", + justifyContent: "center", + borderRadius: size / 5, + // borderWidth: 2, + // borderColor: backgroundColor(colorScheme), + }, + indicatorInner: { + height: size / 5 - 4, + width: size / 5 - 4, + backgroundColor: badgeColor(colorScheme), + borderRadius: size / 5, + }, + }); diff --git a/components/PinnedConversations/PinnedConversation.tsx b/components/PinnedConversations/PinnedConversation.tsx index 98508c782..c76aa82a2 100644 --- a/components/PinnedConversations/PinnedConversation.tsx +++ b/components/PinnedConversations/PinnedConversation.tsx @@ -1,8 +1,11 @@ +import { useSelect } from "@data/store/storeHelpers"; import { useGroupNameQuery } from "@queries/useGroupNameQuery"; import { useGroupPhotoQuery } from "@queries/useGroupPhotoQuery"; import { backgroundColor, textSecondaryColor } from "@styles/colors"; import { AvatarSizes } from "@styles/sizes"; -import { FC, useCallback } from "react"; +import { ConversationWithLastMessagePreview } from "@utils/conversation"; +import { showUnreadOnConversation } from "@utils/conversation/showUnreadOnConversation"; +import { FC, useCallback, useMemo } from "react"; import { StyleSheet, Text, @@ -16,13 +19,12 @@ import { useCurrentAccount, useProfilesStore, } from "../../data/store/accountsStore"; -import { XmtpConversation } from "../../data/store/chatStore"; import { navigate } from "../../utils/navigation"; import { getPreferredAvatar, getPreferredName } from "../../utils/profile"; import GroupAvatar from "../GroupAvatar"; interface Props { - conversation: XmtpConversation; + conversation: ConversationWithLastMessagePreview; } export const PinnedConversation: FC = ({ conversation }) => { @@ -50,6 +52,21 @@ export const PinnedConversation: FC = ({ conversation }) => { const onLongPress = useCallback(() => { setPinnedConversations([conversation]); }, [conversation, setPinnedConversations]); + const { initialLoadDoneOnce, topicsData } = useChatStore( + useSelect(["initialLoadDoneOnce", "topicsData"]) + ); + + const showUnread = useMemo( + () => + showUnreadOnConversation( + initialLoadDoneOnce, + conversation.lastMessagePreview, + topicsData, + conversation, + account + ), + [account, conversation, initialLoadDoneOnce, topicsData] + ); const avatarComponent = isGroup ? ( = ({ conversation }) => { size={AvatarSizes.pinnedConversation} style={styles.avatar} topic={conversation.topic} + showIndicator={showUnread} /> ) : ( = ({ conversation }) => { size={AvatarSizes.pinnedConversation} style={styles.avatar} name={getPreferredName(socials, conversation.peerAddress || "")} + showIndicator={showUnread} /> ); diff --git a/utils/conversation/showUnreadOnConversation.ts b/utils/conversation/showUnreadOnConversation.ts new file mode 100644 index 000000000..f5b3bcd16 --- /dev/null +++ b/utils/conversation/showUnreadOnConversation.ts @@ -0,0 +1,23 @@ +import { TopicsData } from "@data/store/chatStore"; +import { + ConversationWithLastMessagePreview, + LastMessagePreview, +} from "@utils/conversation"; + +export const showUnreadOnConversation = ( + initialLoadDoneOnce: boolean, + lastMessagePreview: LastMessagePreview | undefined, + topicsData: TopicsData, + conversation: ConversationWithLastMessagePreview, + userAddress: string +) => { + if (!initialLoadDoneOnce) return false; + if (!lastMessagePreview) return false; + // Manually marked as unread + if (topicsData[conversation.topic]?.status === "unread") return true; + // If not manually markes as unread, we only show badge if last message + // not from me + if (lastMessagePreview.message.senderAddress === userAddress) return false; + const readUntil = topicsData[conversation.topic]?.readUntil || 0; + return readUntil < lastMessagePreview.message.sent; +};