From 975a1fecca1f884e4c0e2296f06369273748ec57 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Thu, 18 Dec 2025 16:26:06 -0800 Subject: [PATCH 1/2] feat(messages): add translate message functionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add long-press translate option for messages on iOS/Android: - Add translateMessage API call to ConversationService - Add translateMessage async action to conversationActions - Add Translate menu option in message long-press context menu - Update TextBubble to display translations with toggle - Add translations field to MessageContentAttributes type - Add i18n strings for translate feature 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/i18n/en.json | 6 ++- .../message-components/TextBubble.tsx | 37 +++++++++++++++++-- .../message-item/MessageItemContainer.tsx | 22 ++++++++++- src/store/conversation/conversationActions.ts | 15 ++++++++ src/store/conversation/conversationService.ts | 8 ++++ src/store/conversation/conversationTypes.ts | 6 +++ src/types/Message.ts | 1 + 7 files changed, 90 insertions(+), 5 deletions(-) diff --git a/src/i18n/en.json b/src/i18n/en.json index 53b1c687a..2e9ce1a72 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -238,8 +238,12 @@ "LONG_PRESS_ACTIONS": { "COPY": "Copy", "REPLY": "Reply", - "DELETE_MESSAGE": "Delete message" + "DELETE_MESSAGE": "Delete message", + "TRANSLATE": "Translate" }, + "TRANSLATE_MESSAGE_SUCCESS": "Message translated", + "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..4d41f1ee2 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,31 @@ 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 ? translations[Object.keys(translations)[0]] : null; + + const displayContent = hasTranslations && !showOriginal ? 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 +50,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..e08d20e5b 100644 --- a/src/screens/chat-screen/components/message-item/MessageItemContainer.tsx +++ b/src/screens/chat-screen/components/message-item/MessageItemContainer.tsx @@ -2,6 +2,7 @@ import React 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'; @@ -12,7 +13,7 @@ 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,6 +28,7 @@ export const MessageItemContainer = (props: MessageItemContainerProps) => { const hapticSelection = useHaptic(); const conversation = useAppSelector(state => selectConversationById(state, conversationId)); + const locale = useAppSelector(selectLocale); // const handleQuoteReplyAttachment = () => { // dispatch(setQuoteMessage(props.item as Message)); @@ -45,6 +47,18 @@ export const MessageItemContainer = (props: MessageItemContainerProps) => { showToast({ message: i18n.t('CONVERSATION.DELETE_MESSAGE_SUCCESS') }); }; + const handleTranslateMessage = async (messageId: number) => { + hapticSelection?.(); + await dispatch( + conversationActions.translateMessage({ + conversationId, + messageId, + targetLanguage: locale || 'en', + }), + ); + showToast({ message: i18n.t('CONVERSATION.TRANSLATE_MESSAGE_SUCCESS') }); + }; + // const inboxSupportsReplyTo = (channel: string) => { // const incoming = inboxHasFeature(INBOX_FEATURES.REPLY_TO, channel); // const outgoing = @@ -75,6 +89,12 @@ export const MessageItemContainer = (props: MessageItemContainerProps) => { handleOnPressMenuOption: () => handleCopyMessage(content), destructive: false, }); + 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 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 { From ad0bb1dacdd16e0a6f5cbb42cf061a224ef4ce00 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Thu, 18 Dec 2025 16:51:59 -0800 Subject: [PATCH 2/2] fix(translate): add error handling and improve translation UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add try-catch with .unwrap() for proper error handling - Show error toast when translation fails - Add loading state to prevent duplicate translation requests - Hide translate option for already-translated messages - Add null safety check for displayContent - Use Object.values() for cleaner translation extraction - Add TRANSLATE_MESSAGE_ERROR i18n string 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/i18n/en.json | 1 + .../message-components/TextBubble.tsx | 9 ++- .../message-item/MessageItemContainer.tsx | 74 ++++++++----------- 3 files changed, 38 insertions(+), 46 deletions(-) diff --git a/src/i18n/en.json b/src/i18n/en.json index 2e9ce1a72..5260b89a3 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -242,6 +242,7 @@ "TRANSLATE": "Translate" }, "TRANSLATE_MESSAGE_SUCCESS": "Message translated", + "TRANSLATE_MESSAGE_ERROR": "Failed to translate message", "VIEW_ORIGINAL": "View original", "VIEW_TRANSLATED": "View translated", "EMAIL_HEADER": { diff --git a/src/screens/chat-screen/components/message-components/TextBubble.tsx b/src/screens/chat-screen/components/message-components/TextBubble.tsx index 4d41f1ee2..921fc4bf0 100644 --- a/src/screens/chat-screen/components/message-components/TextBubble.tsx +++ b/src/screens/chat-screen/components/message-components/TextBubble.tsx @@ -22,9 +22,14 @@ export const TextBubble = (props: TextBubbleProps) => { const translations = contentAttributes?.translations; const hasTranslations = translations && Object.keys(translations).length > 0; - const translationContent = hasTranslations ? translations[Object.keys(translations)[0]] : null; + const translationContent = hasTranslations + ? Object.values(translations)[0] + : null; - const displayContent = hasTranslations && !showOriginal ? translationContent : content; + const displayContent = + hasTranslations && !showOriginal && translationContent + ? translationContent + : content; const renderTranslationToggle = () => { if (!hasTranslations) return null; diff --git a/src/screens/chat-screen/components/message-item/MessageItemContainer.tsx b/src/screens/chat-screen/components/message-item/MessageItemContainer.tsx index e08d20e5b..cc12cefb6 100644 --- a/src/screens/chat-screen/components/message-item/MessageItemContainer.tsx +++ b/src/screens/chat-screen/components/message-item/MessageItemContainer.tsx @@ -1,14 +1,11 @@ -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'; @@ -29,10 +26,7 @@ export const MessageItemContainer = (props: MessageItemContainerProps) => { const hapticSelection = useHaptic(); const conversation = useAppSelector(state => selectConversationById(state, conversationId)); const locale = useAppSelector(selectLocale); - - // const handleQuoteReplyAttachment = () => { - // dispatch(setQuoteMessage(props.item as Message)); - // }; + const [translatingMessageId, setTranslatingMessageId] = useState(null); const handleCopyMessage = (content: string) => { hapticSelection?.(); @@ -49,32 +43,27 @@ export const MessageItemContainer = (props: MessageItemContainerProps) => { const handleTranslateMessage = async (messageId: number) => { hapticSelection?.(); - await dispatch( - conversationActions.translateMessage({ - conversationId, - messageId, - targetLanguage: locale || 'en', - }), - ); - showToast({ message: i18n.t('CONVERSATION.TRANSLATE_MESSAGE_SUCCESS') }); + 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 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 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[] = []; @@ -82,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'), @@ -89,24 +83,16 @@ export const MessageItemContainer = (props: MessageItemContainerProps) => { handleOnPressMenuOption: () => handleCopyMessage(content), destructive: false, }); - menuOptions.push({ - title: i18n.t('CONVERSATION.LONG_PRESS_ACTIONS.TRANSLATE'), - icon: , - handleOnPressMenuOption: () => handleTranslateMessage(message.id), - 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'),