import { AnyAction } from 'redux'; import { omit } from 'lodash'; import { trigger } from '../../shims/events'; import { NoopActionType } from './noop'; // State export type MessageSearchResultType = { id: string; conversationId: string; receivedAt: number; snippet: string; from: { phoneNumber: string; isMe?: boolean; name?: string; color?: string; profileName?: string; avatarPath?: string; }; to: { groupName?: string; phoneNumber: string; isMe?: boolean; name?: string; profileName?: string; }; isSelected?: boolean; }; export type ConversationType = { id: string; name?: string; isArchived: boolean; activeAt?: number; timestamp: number; lastMessage?: { status: 'error' | 'sending' | 'sent' | 'delivered' | 'read'; text: string; }; phoneNumber: string; type: 'direct' | 'group'; isMe: boolean; lastUpdated: number; unreadCount: number; isSelected: boolean; isTyping: boolean; }; export type ConversationLookupType = { [key: string]: ConversationType; }; export type MessageType = { id: string; }; export type MessageLookupType = { [key: string]: MessageType; }; export type ConversationMessageType = { // And perhaps this could be part of our ConversationType? What if we moved all the selectors as part of this set of changes? // We have the infrastructure for it now... messages: Array; }; export type MessagesByConversationType = { [key: string]: ConversationMessageType; }; export type ConversationsStateType = { conversationLookup: ConversationLookupType; selectedConversation?: string; showArchived: boolean; // Note: it's very important that both of these locations are always kept up to date messagesLookup: MessageLookupType; messagesByConversation: MessagesByConversationType; }; // Actions type ConversationAddedActionType = { type: 'CONVERSATION_ADDED'; payload: { id: string; data: ConversationType; }; }; type ConversationChangedActionType = { type: 'CONVERSATION_CHANGED'; payload: { id: string; data: ConversationType; }; }; type ConversationRemovedActionType = { type: 'CONVERSATION_REMOVED'; payload: { id: string; }; }; export type RemoveAllConversationsActionType = { type: 'CONVERSATIONS_REMOVE_ALL'; payload: null; }; export type MessageExpiredActionType = { type: 'MESSAGE_EXPIRED'; payload: { id: string; conversationId: string; }; }; export type SelectedConversationChangedActionType = { type: 'SELECTED_CONVERSATION_CHANGED'; payload: { id: string; messageId?: string; }; }; type ShowInboxActionType = { type: 'SHOW_INBOX'; payload: null; }; type ShowArchivedConversationsActionType = { type: 'SHOW_ARCHIVED_CONVERSATIONS'; payload: null; }; export type ConversationActionType = | AnyAction | ConversationAddedActionType | ConversationChangedActionType | ConversationRemovedActionType | RemoveAllConversationsActionType | MessageExpiredActionType | SelectedConversationChangedActionType | MessageExpiredActionType | SelectedConversationChangedActionType | ShowInboxActionType | ShowArchivedConversationsActionType; // Action Creators export const actions = { conversationAdded, conversationChanged, conversationRemoved, removeAllConversations, messageExpired, openConversationInternal, openConversationExternal, showInbox, showArchivedConversations, }; function conversationAdded( id: string, data: ConversationType ): ConversationAddedActionType { return { type: 'CONVERSATION_ADDED', payload: { id, data, }, }; } function conversationChanged( id: string, data: ConversationType ): ConversationChangedActionType { return { type: 'CONVERSATION_CHANGED', payload: { id, data, }, }; } function conversationRemoved(id: string): ConversationRemovedActionType { return { type: 'CONVERSATION_REMOVED', payload: { id, }, }; } function removeAllConversations(): RemoveAllConversationsActionType { return { type: 'CONVERSATIONS_REMOVE_ALL', payload: null, }; } function messageExpired( id: string, conversationId: string ): MessageExpiredActionType { return { type: 'MESSAGE_EXPIRED', payload: { id, conversationId, }, }; } // Note: we need two actions here to simplify. Operations outside of the left pane can // trigger an 'openConversation' so we go through Whisper.events for all conversation // selection. function openConversationInternal( id: string, messageId?: string ): NoopActionType { trigger('showConversation', id, messageId); return { type: 'NOOP', payload: null, }; } function openConversationExternal( id: string, messageId?: string ): SelectedConversationChangedActionType { return { type: 'SELECTED_CONVERSATION_CHANGED', payload: { id, messageId, }, }; } function showInbox() { return { type: 'SHOW_INBOX', payload: null, }; } function showArchivedConversations() { return { type: 'SHOW_ARCHIVED_CONVERSATIONS', payload: null, }; } // Reducer function getEmptyState(): ConversationsStateType { return { conversationLookup: {}, showArchived: false, messagesLookup: {}, messagesByConversation: {}, }; } export function reducer( state: ConversationsStateType = getEmptyState(), action: ConversationActionType ): ConversationsStateType { if (action.type === 'CONVERSATION_ADDED') { const { payload } = action; const { id, data } = payload; const { conversationLookup } = state; return { ...state, conversationLookup: { ...conversationLookup, [id]: data, }, }; } if (action.type === 'CONVERSATION_CHANGED') { const { payload } = action; const { id, data } = payload; const { conversationLookup } = state; let showArchived = state.showArchived; let selectedConversation = state.selectedConversation; const existing = conversationLookup[id]; // In the change case we only modify the lookup if we already had that conversation if (!existing) { return state; } if (selectedConversation === id) { // Archived -> Inbox: we go back to the normal inbox view if (existing.isArchived && !data.isArchived) { showArchived = false; } // Inbox -> Archived: no conversation is selected // Note: With today's stacked converastions architecture, this can result in weird // behavior - no selected conversation in the left pane, but a conversation show // in the right pane. if (!existing.isArchived && data.isArchived) { selectedConversation = undefined; } } return { ...state, selectedConversation, showArchived, conversationLookup: { ...conversationLookup, [id]: data, }, }; } if (action.type === 'CONVERSATION_REMOVED') { const { payload } = action; const { id } = payload; const { conversationLookup } = state; return { ...state, conversationLookup: omit(conversationLookup, [id]), }; } if (action.type === 'CONVERSATIONS_REMOVE_ALL') { return getEmptyState(); } if (action.type === 'MESSAGE_EXPIRED') { // noop - for now this is only important for search } if (action.type === 'SELECTED_CONVERSATION_CHANGED') { const { payload } = action; const { id } = payload; return { ...state, selectedConversation: id, }; } if (action.type === 'SHOW_INBOX') { return { ...state, showArchived: false, }; } if (action.type === 'SHOW_ARCHIVED_CONVERSATIONS') { return { ...state, showArchived: true, }; } return state; }