From f9ab90fb71cd9a474b7db3a5a850c7bebe643568 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Mon, 16 Nov 2020 14:45:13 +1100 Subject: [PATCH] link backbone message added to redux --- js/models/conversations.js | 12 +- js/modules/signal.js | 2 - js/views/app_view.js | 3 +- ts/components/session/SessionInboxView.tsx | 8 +- .../conversation/SessionConversation.tsx | 4 +- ts/state/actions.ts | 3 +- ts/state/ducks/conversations.ts | 239 +++++++++++++++++- ts/state/ducks/messages.ts | 129 ---------- ts/state/reducer.ts | 5 +- ts/state/smart/SessionConversation.tsx | 2 +- 10 files changed, 254 insertions(+), 153 deletions(-) delete mode 100644 ts/state/ducks/messages.ts diff --git a/js/models/conversations.js b/js/models/conversations.js index 32ffe48b0..81c3588ea 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -468,7 +468,12 @@ const model = this.addSingleMessage(message); MessageController.register(model.id, model); - this.trigger('change'); + window.Whisper.events.trigger('messageAdded', { + conversationKey: this.id, + messageModel: model, + }); + + this.trigger('change', this); }, addSingleMessage(message, setToExpire = true) { const model = this.messageCollection.add(message, { merge: true }); @@ -1201,6 +1206,11 @@ const id = await message.commit(); message.set({ id }); + window.Whisper.events.trigger('messageAdded', { + conversationKey: this.id, + messageModel: message, + }); + this.set({ lastMessage: model.getNotificationText(), lastMessageStatus: 'sending', diff --git a/js/modules/signal.js b/js/modules/signal.js index 8fd44f40c..3f0e6e026 100644 --- a/js/modules/signal.js +++ b/js/modules/signal.js @@ -107,7 +107,6 @@ const { // State const conversationsDuck = require('../../ts/state/ducks/conversations'); const userDuck = require('../../ts/state/ducks/user'); -const messagesDuck = require('../../ts/state/ducks/messages'); // Migrations const { @@ -283,7 +282,6 @@ exports.setup = (options = {}) => { const Ducks = { conversations: conversationsDuck, user: userDuck, - messages: messagesDuck, }; const State = { Ducks, diff --git a/js/views/app_view.js b/js/views/app_view.js index d68e630a3..717a252f4 100644 --- a/js/views/app_view.js +++ b/js/views/app_view.js @@ -124,13 +124,12 @@ }, onEmpty() { // const view = this.inboxView; - // this.initialLoadComplete = true; // if (view) { // view.onEmpty(); // } }, - onProgress(count) { + onProgress() { // const view = this.inboxView; // if (view) { // view.onProgress(count); diff --git a/ts/components/session/SessionInboxView.tsx b/ts/components/session/SessionInboxView.tsx index d2629385a..7797f8b37 100644 --- a/ts/components/session/SessionInboxView.tsx +++ b/ts/components/session/SessionInboxView.tsx @@ -56,7 +56,6 @@ export class SessionInboxView extends React.Component { void this.setupLeftPane(); // ConversationCollection - conversationModels; // this.listenTo(inboxCollection, 'messageError', () => { // if (this.networkStatusView) { // this.networkStatusView.render(); @@ -219,7 +218,7 @@ export class SessionInboxView extends React.Component { window.inboxStore = this.store; // Enables our redux store to be updated by backbone events in the outside world - const { messageExpired } = bindActionCreators( + const { messageExpired, messageAdded, messageChanged } = bindActionCreators( window.Signal.State.Ducks.conversations.actions, this.store.dispatch ); @@ -228,10 +227,6 @@ export class SessionInboxView extends React.Component { window.Signal.State.Ducks.user.actions, this.store.dispatch ); - const { messageChanged } = bindActionCreators( - window.Signal.State.Ducks.messages.actions, - this.store.dispatch - ); this.fetchHandleMessageSentData = this.fetchHandleMessageSentData.bind( this @@ -248,6 +243,7 @@ export class SessionInboxView extends React.Component { window.Whisper.events.on('messageExpired', messageExpired); window.Whisper.events.on('messageChanged', messageChanged); + window.Whisper.events.on('messageAdded', messageAdded); window.Whisper.events.on('userChanged', userChanged); this.setState({ isInitialLoadComplete: true }); diff --git a/ts/components/session/conversation/SessionConversation.tsx b/ts/components/session/conversation/SessionConversation.tsx index f20cc31ad..148dff562 100644 --- a/ts/components/session/conversation/SessionConversation.tsx +++ b/ts/components/session/conversation/SessionConversation.tsx @@ -191,12 +191,12 @@ export class SessionConversation extends React.Component { } = this.state; const selectionMode = !!selectedMessages.length; - const { conversation, conversationKey } = this.props; + const { conversation, conversationKey, messages } = this.props; const conversationModel = window.ConversationController.get( conversationKey ); - if (!conversationModel) { + if (!conversationModel || !messages) { // return an empty message view return ; } diff --git a/ts/state/actions.ts b/ts/state/actions.ts index 468acd133..99e715579 100644 --- a/ts/state/actions.ts +++ b/ts/state/actions.ts @@ -4,13 +4,12 @@ import { actions as search } from './ducks/search'; import { actions as conversations } from './ducks/conversations'; import { actions as user } from './ducks/user'; import { actions as sections } from './ducks/section'; -import { actions as messages } from './ducks/messages'; const actions = { ...search, ...conversations, ...user, - ...messages, + // ...messages, ...sections, }; diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 27d3d3b17..090c9e002 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -1,6 +1,8 @@ -import { omit } from 'lodash'; +import _, { omit } from 'lodash'; -import { trigger } from '../../shims/events'; +import { Constants } from '../../session'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { MessageModel } from '../../../js/models/messages'; // State @@ -30,6 +32,22 @@ export type MessageType = { isSelected?: boolean; }; + +export type MessageTypeInConvo = { + id: string; + conversationId: string; + attributes: any; + propsForMessage: Object; + propsForSearchResult: Object; + propsForGroupInvitation: Object; + propsForTimerNotification: Object; + propsForVerificationNotification: Object; + propsForResetSessionNotification: Object; + propsForGroupNotification: Object; + firstMessageOfSeries: boolean; + receivedAt: number; +}; + export type ConversationType = { id: string; name?: string; @@ -65,8 +83,82 @@ export type ConversationLookupType = { export type ConversationsStateType = { conversationLookup: ConversationLookupType; selectedConversation?: string; + messages: Array; }; +async function getMessages( + conversationKey: string, + numMessages: number +): Promise> { + const conversation = window.ConversationController.get(conversationKey); + if (!conversation) { + // no valid conversation, early return + window.log.error('Failed to get convo on reducer.'); + return []; + } + const unreadCount = await conversation.getUnreadCount(); + let msgCount = + numMessages || + Number(Constants.CONVERSATION.DEFAULT_MESSAGE_FETCH_COUNT) + unreadCount; + msgCount = + msgCount > Constants.CONVERSATION.MAX_MESSAGE_FETCH_COUNT + ? Constants.CONVERSATION.MAX_MESSAGE_FETCH_COUNT + : msgCount; + + if (msgCount < Constants.CONVERSATION.DEFAULT_MESSAGE_FETCH_COUNT) { + msgCount = Constants.CONVERSATION.DEFAULT_MESSAGE_FETCH_COUNT; + } + + const messageSet = await window.Signal.Data.getMessagesByConversation( + conversationKey, + { limit: msgCount, MessageCollection: window.Whisper.MessageCollection } + ); + + // Set first member of series here. + const messageModels = messageSet.models; + + const messages = []; + // no need to do that `firstMessageOfSeries` on a private chat + if (conversation.isPrivate()) { + return messageModels; + } + + // messages are got from the more recent to the oldest, so we need to check if + // the next messages in the list is still the same author. + // The message is the first of the series if the next message is not from the same author + for (let i = 0; i < messageModels.length; i++) { + // Handle firstMessageOfSeries for conditional avatar rendering + let firstMessageOfSeries = true; + const currentSender = messageModels[i].propsForMessage?.authorPhoneNumber; + const nextSender = + i < messageModels.length - 1 + ? messageModels[i + 1].propsForMessage?.authorPhoneNumber + : undefined; + if (i > 0 && currentSender === nextSender) { + firstMessageOfSeries = false; + } + messages.push({ ...messageModels[i], firstMessageOfSeries }); + } + return messages; +} + +const fetchMessagesForConversation = createAsyncThunk( + 'messages/fetchByConversationKey', + async ({ + conversationKey, + count, + }: { + conversationKey: string; + count: number; + }) => { + const messages = await getMessages(conversationKey, count); + return { + conversationKey, + messages, + }; + } +); + // Actions type ConversationAddedActionType = { @@ -100,6 +192,17 @@ export type MessageExpiredActionType = { conversationId: string; }; }; +export type MessageChangedActionType = { + type: 'MESSAGE_CHANGED'; + payload: MessageModel; +}; +export type MessageAddedActionType = { + type: 'MESSAGE_ADDED'; + payload: { + conversationKey: string; + messageModel: MessageModel; + }; +}; export type SelectedConversationChangedActionType = { type: 'SELECTED_CONVERSATION_CHANGED'; payload: { @@ -108,15 +211,25 @@ export type SelectedConversationChangedActionType = { }; }; +export type FetchMessagesForConversationType = { + type: 'messages/fetchByConversationKey/fulfilled'; + payload: { + conversationKey: string; + messages: Array; + }; +}; + export type ConversationActionType = | ConversationAddedActionType | ConversationChangedActionType | ConversationRemovedActionType | RemoveAllConversationsActionType | MessageExpiredActionType + | MessageAddedActionType + | MessageChangedActionType | SelectedConversationChangedActionType - | MessageExpiredActionType - | SelectedConversationChangedActionType; + | SelectedConversationChangedActionType + | FetchMessagesForConversationType; // Action Creators @@ -126,6 +239,9 @@ export const actions = { conversationRemoved, removeAllConversations, messageExpired, + messageAdded, + messageChanged, + fetchMessagesForConversation, openConversationExternal, }; @@ -181,6 +297,29 @@ function messageExpired( }; } +function messageChanged(messageModel: MessageModel): MessageChangedActionType { + return { + type: 'MESSAGE_CHANGED', + payload: messageModel, + }; +} + +function messageAdded({ + conversationKey, + messageModel, +}: { + conversationKey: string; + messageModel: MessageModel; +}): MessageAddedActionType { + return { + type: 'MESSAGE_ADDED', + payload: { + conversationKey, + messageModel, + }, + }; +} + function openConversationExternal( id: string, messageId?: string @@ -196,12 +335,29 @@ function openConversationExternal( // Reducer +const toPickFromMessageModel = [ + 'attributes', + 'id', + 'propsForSearchResult', + 'propsForMessage', + 'receivedAt', + 'conversationId', + 'firstMessageOfSeries', + 'propsForGroupInvitation', + 'propsForTimerNotification', + 'propsForVerificationNotification', + 'propsForResetSessionNotification', + 'propsForGroupNotification', +]; + function getEmptyState(): ConversationsStateType { return { conversationLookup: {}, + messages: [], }; } +// tslint:disable-next-line: cyclomatic-complexity export function reducer( state: ConversationsStateType = getEmptyState(), action: ConversationActionType @@ -263,7 +419,8 @@ export function reducer( return getEmptyState(); } if (action.type === 'MESSAGE_EXPIRED') { - // noop - for now this is only important for search + // FIXME + console.warn('EXPIRED'); } if (action.type === 'SELECTED_CONVERSATION_CHANGED') { const { payload } = action; @@ -273,5 +430,77 @@ export function reducer( selectedConversation: id, }; } + if (action.type === fetchMessagesForConversation.fulfilled.type) { + const { messages, conversationKey } = action.payload as any; + // double check that this update is for the shown convo + if (conversationKey === state.selectedConversation) { + const lightMessages = messages.map((m: any) => + _.pick(m, toPickFromMessageModel) + ) as Array; + return { + ...state, + messages: lightMessages, + }; + } + return state; + } + + if (action.type === 'MESSAGE_CHANGED') { + const messageInStoreIndex = state?.messages.findIndex( + m => m.id === action.payload.id + ); + if (messageInStoreIndex >= 0) { + const changedMessage = _.pick( + action.payload as any, + toPickFromMessageModel + ) as MessageTypeInConvo; + // we cannot edit the array directly, so slice the first part, insert our edited message, and slice the second part + const editedMessages = [ + ...state.messages.slice(0, messageInStoreIndex), + changedMessage, + ...state.messages.slice(messageInStoreIndex + 1), + ]; + return { + ...state, + messages: editedMessages, + }; + } + + return state; + } + + if (action.type === 'MESSAGE_ADDED') { + const { conversationKey, messageModel } = action.payload; + if (conversationKey === state.selectedConversation) { + const { messages } = state; + const addedMessage = _.pick( + messageModel as any, + toPickFromMessageModel + ) as MessageTypeInConvo; + const messagesWithNewMessage = [...messages, addedMessage]; + const convo = state.conversationLookup[state.selectedConversation]; + const isPublic = convo?.isPublic; + + if (convo && isPublic) { + return { + ...state, + messages: messagesWithNewMessage.sort( + (a: any, b: any) => + b.attributes.serverTimestamp - a.attributes.serverTimestamp + ), + }; + } + if (convo) { + return { + ...state, + messages: messagesWithNewMessage.sort( + (a, b) => b.attributes.timestamp - a.attributes.timestamp + ), + }; + } + } + return state; + } + return state; } diff --git a/ts/state/ducks/messages.ts b/ts/state/ducks/messages.ts deleted file mode 100644 index c33efaf27..000000000 --- a/ts/state/ducks/messages.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { Constants } from '../../session'; -import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; -import _ from 'lodash'; -import { MessageType } from './conversations'; - -export type MessagesStateType = Array; - -export async function getMessages( - conversationKey: string, - numMessages: number -): Promise { - const conversation = window.ConversationController.get(conversationKey); - if (!conversation) { - // no valid conversation, early return - window.log.error('Failed to get convo on reducer.'); - return []; - } - const unreadCount = await conversation.getUnreadCount(); - let msgCount = - numMessages || - Number(Constants.CONVERSATION.DEFAULT_MESSAGE_FETCH_COUNT) + unreadCount; - msgCount = - msgCount > Constants.CONVERSATION.MAX_MESSAGE_FETCH_COUNT - ? Constants.CONVERSATION.MAX_MESSAGE_FETCH_COUNT - : msgCount; - - if (msgCount < Constants.CONVERSATION.DEFAULT_MESSAGE_FETCH_COUNT) { - msgCount = Constants.CONVERSATION.DEFAULT_MESSAGE_FETCH_COUNT; - } - - const messageSet = await window.Signal.Data.getMessagesByConversation( - conversationKey, - { limit: msgCount, MessageCollection: window.Whisper.MessageCollection } - ); - - // Set first member of series here. - const messageModels = messageSet.models; - - const messages = []; - // no need to do that `firstMessageOfSeries` on a private chat - if (conversation.isPrivate()) { - return messageModels; - } - - // messages are got from the more recent to the oldest, so we need to check if - // the next messages in the list is still the same author. - // The message is the first of the series if the next message is not from the same authori - for (let i = 0; i < messageModels.length; i++) { - // Handle firstMessageOfSeries for conditional avatar rendering - let firstMessageOfSeries = true; - const currentSender = messageModels[i].propsForMessage?.authorPhoneNumber; - const nextSender = - i < messageModels.length - 1 - ? messageModels[i + 1].propsForMessage?.authorPhoneNumber - : undefined; - if (i > 0 && currentSender === nextSender) { - firstMessageOfSeries = false; - } - messages.push({ ...messageModels[i], firstMessageOfSeries }); - } - return messages; -} - -// ACTIONS -const fetchMessagesForConversation = createAsyncThunk( - 'messages/fetchByConversationKey', - async ({ - conversationKey, - count, - }: { - conversationKey: string; - count: number; - }) => { - return getMessages(conversationKey, count); - } -); - -const toPickFromMessageModel = [ - 'attributes', - 'id', - 'propsForSearchResult', - 'propsForMessage', - 'receivedAt', - 'conversationId', - 'firstMessageOfSeries', - 'propsForGroupInvitation', - 'propsForTimerNotification', - 'propsForVerificationNotification', - 'propsForResetSessionNotification', - 'propsForGroupNotification', -]; - -const messageSlice = createSlice({ - name: 'messages', - initialState: [] as MessagesStateType, - reducers: { - messageChanged(state: MessagesStateType, action) { - console.log('message changed ', action); - const messageInStoreIndex = state.findIndex( - m => m.id === action.payload.id - ); - if (messageInStoreIndex >= 0) { - state[messageInStoreIndex] = _.pick( - action.payload, - toPickFromMessageModel - ) as MessageType; - } - return state; - }, - }, - extraReducers: { - // Add reducers for additional action types here, and handle loading state as needed - [fetchMessagesForConversation.fulfilled.type]: (state, action) => { - // console.log('fetchMessagesForConversatio0 NON LIGHT', action.payload); - - const lightMessages = action.payload.map((m: any) => - _.pick(m, toPickFromMessageModel) - ) as MessagesStateType; - // console.log('fetchMessagesForConversation', lightMessages); - return lightMessages; - }, - }, -}); - -export const actions = { - ...messageSlice.actions, - fetchMessagesForConversation, -}; -export const reducer = messageSlice.reducer; diff --git a/ts/state/reducer.ts b/ts/state/reducer.ts index fa18de577..f6b07979b 100644 --- a/ts/state/reducer.ts +++ b/ts/state/reducer.ts @@ -8,11 +8,10 @@ import { import { reducer as user, UserStateType } from './ducks/user'; import { reducer as theme, ThemeStateType } from './ducks/theme'; import { reducer as section, SectionStateType } from './ducks/section'; -import { MessagesStateType, reducer as messages } from './ducks/messages'; export type StateType = { search: SearchStateType; - messages: MessagesStateType; + // messages: MessagesStateType; user: UserStateType; conversations: ConversationsStateType; theme: ThemeStateType; @@ -22,7 +21,7 @@ export type StateType = { export const reducers = { search, // Temporary until ./ducks/messages is working - messages, + // messages, // messages: search, conversations, user, diff --git a/ts/state/smart/SessionConversation.tsx b/ts/state/smart/SessionConversation.tsx index f2afa7794..70e29db4f 100644 --- a/ts/state/smart/SessionConversation.tsx +++ b/ts/state/smart/SessionConversation.tsx @@ -14,7 +14,7 @@ const mapStateToProps = (state: StateType) => { conversation, conversationKey, theme: state.theme, - messages: state.messages, + messages: state.conversations.messages, }; };