From bc3351e9e08662fae28aebecdefc3f6c64484be2 Mon Sep 17 00:00:00 2001 From: Frank Filius Date: Fri, 1 Nov 2024 11:42:26 +0100 Subject: [PATCH 1/6] Create new message indicator --- .../ios/SalesforceMessagingInApp.mm | 1 + .../src/types.ts | 12 ++++ src/components/ui/feedback/Badge.tsx | 60 +++++++------------ src/modules/chat/components/ChatHeader.tsx | 8 ++- .../chat/components/NewMessageIndicator.tsx | 14 +++++ src/modules/chat/providers/chat.provider.tsx | 24 ++++++++ src/modules/chat/slice.ts | 2 + 7 files changed, 83 insertions(+), 38 deletions(-) create mode 100644 src/modules/chat/components/NewMessageIndicator.tsx diff --git a/react-native-salesforce-messaging-in-app/ios/SalesforceMessagingInApp.mm b/react-native-salesforce-messaging-in-app/ios/SalesforceMessagingInApp.mm index fb8055ece..a090244a4 100644 --- a/react-native-salesforce-messaging-in-app/ios/SalesforceMessagingInApp.mm +++ b/react-native-salesforce-messaging-in-app/ios/SalesforceMessagingInApp.mm @@ -704,6 +704,7 @@ - (NSDictionary *)parseEntryToDictionary:(id)entry // Convert the extracted properties into a dictionary NSMutableDictionary *messageDict = [NSMutableDictionary dictionary]; messageDict[@"entryId"] = entry.identifier; + messageDict[@"entryType"] = entry.type; messageDict[@"payloadDescription"] = entry.payload ? [entry.payload description] : @""; // messageDict[@"senderDisplayName"] = senderDisplayName ?: [NSNull null]; messageDict[@"sender"] = [self parseParticipantToDictionary:entry.sender]; diff --git a/react-native-salesforce-messaging-in-app/src/types.ts b/react-native-salesforce-messaging-in-app/src/types.ts index 81ca3eaa3..703bf3eff 100644 --- a/react-native-salesforce-messaging-in-app/src/types.ts +++ b/react-native-salesforce-messaging-in-app/src/types.ts @@ -128,6 +128,17 @@ export enum ConversationEntryFormat { webview = 'WebView', } +export enum ConversationEntryType { + deliveryAcknowledgement = 'DeliveryAcknowledgement', + message = 'Message', + participantChanged = 'ParticipantChanged', + readAcknowledgement = 'ReadAcknowledgement', + routingResult = 'RoutingResult', + routingWorkResult = 'RoutingWorkResult', + typingIndicator = 'TypingIndicator', + unknownEntry = 'UnknownEntry', +} + export enum ConversationEntryStatus { sending = 'Sending', sent = 'Sent', @@ -143,6 +154,7 @@ export enum ConversationEntrySenderRole { export type ConversationEntryBase = { conversationId: string entryId: string + entryType: ConversationEntryType // format: ConversationEntryFormat /** * the id of the ConversationEntry this is a reply to diff --git a/src/components/ui/feedback/Badge.tsx b/src/components/ui/feedback/Badge.tsx index 8e1b7a42e..89eab25c0 100644 --- a/src/components/ui/feedback/Badge.tsx +++ b/src/components/ui/feedback/Badge.tsx @@ -1,4 +1,10 @@ -import {AccessibilityProps, StyleSheet, Text, View} from 'react-native' +import { + AccessibilityProps, + Platform, + StyleSheet, + Text, + View, +} from 'react-native' import {Row} from '@/components/ui/layout/Row' import {type TestProps} from '@/components/ui/types' import {useDeviceContext} from '@/hooks/useDeviceContext' @@ -28,7 +34,7 @@ export const Badge = ({ variant = 'default', }: BadgeProps) => { const {fontScale} = useDeviceContext() - const styles = useThemable(createStyles(fontScale, variant)) + const styles = useThemable(createStyles(fontScale, variant, value)) return ( @@ -36,7 +42,7 @@ export const Badge = ({ @@ -47,58 +53,38 @@ export const Badge = ({ ) } -type VariantConfig = { - [v in OmitUndefined]: { - accessible?: boolean - diameter: number - text: number - } -} - -const variantConfig: VariantConfig = { - default: { - diameter: 22, - text: 14, - }, - 'on-icon': { - accessible: false, - diameter: 16, - text: 12, - }, - small: { - diameter: 16, - text: 12, - }, -} +const MARGIN_SINGLE_DIGIT = 1.2 +const MARGIN_DOUBLE_DIGIT = 1.4 const createStyles = ( fontScale: Device['fontScale'], variant: OmitUndefined, + value: number, ) => - ({color, size, text}: Theme) => { - const {diameter, text: textSize} = variantConfig[variant] + ({color, text}: Theme) => { + const fontSize = text.fontSize[variant === 'small' ? 'small' : 'body'] const scalesWithFont = variant !== 'on-icon' const scaleFactor = scalesWithFont ? fontScale : 1 + const marginFactor = value > 9 ? MARGIN_DOUBLE_DIGIT : MARGIN_SINGLE_DIGIT - const scaledDiameter = diameter * scaleFactor - const scaledTextSize = textSize * scaleFactor + const scaledDiameter = marginFactor * scaleFactor * fontSize return StyleSheet.create({ circle: { - flexDirection: 'row', justifyContent: 'center', - minWidth: scaledDiameter, // Prevent the circle becoming a vertical oval - paddingStart: size.spacing.xs + 0.5, // Nudge center-alignment because of even width - paddingEnd: size.spacing.xs, + alignItems: 'center', + height: scaledDiameter, + width: scaledDiameter, borderRadius: scaledDiameter / 2, backgroundColor: color.badge.background, }, text: { - fontFamily: text.fontFamily.bold, - fontSize: scaledTextSize, - lineHeight: scaledDiameter, + fontFamily: + variant === 'small' ? text.fontFamily.bold : text.fontFamily.regular, + fontSize, color: color.text.inverse, + bottom: Platform.OS === 'android' ? 2 : 1 * fontScale, }, }) } diff --git a/src/modules/chat/components/ChatHeader.tsx b/src/modules/chat/components/ChatHeader.tsx index d42ccf4ef..90598a94b 100644 --- a/src/modules/chat/components/ChatHeader.tsx +++ b/src/modules/chat/components/ChatHeader.tsx @@ -11,6 +11,7 @@ import {ScreenTitle} from '@/components/ui/text/ScreenTitle' import {useToggle} from '@/hooks/useToggle' import {MeatballsMenu} from '@/modules/chat/assets/MeatballsMenu' import {ChatMenu} from '@/modules/chat/components/ChatMenu' +import {NewMessageIndicator} from '@/modules/chat/components/NewMessageIndicator' import {useChat} from '@/modules/chat/slice' import {useTheme} from '@/themes/useTheme' @@ -89,7 +90,12 @@ export const ChatHeader = () => { testID="ChatHeaderMeatballsMenuButton" /> - + + + + { + const {newMessagesCount} = useContext(ChatContext) + + return newMessagesCount ? ( + + ) : null +} diff --git a/src/modules/chat/providers/chat.provider.tsx b/src/modules/chat/providers/chat.provider.tsx index 6f35768be..2346ea6cb 100644 --- a/src/modules/chat/providers/chat.provider.tsx +++ b/src/modules/chat/providers/chat.provider.tsx @@ -5,21 +5,25 @@ import { } from 'react-native-salesforce-messaging-in-app/src' import { ConversationEntry, + ConversationEntryType, RemoteConfiguration, } from 'react-native-salesforce-messaging-in-app/src/types' import {useCoreConfig} from '@/modules/chat/hooks/useCoreConfig' +import {useChat} from '@/modules/chat/slice' import {filterOutDeliveryAcknowledgements} from '@/modules/chat/utils/filterOutDeliveryAcknowledgements' type ChatContextType = { employeeInChat: boolean isWaitingForAgent: boolean messages: ConversationEntry[] + newMessagesCount: number ready: boolean remoteConfiguration: RemoteConfiguration | undefined } const initialValue: ChatContextType = { messages: [], + newMessagesCount: 0, ready: false, employeeInChat: false, remoteConfiguration: undefined, @@ -33,6 +37,8 @@ type Props = { } export const ChatProvider = ({children}: Props) => { + const {isMaximized, isMinimized} = useChat() + const [newMessagesCount, setNewMessagesCount] = useState(0) const coreConfig = useCoreConfig() const [conversationId, setConversationId] = useState() const { @@ -48,9 +54,25 @@ export const ChatProvider = ({children}: Props) => { conversationId, }) + useEffect(() => { + if ( + isMinimized && + messages[messages.length - 1]?.entryType === ConversationEntryType.message + ) { + setNewMessagesCount(count => count + 1) + } + }, [isMinimized, messages]) + + useEffect(() => { + if (isMaximized) { + setNewMessagesCount(0) + } + }, [isMaximized]) + useEffect(() => { setConversationId(newConversationId ?? conversationId) }, [conversationId, newConversationId]) + useEffect(() => { if (remoteConfiguration) { const remoteConfig = JSON.parse( @@ -75,6 +97,7 @@ export const ChatProvider = ({children}: Props) => { messages: isTyping ? [...filterOutDeliveryAcknowledgements(messages), isTyping] : filterOutDeliveryAcknowledgements(messages), + newMessagesCount, ready, employeeInChat, remoteConfiguration, @@ -85,6 +108,7 @@ export const ChatProvider = ({children}: Props) => { isTyping, isWaitingForAgent, messages, + newMessagesCount, ready, remoteConfiguration, ], diff --git a/src/modules/chat/slice.ts b/src/modules/chat/slice.ts index 4ccf1621f..9c3208508 100644 --- a/src/modules/chat/slice.ts +++ b/src/modules/chat/slice.ts @@ -82,6 +82,7 @@ export const useChat = () => { const isOpen = useSelector(selectChatIsOpen) const visibility = useSelector(selectChatVisibility) const isMaximized = visibility === ChatVisibility.maximized + const isMinimized = visibility === ChatVisibility.minimized const minimizedHeight = useSelector(selectChatMinimizedHeight) const dispatch = useDispatch() @@ -105,6 +106,7 @@ export const useChat = () => { return { close, isMaximized, + isMinimized, isOpen, open, maximize, From 37a5c7b0cc34340fc25346c4963836a99862d317 Mon Sep 17 00:00:00 2001 From: Rik Scheffer Date: Mon, 4 Nov 2024 14:09:38 +0100 Subject: [PATCH 2/6] Improve entry gutters and isWaitingForAgent --- src/modules/chat/providers/chat.provider.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/modules/chat/providers/chat.provider.tsx b/src/modules/chat/providers/chat.provider.tsx index 2346ea6cb..02bac438b 100644 --- a/src/modules/chat/providers/chat.provider.tsx +++ b/src/modules/chat/providers/chat.provider.tsx @@ -109,6 +109,7 @@ export const ChatProvider = ({children}: Props) => { isWaitingForAgent, messages, newMessagesCount, + isWaitingForAgent, ready, remoteConfiguration, ], From a7d81f9e5e04ed2ec672f11409b4f7b2a73a5795 Mon Sep 17 00:00:00 2001 From: Frank Filius Date: Mon, 4 Nov 2024 16:26:27 +0100 Subject: [PATCH 3/6] Use more stable entry format to determine new message --- src/modules/chat/providers/chat.provider.tsx | 7 +- src/modules/chat/utils/isNewMessage.test.ts | 68 ++++++++++++++++++++ src/modules/chat/utils/isNewMessage.ts | 14 ++++ 3 files changed, 84 insertions(+), 5 deletions(-) create mode 100644 src/modules/chat/utils/isNewMessage.test.ts create mode 100644 src/modules/chat/utils/isNewMessage.ts diff --git a/src/modules/chat/providers/chat.provider.tsx b/src/modules/chat/providers/chat.provider.tsx index 02bac438b..619c640fc 100644 --- a/src/modules/chat/providers/chat.provider.tsx +++ b/src/modules/chat/providers/chat.provider.tsx @@ -5,12 +5,12 @@ import { } from 'react-native-salesforce-messaging-in-app/src' import { ConversationEntry, - ConversationEntryType, RemoteConfiguration, } from 'react-native-salesforce-messaging-in-app/src/types' import {useCoreConfig} from '@/modules/chat/hooks/useCoreConfig' import {useChat} from '@/modules/chat/slice' import {filterOutDeliveryAcknowledgements} from '@/modules/chat/utils/filterOutDeliveryAcknowledgements' +import {isNewMessage} from '@/modules/chat/utils/isNewMessage' type ChatContextType = { employeeInChat: boolean @@ -55,10 +55,7 @@ export const ChatProvider = ({children}: Props) => { }) useEffect(() => { - if ( - isMinimized && - messages[messages.length - 1]?.entryType === ConversationEntryType.message - ) { + if (isMinimized && isNewMessage(messages[messages.length - 1]?.format)) { setNewMessagesCount(count => count + 1) } }, [isMinimized, messages]) diff --git a/src/modules/chat/utils/isNewMessage.test.ts b/src/modules/chat/utils/isNewMessage.test.ts new file mode 100644 index 000000000..a61d2e8c2 --- /dev/null +++ b/src/modules/chat/utils/isNewMessage.test.ts @@ -0,0 +1,68 @@ +import {ConversationEntryFormat} from 'react-native-salesforce-messaging-in-app/src/types' +import {isNewMessage} from '@/modules/chat/utils/isNewMessage' + +describe('isNewMessage', () => { + it('should return true for attachments format', () => { + expect(isNewMessage(ConversationEntryFormat.attachments)).toBe(true) + }) + + it('should return true for carousel format', () => { + expect(isNewMessage(ConversationEntryFormat.carousel)).toBe(true) + }) + + it('should return true for imageMessage format', () => { + expect(isNewMessage(ConversationEntryFormat.imageMessage)).toBe(true) + }) + + it('should return true for inputs format', () => { + expect(isNewMessage(ConversationEntryFormat.inputs)).toBe(true) + }) + + it('should return true for listPicker format', () => { + expect(isNewMessage(ConversationEntryFormat.listPicker)).toBe(true) + }) + + it('should return true for richLink format', () => { + expect(isNewMessage(ConversationEntryFormat.richLink)).toBe(true) + }) + + it('should return true for quickReplies format', () => { + expect(isNewMessage(ConversationEntryFormat.quickReplies)).toBe(true) + }) + + it('should return true for routingResult format', () => { + expect(isNewMessage(ConversationEntryFormat.routingResult)).toBe(true) + }) + + it('should return true for routingWorkResult format', () => { + expect(isNewMessage(ConversationEntryFormat.routingWorkResult)).toBe(true) + }) + + it('should return true for selections format', () => { + expect(isNewMessage(ConversationEntryFormat.selections)).toBe(true) + }) + + it('should return true for text format', () => { + expect(isNewMessage(ConversationEntryFormat.text)).toBe(true) + }) + + it('should return false for unspecified format', () => { + expect(isNewMessage(ConversationEntryFormat.unspecified)).toBe(false) + }) + + it('should return false for webview format', () => { + expect(isNewMessage(ConversationEntryFormat.webview)).toBe(false) + }) + + it('should return false for typingStartedIndicator format', () => { + expect(isNewMessage(ConversationEntryFormat.typingStartedIndicator)).toBe( + false, + ) + }) + + it('should return false for typingStoppedIndicator format', () => { + expect(isNewMessage(ConversationEntryFormat.typingStoppedIndicator)).toBe( + false, + ) + }) +}) diff --git a/src/modules/chat/utils/isNewMessage.ts b/src/modules/chat/utils/isNewMessage.ts new file mode 100644 index 000000000..1e6666bb8 --- /dev/null +++ b/src/modules/chat/utils/isNewMessage.ts @@ -0,0 +1,14 @@ +import {ConversationEntryFormat} from 'react-native-salesforce-messaging-in-app/src/types' + +export const isNewMessage = (format: ConversationEntryFormat) => + format === ConversationEntryFormat.attachments || + format === ConversationEntryFormat.carousel || + format === ConversationEntryFormat.imageMessage || + format === ConversationEntryFormat.inputs || + format === ConversationEntryFormat.listPicker || + format === ConversationEntryFormat.richLink || + format === ConversationEntryFormat.quickReplies || + format === ConversationEntryFormat.routingResult || + format === ConversationEntryFormat.routingWorkResult || + format === ConversationEntryFormat.selections || + format === ConversationEntryFormat.text From 5416a1357f59203dbbb96ba8d2cfb877ec47aef2 Mon Sep 17 00:00:00 2001 From: Frank Filius Date: Mon, 4 Nov 2024 16:37:00 +0100 Subject: [PATCH 4/6] Deprecate type to disencourage usage --- .../ios/SalesforceMessagingInApp.mm | 3 +-- react-native-salesforce-messaging-in-app/src/types.ts | 3 +++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/react-native-salesforce-messaging-in-app/ios/SalesforceMessagingInApp.mm b/react-native-salesforce-messaging-in-app/ios/SalesforceMessagingInApp.mm index a090244a4..b0263d26d 100644 --- a/react-native-salesforce-messaging-in-app/ios/SalesforceMessagingInApp.mm +++ b/react-native-salesforce-messaging-in-app/ios/SalesforceMessagingInApp.mm @@ -704,7 +704,6 @@ - (NSDictionary *)parseEntryToDictionary:(id)entry // Convert the extracted properties into a dictionary NSMutableDictionary *messageDict = [NSMutableDictionary dictionary]; messageDict[@"entryId"] = entry.identifier; - messageDict[@"entryType"] = entry.type; messageDict[@"payloadDescription"] = entry.payload ? [entry.payload description] : @""; // messageDict[@"senderDisplayName"] = senderDisplayName ?: [NSNull null]; messageDict[@"sender"] = [self parseParticipantToDictionary:entry.sender]; @@ -717,7 +716,7 @@ - (NSDictionary *)parseEntryToDictionary:(id)entry messageDict[@"payloadId"] = payload.identifier ?: @""; messageDict[@"inReplyToEntryId"] = payload.inReplyToEntryId ?: @""; NSString * type = entry.type; - messageDict[@"type"] = type; + messageDict[@"entryType"] = type; if (format == SMIConversationFormatTypesAttachments) { id attachmentsPayload = (id)payload; //https://salesforce-async-messaging.github.io/messaging-in-app-ios/Protocols/SMIAttachments.html#/c:objc(pl)SMIAttachments(py)attachments diff --git a/react-native-salesforce-messaging-in-app/src/types.ts b/react-native-salesforce-messaging-in-app/src/types.ts index 703bf3eff..8afa5ff09 100644 --- a/react-native-salesforce-messaging-in-app/src/types.ts +++ b/react-native-salesforce-messaging-in-app/src/types.ts @@ -128,6 +128,9 @@ export enum ConversationEntryFormat { webview = 'WebView', } +/** + * @deprecated unverified values, use ConversationEntryFormat + */ export enum ConversationEntryType { deliveryAcknowledgement = 'DeliveryAcknowledgement', message = 'Message', From 1eb123c62021b9b8ad4f1d42db8350e79a78a85b Mon Sep 17 00:00:00 2001 From: Frank Filius Date: Mon, 4 Nov 2024 16:49:43 +0100 Subject: [PATCH 5/6] Fix dependency array --- src/modules/chat/providers/chat.provider.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/modules/chat/providers/chat.provider.tsx b/src/modules/chat/providers/chat.provider.tsx index 619c640fc..5bd0e7a78 100644 --- a/src/modules/chat/providers/chat.provider.tsx +++ b/src/modules/chat/providers/chat.provider.tsx @@ -106,7 +106,6 @@ export const ChatProvider = ({children}: Props) => { isWaitingForAgent, messages, newMessagesCount, - isWaitingForAgent, ready, remoteConfiguration, ], From d672809fff1c2b4bf50269b45661183ba2b3b2e9 Mon Sep 17 00:00:00 2001 From: Frank Filius Date: Tue, 5 Nov 2024 10:05:53 +0100 Subject: [PATCH 6/6] PR feedback --- src/components/ui/feedback/Badge.tsx | 4 +++- src/modules/chat/providers/chat.provider.tsx | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/ui/feedback/Badge.tsx b/src/components/ui/feedback/Badge.tsx index 89eab25c0..4ceb61b15 100644 --- a/src/components/ui/feedback/Badge.tsx +++ b/src/components/ui/feedback/Badge.tsx @@ -81,7 +81,9 @@ const createStyles = }, text: { fontFamily: - variant === 'small' ? text.fontFamily.bold : text.fontFamily.regular, + variant === 'default' + ? text.fontFamily.regular + : text.fontFamily.bold, fontSize, color: color.text.inverse, bottom: Platform.OS === 'android' ? 2 : 1 * fontScale, diff --git a/src/modules/chat/providers/chat.provider.tsx b/src/modules/chat/providers/chat.provider.tsx index 5bd0e7a78..bc624d390 100644 --- a/src/modules/chat/providers/chat.provider.tsx +++ b/src/modules/chat/providers/chat.provider.tsx @@ -58,7 +58,8 @@ export const ChatProvider = ({children}: Props) => { if (isMinimized && isNewMessage(messages[messages.length - 1]?.format)) { setNewMessagesCount(count => count + 1) } - }, [isMinimized, messages]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [messages.length]) useEffect(() => { if (isMaximized) {