diff --git a/src/i18n/en.json b/src/i18n/en.json index 53b1c687a..5260b89a3 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -238,8 +238,13 @@ "LONG_PRESS_ACTIONS": { "COPY": "Copy", "REPLY": "Reply", - "DELETE_MESSAGE": "Delete message" + "DELETE_MESSAGE": "Delete message", + "TRANSLATE": "Translate" }, + "TRANSLATE_MESSAGE_SUCCESS": "Message translated", + "TRANSLATE_MESSAGE_ERROR": "Failed to translate message", + "VIEW_ORIGINAL": "View original", + "VIEW_TRANSLATED": "View translated", "EMAIL_HEADER": { "FROM": "From", "TO": "To", diff --git a/src/screens/chat-screen/components/message-components/TextBubble.tsx b/src/screens/chat-screen/components/message-components/TextBubble.tsx index 41028866d..921fc4bf0 100644 --- a/src/screens/chat-screen/components/message-components/TextBubble.tsx +++ b/src/screens/chat-screen/components/message-components/TextBubble.tsx @@ -1,9 +1,11 @@ -import React from 'react'; +import React, { useState } from 'react'; +import { Pressable } from 'react-native'; import Animated from 'react-native-reanimated'; import { tailwind } from '@/theme'; import { Message } from '@/types'; import { MarkdownBubble } from './MarkdownBubble'; import { EmailMeta } from './EmailMeta'; +import i18n from '@/i18n'; export type TextBubbleProps = { item: Message; @@ -16,6 +18,36 @@ export const TextBubble = (props: TextBubbleProps) => { const { private: isPrivate, content, contentAttributes, sender } = messageItem; + const [showOriginal, setShowOriginal] = useState(false); + + const translations = contentAttributes?.translations; + const hasTranslations = translations && Object.keys(translations).length > 0; + const translationContent = hasTranslations + ? Object.values(translations)[0] + : null; + + const displayContent = + hasTranslations && !showOriginal && translationContent + ? translationContent + : content; + + const renderTranslationToggle = () => { + if (!hasTranslations) return null; + + return ( + setShowOriginal(!showOriginal)}> + + {showOriginal + ? i18n.t('CONVERSATION.VIEW_TRANSLATED') + : i18n.t('CONVERSATION.VIEW_ORIGINAL')} + + + ); + }; + return ( {contentAttributes && } @@ -23,11 +55,15 @@ export const TextBubble = (props: TextBubbleProps) => { - + + {renderTranslationToggle()} ) : ( - + <> + + {renderTranslationToggle()} + )} ); diff --git a/src/screens/chat-screen/components/message-item/MessageItemContainer.tsx b/src/screens/chat-screen/components/message-item/MessageItemContainer.tsx index 6e0ac1c2f..cc12cefb6 100644 --- a/src/screens/chat-screen/components/message-item/MessageItemContainer.tsx +++ b/src/screens/chat-screen/components/message-item/MessageItemContainer.tsx @@ -1,18 +1,16 @@ -import React from 'react'; +import React, { useState } from 'react'; import { Message } from '@/types'; import { useAppDispatch, useAppSelector } from '@/hooks'; import { selectConversationById } from '@/store/conversation/conversationSelectors'; +import { selectLocale } from '@/store/settings/settingsSelectors'; import { useChatWindowContext } from '@/context'; -// import { setQuoteMessage } from '@/store/conversation/sendMessageSlice'; import { conversationActions } from '@/store/conversation/conversationActions'; import { useHaptic } from '@/utils'; -// import { inboxHasFeature, is360DialogWhatsAppChannel, useHaptic } from '@/utils'; -// import { INBOX_FEATURES } from '@/constants'; import { showToast } from '@/utils/toastUtils'; import i18n from '@/i18n'; import Clipboard from '@react-native-clipboard/clipboard'; import { MESSAGE_TYPES } from '@/constants'; -import { CopyIcon, Trash } from '@/svg-icons'; +import { CopyIcon, Trash, TranslateIcon } from '@/svg-icons'; import { MenuOption } from '../message-menu'; import { MessageItem } from './MessageItem'; @@ -27,10 +25,8 @@ export const MessageItemContainer = (props: MessageItemContainerProps) => { const hapticSelection = useHaptic(); const conversation = useAppSelector(state => selectConversationById(state, conversationId)); - - // const handleQuoteReplyAttachment = () => { - // dispatch(setQuoteMessage(props.item as Message)); - // }; + const locale = useAppSelector(selectLocale); + const [translatingMessageId, setTranslatingMessageId] = useState(null); const handleCopyMessage = (content: string) => { hapticSelection?.(); @@ -45,22 +41,29 @@ export const MessageItemContainer = (props: MessageItemContainerProps) => { showToast({ message: i18n.t('CONVERSATION.DELETE_MESSAGE_SUCCESS') }); }; - // const inboxSupportsReplyTo = (channel: string) => { - // const incoming = inboxHasFeature(INBOX_FEATURES.REPLY_TO, channel); - // const outgoing = - // inboxHasFeature(INBOX_FEATURES.REPLY_TO_OUTGOING, channel) && - // !is360DialogWhatsAppChannel(channel); - - // return { incoming, outgoing }; - // }; + const handleTranslateMessage = async (messageId: number) => { + hapticSelection?.(); + setTranslatingMessageId(messageId); + try { + await dispatch( + conversationActions.translateMessage({ + conversationId, + messageId, + targetLanguage: locale || 'en', + }), + ).unwrap(); + showToast({ message: i18n.t('CONVERSATION.TRANSLATE_MESSAGE_SUCCESS') }); + } catch { + showToast({ message: i18n.t('CONVERSATION.TRANSLATE_MESSAGE_ERROR') }); + } finally { + setTranslatingMessageId(null); + } + }; const getMenuOptions = (message: Message): MenuOption[] => { const { messageType, content, attachments } = message; - // const { private: isPrivate } = message; const hasText = !!content; const hasAttachments = !!(attachments && attachments.length > 0); - // const channel = conversation?.meta?.channel; - const isDeleted = message.contentAttributes?.deleted; const menuOptions: MenuOption[] = []; @@ -68,6 +71,11 @@ export const MessageItemContainer = (props: MessageItemContainerProps) => { return []; } + const hasTranslations = + message.contentAttributes?.translations && + Object.keys(message.contentAttributes.translations).length > 0; + const isTranslating = translatingMessageId === message.id; + if (hasText) { menuOptions.push({ title: i18n.t('CONVERSATION.LONG_PRESS_ACTIONS.COPY'), @@ -75,18 +83,16 @@ export const MessageItemContainer = (props: MessageItemContainerProps) => { handleOnPressMenuOption: () => handleCopyMessage(content), destructive: false, }); + if (!hasTranslations && !isTranslating) { + menuOptions.push({ + title: i18n.t('CONVERSATION.LONG_PRESS_ACTIONS.TRANSLATE'), + icon: , + handleOnPressMenuOption: () => handleTranslateMessage(message.id), + destructive: false, + }); + } } - // TODO: Add reply to message when we have the feature - // if (!isPrivate && channel && inboxSupportsReplyTo(channel).outgoing) { - // menuOptions.push({ - // title: i18n.t('CONVERSATION.LONG_PRESS_ACTIONS.REPLY'), - // icon: null, - // handleOnPressMenuOption: handleQuoteReplyAttachment, - // destructive: false, - // }); - // } - if (hasAttachments || hasText) { menuOptions.push({ title: i18n.t('CONVERSATION.LONG_PRESS_ACTIONS.DELETE_MESSAGE'), diff --git a/src/store/conversation/conversationActions.ts b/src/store/conversation/conversationActions.ts index ad56f5fc5..a41c7cb35 100644 --- a/src/store/conversation/conversationActions.ts +++ b/src/store/conversation/conversationActions.ts @@ -25,6 +25,7 @@ import type { SendMessageAPIResponse, SendMessagePayload, TogglePriorityPayload, + TranslateMessagePayload, } from './conversationTypes'; import { AxiosError } from 'axios'; import { MESSAGE_STATUS } from '@/constants'; @@ -254,4 +255,18 @@ export const conversationActions = { return await ConversationService.togglePriority(payload); }, ), + translateMessage: createAsyncThunk( + 'conversations/translateMessage', + async (payload, { rejectWithValue }) => { + try { + await ConversationService.translateMessage(payload); + } catch (error) { + const { response } = error as AxiosError; + if (!response) { + throw error; + } + return rejectWithValue(response.data); + } + }, + ), }; diff --git a/src/store/conversation/conversationService.ts b/src/store/conversation/conversationService.ts index 0012de0c4..c474f9b39 100644 --- a/src/store/conversation/conversationService.ts +++ b/src/store/conversation/conversationService.ts @@ -29,6 +29,7 @@ import type { MarkMessageReadOrUnreadResponse, ToggleConversationStatusAPIResponse, TogglePriorityPayload, + TranslateMessagePayload, } from './conversationTypes'; import { @@ -212,4 +213,11 @@ export class ConversationService { const { conversationId, priority } = payload; await apiService.post(`conversations/${conversationId}/toggle_priority`, { priority }); } + + static async translateMessage(payload: TranslateMessagePayload): Promise { + const { conversationId, messageId, targetLanguage } = payload; + await apiService.post(`conversations/${conversationId}/messages/${messageId}/translate`, { + target_language: targetLanguage, + }); + } } diff --git a/src/store/conversation/conversationTypes.ts b/src/store/conversation/conversationTypes.ts index 4650f320e..b88ba8613 100644 --- a/src/store/conversation/conversationTypes.ts +++ b/src/store/conversation/conversationTypes.ts @@ -238,3 +238,9 @@ export interface TogglePriorityPayload { conversationId: number; priority: ConversationPriority; } + +export interface TranslateMessagePayload { + conversationId: number; + messageId: number; + targetLanguage: string; +} diff --git a/src/types/Message.ts b/src/types/Message.ts index 71e3bb876..e7f28fd2b 100644 --- a/src/types/Message.ts +++ b/src/types/Message.ts @@ -63,6 +63,7 @@ export type MessageContentAttributes = { imageType: string; contentType: ContentType; isUnsupported: boolean; + translations?: Record; }; export interface Message {