Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -16,18 +18,52 @@ 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 (
<Pressable onPress={() => setShowOriginal(!showOriginal)}>
<Animated.Text
style={tailwind.style(
'text-xs text-gray-500 mt-1 font-inter-420-20 tracking-[0.32px]',
)}>
{showOriginal
? i18n.t('CONVERSATION.VIEW_TRANSLATED')
: i18n.t('CONVERSATION.VIEW_ORIGINAL')}
</Animated.Text>
</Pressable>
);
};

return (
<React.Fragment>
{contentAttributes && <EmailMeta {...{ contentAttributes, sender }} />}
{isPrivate ? (
<Animated.View style={tailwind.style('flex flex-row')}>
<Animated.View style={tailwind.style('w-[3px] bg-amber-700 h-auto rounded-[4px]')} />
<Animated.View style={tailwind.style('pl-2.5')}>
<MarkdownBubble messageContent={content} variant={variant} />
<MarkdownBubble messageContent={displayContent} variant={variant} />
{renderTranslationToggle()}
</Animated.View>
</Animated.View>
) : (
<MarkdownBubble messageContent={content} variant={variant} />
<>
<MarkdownBubble messageContent={displayContent} variant={variant} />
{renderTranslationToggle()}
</>
)}
</React.Fragment>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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<number | null>(null);

const handleCopyMessage = (content: string) => {
hapticSelection?.();
Expand All @@ -45,48 +41,58 @@ 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[] = [];
if (messageType === MESSAGE_TYPES.ACTIVITY || isDeleted) {
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'),
icon: <CopyIcon />,
handleOnPressMenuOption: () => handleCopyMessage(content),
destructive: false,
});
if (!hasTranslations && !isTranslating) {
menuOptions.push({
title: i18n.t('CONVERSATION.LONG_PRESS_ACTIONS.TRANSLATE'),
icon: <TranslateIcon />,
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'),
Expand Down
15 changes: 15 additions & 0 deletions src/store/conversation/conversationActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type {
SendMessageAPIResponse,
SendMessagePayload,
TogglePriorityPayload,
TranslateMessagePayload,
} from './conversationTypes';
import { AxiosError } from 'axios';
import { MESSAGE_STATUS } from '@/constants';
Expand Down Expand Up @@ -254,4 +255,18 @@ export const conversationActions = {
return await ConversationService.togglePriority(payload);
},
),
translateMessage: createAsyncThunk<void, TranslateMessagePayload>(
'conversations/translateMessage',
async (payload, { rejectWithValue }) => {
try {
await ConversationService.translateMessage(payload);
Comment on lines +258 to +262

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Update store with translated message data

The new translateMessage thunk only posts to the API and returns without dispatching any update, so no reducer ever adds the returned translations to the message in the store. TextBubble renders translated text only when contentAttributes.translations is present (lines 21‑33 in TextBubble.tsx), which means users will see a success toast but the message stays untranslated and the Translate menu remains available until the conversation is reloaded or updated externally. Wire the API response into addOrUpdateMessage or trigger a refetch so the translated content appears immediately.

Useful? React with 👍 / 👎.

} catch (error) {
const { response } = error as AxiosError<ApiErrorResponse>;
if (!response) {
throw error;
}
return rejectWithValue(response.data);
}
},
),
};
8 changes: 8 additions & 0 deletions src/store/conversation/conversationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import type {
MarkMessageReadOrUnreadResponse,
ToggleConversationStatusAPIResponse,
TogglePriorityPayload,
TranslateMessagePayload,
} from './conversationTypes';

import {
Expand Down Expand Up @@ -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<void> {
const { conversationId, messageId, targetLanguage } = payload;
await apiService.post(`conversations/${conversationId}/messages/${messageId}/translate`, {
target_language: targetLanguage,
});
}
}
6 changes: 6 additions & 0 deletions src/store/conversation/conversationTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,3 +238,9 @@ export interface TogglePriorityPayload {
conversationId: number;
priority: ConversationPriority;
}

export interface TranslateMessagePayload {
conversationId: number;
messageId: number;
targetLanguage: string;
}
1 change: 1 addition & 0 deletions src/types/Message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export type MessageContentAttributes = {
imageType: string;
contentType: ContentType;
isUnsupported: boolean;
translations?: Record<string, string>;
};

export interface Message {
Expand Down