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
56 changes: 56 additions & 0 deletions CHANNELS_TAB_PLAN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Channels Tab Plan

## Goal
Add a new bottom tab (left of Inbox) with the desktop channel icon (`i-lucide-mailbox`), labeled **Channels**. The new tab shows a list of all inbox channels. Selecting a channel resets conversation filters and navigates to the Conversations list filtered by that inbox.

## Scope
- Mobile app only (`chatwoot-mobile-app`).
- New tab + new screen for channel list.
- Reuse existing conversation filters and inbox data.
- No "Unread vs All" toggle work.

## UX Decisions (confirmed)
- Icon: matches desktop `i-lucide-mailbox` (we will add an equivalent SVG in mobile).
- Title: "Channels".
- List content: all inboxes/channels (no "All inboxes" entry).
- Selection behavior: reset conversation filters (assignee/status/sort/inbox) and then apply the selected inbox filter.

## Implementation Steps
1. **Assets & Icons**
- Create a new SVG icon component for the mailbox icon (outline + filled if needed) under `src/svg-icons/tabs/`.
- Export in `src/svg-icons/tabs/index.ts` and `src/svg-icons/index.ts`.

2. **Navigation & Tabs**
- Add a new tab route (e.g., `Channels`) in `src/navigation/tabs/AppTabs.tsx`.
- Create a new stack for channels (e.g., `ChannelsStack`) in `src/navigation/stack/`.
- Update `BottomTabBar` to include the new icon in `TabBarIcons`.
- Adjust tab bar padding (`pl/pr`) if needed for 4 tabs.

3. **Channels Screen (List of Inboxes)**
- New screen file (e.g., `src/screens/channels/ChannelsScreen.tsx`).
- Use `selectAllInboxes` to show channels with `getChannelIcon`.
- Match visual style used in conversation inbox picker (list row layout).
- Header title "Channels".

4. **Selection Behavior**
- On tap: dispatch `resetFilters()` from `conversationFilterSlice`, then `setFilters({ key: 'inbox_id', value: inbox.id.toString() })`.
- Navigate to Conversations tab (likely `navigation.navigate('Conversations')`).

5. **Testing**
- Run `pnpm test` and report results.
- If tests are too heavy, at least run a targeted lint/test if available.

6. **Review Package (no PR submission)**
- Provide diff summary and the list of changed files.
- Wait for your review before any PR creation or push.

## Risks & Notes
- Verify the correct tab navigation target name (Tabs uses `Conversations`).
- Ensure filter reset does not break persisted filters.
- Ensure list uses available inbox data and renders safely when empty.

## Done When
- New Channels tab appears left of Inbox with mailbox icon.
- Channels list shows all inboxes and navigates to Conversations with filters reset and inbox filter applied.
- Tests run locally and results shared.
- No PR submitted or pushed.
Comment on lines +1 to +56
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

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

The CHANNELS_TAB_PLAN.md file appears to be a planning document and should not be included in the production codebase. Consider removing this file from the PR as it's not meant for production deployment.

Copilot uses AI. Check for mistakes.
11 changes: 10 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 Expand Up @@ -281,6 +286,10 @@
"PENDING": "Pending",
"SNOOZED": "Snoozed"
},
"CHANNELS": {
"TITLE": "Channels",
"EMPTY": "No channels yet."
},
"SETTINGS": {
"HEADER_TITLE": "Settings",
"SET_AVAILABILITY": "Set yourself as",
Expand Down
22 changes: 22 additions & 0 deletions src/navigation/stack/ChannelsStack.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import React from 'react';
import { createNativeStackNavigator } from '@react-navigation/native-stack';

import ChannelsScreen from '@/screens/channels/ChannelsScreen';

export type ChannelsStackParamList = {
ChannelsScreen: undefined;
};

const Stack = createNativeStackNavigator<ChannelsStackParamList>();

export const ChannelsStack = () => {
return (
<Stack.Navigator initialRouteName="ChannelsScreen">
<Stack.Screen
options={{ headerShown: false }}
name="ChannelsScreen"
component={ChannelsScreen}
/>
</Stack.Navigator>
);
};
1 change: 1 addition & 0 deletions src/navigation/stack/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './AuthStack';
export * from './ChannelsStack';
export * from './ConversationStack';
export * from './InboxStack';
export * from './SettingsStack';
6 changes: 5 additions & 1 deletion src/navigation/tabs/AppTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { selectWebSocketUrl } from '@/store/settings/settingsSelectors';
import { getUserPermissions } from '@/utils/permissionUtils';
import { CONVERSATION_PERMISSIONS } from 'constants/permissions';

import { AuthStack, ConversationStack, SettingsStack, InboxStack } from '../stack';
import { AuthStack, ChannelsStack, ConversationStack, SettingsStack, InboxStack } from '../stack';
import ChatScreen from '@/screens/chat-screen/ChatScreen';
import ContactDetailsScreen from '@/screens/contact-details/ContactDetailsScreen';
import DashboardScreen from '@/screens/dashboard/DashboardScreen';
Expand All @@ -42,6 +42,7 @@ import { clearSelection } from '@/store/conversation/conversationSelectedSlice';
const Tab = createBottomTabNavigator();

export type TabParamList = {
Channels: undefined;
Conversations: undefined;
Inbox: undefined;
Settings: undefined;
Expand Down Expand Up @@ -154,6 +155,9 @@ const Tabs = () => {

return (
<Tab.Navigator tabBar={CustomTabBar} initialRouteName="Inbox">
{hasConversationPermission && (
<Tab.Screen name="Channels" component={ChannelsStack} options={{ headerShown: false }} />
)}
{hasConversationPermission && (
<Tab.Screen name="Inbox" component={InboxStack} options={{ headerShown: false }} />
)}
Expand Down
8 changes: 6 additions & 2 deletions src/navigation/tabs/BottomTabBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { RouteProp } from '@react-navigation/native';
import { selectCurrentState } from '@/store/conversation/conversationHeaderSlice';

import {
ChannelsIconFilled,
ChannelsIconOutline,
ConversationIconFilled,
ConversationIconOutline,
InboxIconFilled,
Expand All @@ -37,6 +39,8 @@ type TabBarIconsProps = {

const TabBarIcons = ({ focused, route }: TabBarIconsProps) => {
switch (route.name) {
case 'Channels':
return focused ? <ChannelsIconFilled /> : <ChannelsIconOutline />;
case 'Conversations':
return focused ? <ConversationIconFilled /> : <ConversationIconOutline />;
case 'Inbox':
Expand Down Expand Up @@ -155,13 +159,13 @@ export const BottomTabBar = ({ state, descriptors, navigation }: BottomTabBarPro
style={Platform.select({
ios: [
tailwind.style(
'flex flex-row absolute w-full bottom-0 pl-[72px] pr-[71px] pt-[11px] pb-8 bg-[#00000009]',
'flex flex-row absolute w-full bottom-0 px-6 pt-[11px] pb-8 bg-[#00000009]',
`h-[${tabBarHeight}px]`,
),
],
android: [
tailwind.style(
'flex flex-row absolute w-full bottom-0 pl-[72px] pr-[71px] py-[11px] bg-white',
'flex flex-row absolute w-full bottom-0 px-6 py-[11px] bg-white',
`h-[${tabBarHeight}px]`,
),
],
Expand Down
145 changes: 145 additions & 0 deletions src/screens/channels/ChannelsScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import React, { useCallback, useMemo } from 'react';
import { Pressable, StatusBar } from 'react-native';
import Animated from 'react-native-reanimated';
import { SafeAreaView } from 'react-native-safe-area-context';
import { FlashList } from '@shopify/flash-list';
import { useNavigation } from '@react-navigation/native';

import { TAB_BAR_HEIGHT } from '@/constants';
import { useAppDispatch, useAppSelector } from '@/hooks';
import { selectAllInboxes } from '@/store/inbox/inboxSelectors';
import {
defaultFilterState,
setFiltersState,
} from '@/store/conversation/conversationFilterSlice';
import { Icon } from '@/components-next';
import { tailwind } from '@/theme';
import { useHaptic, getChannelIcon } from '@/utils';
import { Channel } from '@/types';
import i18n from '@/i18n';
import { EmptyStateIcon } from '@/svg-icons';

const ChannelsHeader = () => {
return (
<Animated.View style={tailwind.style('border-b-[1px] border-b-blackA-A3')}>
<Animated.View
style={tailwind.style('flex flex-row justify-center items-center px-4 pt-2 pb-[12px]')}>
<Animated.Text
style={tailwind.style(
'text-[17px] text-center leading-[17px] tracking-[0.32px] font-inter-medium-24 text-gray-950',
)}>
{i18n.t('CHANNELS.TITLE')}
</Animated.Text>
</Animated.View>
</Animated.View>
);
};

type ChannelItem = {
id: number;
name: string;
channelType: Channel;
medium: string;
};

type ChannelRowProps = {
item: ChannelItem;
isLastItem: boolean;
onPress: (id: number) => void;
};

const ChannelRow = ({ item, isLastItem, onPress }: ChannelRowProps) => {
return (
<Pressable onPress={() => onPress(item.id)}>
<Animated.View
style={tailwind.style(
'flex flex-row items-center px-4 py-[11px]',
!isLastItem ? 'border-b-[1px] border-blackA-A3' : '',
)}>
<Icon
icon={getChannelIcon(item.channelType, item.medium, '')}
size={18}
style={tailwind.style('my-auto flex items-center justify-center')}
/>
<Animated.Text
style={tailwind.style(
'text-base text-gray-950 font-inter-420-20 leading-[21px] tracking-[0.16px] capitalize ml-2',
)}>
{item.name}
</Animated.Text>
</Animated.View>
</Pressable>
);
};

const ChannelsScreen = () => {
const dispatch = useAppDispatch();
const navigation = useNavigation();
const hapticSelection = useHaptic();

const inboxes = useAppSelector(selectAllInboxes);

const channels = useMemo(
() =>
inboxes.map(inbox => ({
id: inbox.id,
name: inbox.name,
channelType: inbox.channelType as Channel,
medium: inbox.medium,
})),
[inboxes],
);

const handleChannelPress = useCallback(
(inboxId: number) => {
hapticSelection?.();
dispatch(
setFiltersState({
...defaultFilterState,
inbox_id: inboxId.toString(),
}),
);
navigation.navigate('Conversations');
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

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

The navigation call should be type-safe. Consider typing the navigation hook with the proper navigation type. You can use useNavigation<NavigationProp<TabParamList>>() from '@react-navigation/native' to ensure type safety and avoid runtime errors if the route name changes.

Copilot uses AI. Check for mistakes.
},
[dispatch, hapticSelection, navigation],
);

return (
<SafeAreaView edges={['top']} style={tailwind.style('flex-1 bg-white')}>
<StatusBar
translucent
backgroundColor={tailwind.color('bg-white')}
barStyle={'dark-content'}
/>
<ChannelsHeader />
{channels.length === 0 ? (
<Animated.View
style={tailwind.style(
'flex-1 items-center justify-center',
`pb-[${TAB_BAR_HEIGHT}px]`,
)}>
<EmptyStateIcon />
<Animated.Text style={tailwind.style('pt-6 text-md tracking-[0.32px] text-gray-800')}>
{i18n.t('CHANNELS.EMPTY')}
</Animated.Text>
</Animated.View>
) : (
<FlashList
data={channels}
keyExtractor={item => item.id.toString()}
renderItem={({ item, index }) => (
<ChannelRow
item={item}
isLastItem={index === channels.length - 1}
onPress={handleChannelPress}
/>
)}
estimatedItemSize={56}
contentContainerStyle={tailwind.style(`pb-[${TAB_BAR_HEIGHT - 1}px]`)}
/>
)}
</SafeAreaView>
);
};

export default ChannelsScreen;
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')}
Comment on lines +23 to +45
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

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

The translation toggle always displays the first translation value regardless of which language it is. Consider displaying the translation that matches the current locale, or if not available, show a label indicating which language the translation is in so users know what they're viewing.

Suggested change
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')}
const translations = contentAttributes?.translations as
| Record<string, string>
| undefined;
const hasTranslations =
translations !== undefined && Object.keys(translations).length > 0;
// Determine the current locale from i18n, trying common properties/methods.
const currentLocale: string | undefined =
(i18n as any).language ||
(i18n as any).locale ||
(typeof (i18n as any).currentLocale === 'function'
? (i18n as any).currentLocale()
: undefined);
let selectedTranslationKey: string | undefined;
let selectedTranslation: string | null = null;
if (hasTranslations && translations) {
const translationKeys = Object.keys(translations);
// Try to find an exact match for the current locale (e.g., "en-US").
if (currentLocale && translations[currentLocale]) {
selectedTranslationKey = currentLocale;
selectedTranslation = translations[currentLocale];
} else if (currentLocale) {
// Fallback: match by base language code (e.g., "en" matches "en-US").
const baseLocale = currentLocale.split('-')[0];
const matchedKey = translationKeys.find(
key => key === baseLocale || key.split('-')[0] === baseLocale,
);
if (matchedKey) {
selectedTranslationKey = matchedKey;
selectedTranslation = translations[matchedKey];
}
}
// Final fallback: use the first available translation.
if (!selectedTranslation) {
selectedTranslationKey = translationKeys[0];
selectedTranslation = translations[selectedTranslationKey];
}
}
const displayContent =
hasTranslations && !showOriginal && selectedTranslation
? selectedTranslation
: content;
const renderTranslationToggle = () => {
if (!hasTranslations) return null;
const translationLanguageLabel = selectedTranslationKey
? selectedTranslationKey.toUpperCase()
: null;
const toggleText = showOriginal
? i18n.t('CONVERSATION.VIEW_TRANSLATED')
: i18n.t('CONVERSATION.VIEW_ORIGINAL');
return (
<Pressable onPress={() => setShowOriginal(!showOriginal)}>
<Animated.Text
style={tailwind.style(
'text-xs text-gray-500 mt-1 font-inter-420-20 tracking-[0.32px]',
)}>
{toggleText}
{/* When showing the translated content, indicate which language it is. */}
{!showOriginal && translationLanguageLabel
? ` (${translationLanguageLabel})`
: null}

Copilot uses AI. Check for mistakes.
</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
Loading
Loading