Skip to content

Conversations

The Conversations feature provides agents with a unified view of all customer conversations, enabling efficient conversation management, filtering, and prioritization.

User Story: As a support agent, I want to view and filter my assigned conversations so that I can prioritize and respond to customer inquiries efficiently.

Acceptance Criteria:

  • ✅ Display list of conversations with key metadata
  • ✅ Filter by status, assignee, inbox, and team
  • ✅ Sort by last activity or priority
  • ✅ Pull-to-refresh to sync latest conversations
  • ✅ Infinite scroll pagination
  • ✅ Real-time updates via WebSocket
  • ✅ Swipe actions for quick operations
  • ✅ Navigate to chat screen on conversation tap

Inputs:

  • Filter criteria (status, assignee type, inbox, team, sort order)
  • Page number for pagination
  • User permissions

Outputs:

  • Filtered and sorted conversation list
  • Conversation metadata (unread count, last message, assignee, status)
  • Loading and error states
User Opens Conversations Tab
Check Cached Conversations in Redux
├─ Cache Available
│ ↓
│ Display Cached Conversations
│ ↓
│ Fetch Fresh Data in Background
│ ↓
│ Update UI with New Data
└─ No Cache
Show Loading Indicator
Fetch Conversations from API
Display Conversations
User Changes Filter (Status/Assignee/Inbox)
Update Redux Filter State
Clear Existing Conversations
Reset Page Number to 1
Dispatch fetchConversations with New Filters
API Request with Filter Params
Transform Response Data
Update Redux Store
Re-render Conversation List
User Scrolls to End of List
Check isAllConversationsFetched
├─ More Conversations Available
│ ↓
│ Increment Page Number
│ ↓
│ Fetch Next Page
│ ↓
│ Append to Existing Conversations
│ ↓
│ Update UI
└─ All Loaded
Show End of List Message
WebSocket Event: conversation.created
ActionCable Connector Receives Event
Transform Event Data
Dispatch: addConversation(conversation)
Reducer Adds to conversations.byId
Update conversations.allIds
Re-render List with New Conversation

Edge Cases:

  • Empty state when no conversations match filters
  • Network error during fetch
  • Stale data after app backgrounded for extended period
  • Duplicate conversations from race conditions
  • Conversation deleted while viewing list

Request:

GET /api/v1/accounts/{accountId}/conversations
Query Parameters:
{
assignee_type: 'me' | 'unassigned' | 'all',
status: 'open' | 'resolved' | 'pending' | 'snoozed',
inbox_id?: number,
team_id?: number,
sort_by: 'last_activity_at' | 'priority',
page: number
}

Response:

{
data: {
meta: {
count: number,
current_page: number,
all_count: number,
mine_count: number,
unassigned_count: number,
},
payload: Conversation[]
}
}
interface Conversation {
id: number;
account_id: number;
inbox_id: number;
status: 'open' | 'resolved' | 'pending' | 'snoozed';
assignee: Agent | null;
messages: Message[];
meta: {
sender: Contact;
channel: string;
};
labels: Label[];
created_at: string;
timestamp: number;
last_activity_at: number;
unread_count: number;
priority: number | null;
}

Fetch Conversations:

conversationActions.fetchConversations({
assigneeType: 'me',
status: 'open',
inboxId?: number,
teamId?: number,
sortBy: 'last_activity_at',
page: 1,
})

Mark as Read:

conversationActions.markMessagesAsRead({
conversationId: number,
})

Toggle Status:

conversationActions.toggleStatus({
conversationId: number,
status: 'open' | 'resolved',
})

Assign Conversation:

conversationActions.assignConversation({
conversationId: number,
assigneeId: number,
})
// Get all filtered conversations
const conversations = useAppSelector(getFilteredConversations);
// Get conversation by ID
const conversation = useAppSelector(state =>
selectConversationById(state, conversationId)
);
// Get loading state
const isLoading = useAppSelector(selectConversationsLoading);
// Get current filters
const filters = useAppSelector(selectFilters);
// Get conversation counts
const counts = useAppSelector(selectConversationCounts);
try {
await dispatch(conversationActions.fetchConversations(params)).unwrap();
} catch (error) {
if (error.code === 'NETWORK_ERROR') {
showToast({ message: I18n.t('ERRORS.NETWORK_ERROR') });
// Auto-retry after 3 seconds
setTimeout(() => {
dispatch(conversationActions.fetchConversations(params));
}, 3000);
}
}
  • 401 Unauthorized: Auto-logout and redirect to login
  • 403 Forbidden: Show permission error
  • 404 Not Found: Remove conversation from list
  • 500 Server Error: Show generic error, allow manual retry
// Optimistically update UI
dispatch(updateConversation({
id: conversationId,
status: 'resolved'
}));
try {
await conversationActions.toggleStatus({ conversationId, status: 'resolved' });
} catch (error) {
// Revert on failure
dispatch(updateConversation({
id: conversationId,
status: 'open'
}));
showToast({ message: I18n.t('ERRORS.UPDATE_FAILED') });
}
// Conversation viewed
AnalyticsHelper.track('conversation_viewed', {
conversation_id: conversationId,
source: 'conversation_list',
});
// Filter applied
AnalyticsHelper.track('conversation_filter_applied', {
assignee_type: filter.assigneeType,
status: filter.status,
inbox_id: filter.inboxId,
});
// Sort changed
AnalyticsHelper.track('conversation_sort_changed', {
sort_by: sortBy,
});
  • Time to first render
  • Time to interactive
  • Infinite scroll performance
  • Filter application latency
import React, { useEffect } from 'react';
import { View, FlatList } from 'react-native';
import { useAppDispatch, useAppSelector } from '@/hooks';
import { conversationActions } from '@/store/conversation/conversationActions';
import { getFilteredConversations } from '@/store/conversation/conversationSelectors';
import { ConversationItem } from './ConversationItem';
export const ConversationList = () => {
const dispatch = useAppDispatch();
const conversations = useAppSelector(getFilteredConversations);
const isLoading = useAppSelector(state => state.conversations.isFetching);
useEffect(() => {
dispatch(conversationActions.fetchConversations({
assigneeType: 'me',
status: 'open',
page: 1,
}));
}, [dispatch]);
const handleRefresh = () => {
dispatch(conversationActions.fetchConversations({
assigneeType: 'me',
status: 'open',
page: 1,
}));
};
const handleLoadMore = () => {
if (!isLoading) {
dispatch(conversationActions.fetchConversations({
assigneeType: 'me',
status: 'open',
page: pageNumber + 1,
}));
}
};
return (
<FlatList
data={conversations}
renderItem={({ item }) => <ConversationItem conversation={item} />}
keyExtractor={item => item.id.toString()}
onRefresh={handleRefresh}
refreshing={isLoading}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.5}
/>
);
};
import { StatusFilters, AssigneeTypeFilters } from './components';
export const ConversationScreen = () => {
const dispatch = useAppDispatch();
const filters = useAppSelector(selectFilters);
const handleFilterChange = (newFilters: Partial<FilterState>) => {
dispatch(updateFilters(newFilters));
dispatch(clearAllConversations());
dispatch(conversationActions.fetchConversations({
...filters,
...newFilters,
page: 1,
}));
};
return (
<View>
<StatusFilters
selectedStatus={filters.status}
onStatusChange={(status) => handleFilterChange({ status })}
/>
<AssigneeTypeFilters
selectedType={filters.assigneeType}
onTypeChange={(assigneeType) => handleFilterChange({ assigneeType })}
/>
<ConversationList />
</View>
);
};
if (featureFlags.conversationPriority) {
// Show priority sorting option
}

When updating conversation schema:

  1. Add new fields with defaults in reducer
  2. Update API transformation layer
  3. Handle both old and new response formats
  4. Monitor error rates