diff --git a/app/sql.js b/app/sql.js index b9a1128ce..3d2b3b688 100644 --- a/app/sql.js +++ b/app/sql.js @@ -2472,6 +2472,8 @@ async function getUnreadCountByConversation(conversationId) { } // Note: Sorting here is necessary for getting the last message (with limit 1) +// be sure to update the sorting order to sort messages on reduxz too (sortMessages + async function getMessagesByConversation( conversationId, { limit = 100, receivedAt = Number.MAX_VALUE, type = '%' } = {} @@ -2482,7 +2484,7 @@ async function getMessagesByConversation( conversationId = $conversationId AND received_at < $received_at AND type LIKE $type - ORDER BY serverTimestamp DESC, serverId DESC, sent_at DESC + ORDER BY serverTimestamp DESC, serverId DESC, sent_at DESC, received_at DESC LIMIT $limit; `, { diff --git a/js/modules/loki_app_dot_net_api.d.ts b/js/modules/loki_app_dot_net_api.d.ts index 1b5631d92..fd52aa503 100644 --- a/js/modules/loki_app_dot_net_api.d.ts +++ b/js/modules/loki_app_dot_net_api.d.ts @@ -24,6 +24,7 @@ export interface LokiAppDotNetServerInterface { } export interface LokiPublicChannelAPI { + banUser(source: string): Promise; getModerators: () => Promise>; serverAPI: any; deleteMessages(arg0: any[]); diff --git a/js/read_syncs.js b/js/read_syncs.js index 9e078f6e1..c1f191c9e 100644 --- a/js/read_syncs.js +++ b/js/read_syncs.js @@ -88,11 +88,6 @@ const force = true; await message.setToExpire(force); - - const conversation = message.getConversation(); - if (conversation) { - conversation.trigger('expiration-change', message); - } } this.remove(receipt); diff --git a/ts/models/conversation.ts b/ts/models/conversation.ts index 2fa60363b..718dba659 100644 --- a/ts/models/conversation.ts +++ b/ts/models/conversation.ts @@ -11,7 +11,7 @@ import { ReadReceiptMessage, TypingMessage, } from '../session/messages/outgoing'; -import { ClosedGroupChatMessage } from '../session/messages/outgoing/content/data/group'; +import { ClosedGroupChatMessage } from '../session/messages/outgoing/content/data/group/ClosedGroupChatMessage'; import { OpenGroup, PubKey } from '../session/types'; import { ToastUtils, UserUtils } from '../session/utils'; import { BlockedNumberController } from '../util'; @@ -20,7 +20,7 @@ import { leaveClosedGroup } from '../session/group'; import { SignalService } from '../protobuf'; import { MessageCollection, MessageModel } from './message'; import * as Data from '../../js/modules/data'; -import { MessageAttributesOptionals } from './messageType'; +import { MessageAttributesOptionals, MessageModelType } from './messageType'; import autoBind from 'auto-bind'; export interface OurLokiProfile { @@ -160,15 +160,7 @@ export class ConversationModel extends Backbone.Model { this.bouncyUpdateLastMessage.bind(this), 1000 ); - // this.listenTo( - // this.messageCollection, - // 'add remove destroy', - // debouncedUpdateLastMessage - // ); // Listening for out-of-band data updates - this.on('delivered', this.updateAndMerge); - this.on('read', this.updateAndMerge); - this.on('expiration-change', this.updateAndMerge); this.on('expired', this.onExpired); this.on('ourAvatarChanged', avatar => @@ -370,21 +362,6 @@ export class ConversationModel extends Backbone.Model { } } - public async updateAndMerge(message: any) { - await this.updateLastMessage(); - - const mergeMessage = () => { - const existing = this.messageCollection.get(message.id); - if (!existing) { - return; - } - - existing.merge(message.attributes); - }; - - mergeMessage(); - } - public async onExpired(message: any) { await this.updateLastMessage(); @@ -439,16 +416,7 @@ export class ConversationModel extends Backbone.Model { await model.setServerTimestamp(serverTimestamp); return undefined; } - public addSingleMessage( - message: MessageAttributesOptionals, - setToExpire = true - ) { - const model = this.messageCollection.add(message, { merge: true }); - if (setToExpire) { - void model.setToExpire(); - } - return model; - } + public format() { return this.cachedProps; } @@ -673,17 +641,23 @@ export class ConversationModel extends Backbone.Model { conversationId: this.id, }); } - public async sendMessageJob(message: any) { + public async sendMessageJob(message: MessageModel) { try { const uploads = await message.uploadData(); const { id } = message; const expireTimer = this.get('expireTimer'); const destination = this.id; + const sentAt = message.get('sent_at'); + + if (!sentAt) { + throw new Error('sendMessageJob() sent_at must be set.'); + } + const chatMessage = new ChatMessage({ body: uploads.body, identifier: id, - timestamp: message.get('sent_at'), + timestamp: sentAt, attachments: uploads.attachments, expireTimer, preview: uploads.preview, @@ -696,7 +670,7 @@ export class ConversationModel extends Backbone.Model { const openGroupParams = { body: uploads.body, - timestamp: message.get('sent_at'), + timestamp: sentAt, group: openGroup, attachments: uploads.attachments, preview: uploads.preview, @@ -716,7 +690,7 @@ export class ConversationModel extends Backbone.Model { const groupInvitation = message.get('groupInvitation'); const groupInvitMessage = new GroupInvitationMessage({ identifier: id, - timestamp: message.get('sent_at'), + timestamp: sentAt, serverName: groupInvitation.name, channelId: groupInvitation.channelId, serverAddress: groupInvitation.address, @@ -767,6 +741,7 @@ export class ConversationModel extends Backbone.Model { this.clearTypingTimers(); const destination = this.id; + const isPrivate = this.isPrivate(); const expireTimer = this.get('expireTimer'); const recipients = this.getRecipients(); @@ -808,28 +783,16 @@ export class ConversationModel extends Backbone.Model { const attributes: MessageAttributesOptionals = { ...messageWithSchema, groupInvitation, - id: window.getGuid(), conversationId: this.id, + destination: isPrivate ? destination : undefined, }; - const model = this.addSingleMessage(attributes); - MessageController.getInstance().register(model.id, model); - - const id = await model.commit(); - model.set({ id }); + const model = await this.addSingleMessage(attributes); - if (this.isPrivate()) { - model.set({ destination }); - } if (this.isPublic()) { await model.setServerTimestamp(new Date().getTime()); } - window.Whisper.events.trigger('messageAdded', { - conversationKey: this.id, - messageModel: model, - }); - this.set({ lastMessage: model.getNotificationText(), lastMessageStatus: 'sending', @@ -912,7 +875,7 @@ export class ConversationModel extends Backbone.Model { public async updateExpirationTimer( providedExpireTimer: any, providedSource?: string, - receivedAt?: number, + receivedAt?: number, // is set if it comes from outside options: any = {} ) { let expireTimer = providedExpireTimer; @@ -936,6 +899,8 @@ export class ConversationModel extends Backbone.Model { source, }); + const isOutgoing = Boolean(receivedAt); + source = source || UserUtils.getOurPubKeyStrFromCache(); // When we add a disappearing messages notification to the conversation, we want it @@ -943,9 +908,8 @@ export class ConversationModel extends Backbone.Model { const timestamp = (receivedAt || Date.now()) - 1; this.set({ expireTimer }); - await this.commit(); - const message = new MessageModel({ + const messageAttributes = { // Even though this isn't reflected to the user, we want to place the last seen // indicator above it. We set it to 'unread' to trigger that placement. unread: true, @@ -961,23 +925,14 @@ export class ConversationModel extends Backbone.Model { fromGroupUpdate: options.fromGroupUpdate, }, expireTimer: 0, - type: 'incoming', - }); - - message.set({ destination: this.id }); - - if (message.isOutgoing()) { - message.set({ recipients: this.getRecipients() }); - } + type: isOutgoing ? 'outgoing' : ('incoming' as MessageModelType), + destination: this.id, + recipients: isOutgoing ? this.getRecipients() : undefined, + }; - const id = await message.commit(); - - message.set({ id }); - window.Whisper.events.trigger('messageAdded', { - conversationKey: this.id, - messageModel: message, - }); + const message = await this.addSingleMessage(messageAttributes); + // tell the UI this conversation was updated await this.commit(); // if change was made remotely, don't send it to the number/group @@ -991,7 +946,7 @@ export class ConversationModel extends Backbone.Model { } const expireUpdate = { - identifier: id, + identifier: message.id, timestamp, expireTimer, profileKey, @@ -1008,13 +963,16 @@ export class ConversationModel extends Backbone.Model { return message.sendSyncMessageOnly(expirationTimerMessage); } - if (this.get('type') === 'private') { + if (this.isPrivate()) { const expirationTimerMessage = new ExpirationTimerUpdateMessage( expireUpdate ); const pubkey = new PubKey(this.get('id')); await getMessageQueue().sendToPubKey(pubkey, expirationTimerMessage); } else { + window.log.warn( + 'TODO: Expiration update for closed groups are to be updated' + ); const expireUpdateForGroup = { ...expireUpdate, groupId: this.get('id'), @@ -1023,24 +981,12 @@ export class ConversationModel extends Backbone.Model { const expirationTimerMessage = new ExpirationTimerUpdateMessage( expireUpdateForGroup ); - // special case when we are the only member of a closed group - const ourNumber = UserUtils.getOurPubKeyStrFromCache(); - if ( - this.get('members').length === 1 && - this.get('members')[0] === ourNumber - ) { - return message.sendSyncMessageOnly(expirationTimerMessage); - } await getMessageQueue().sendToGroup(expirationTimerMessage); } return message; } - public isSearchable() { - return !this.get('left'); - } - public async commit() { await window.Signal.Data.updateConversation(this.id, this.attributes, { Conversation: ConversationModel, @@ -1048,27 +994,33 @@ export class ConversationModel extends Backbone.Model { this.trigger('change', this); } - public async addMessage(messageAttributes: MessageAttributesOptionals) { + public async addSingleMessage( + messageAttributes: MessageAttributesOptionals, + setToExpire = true + ) { const model = new MessageModel(messageAttributes); const messageId = await model.commit(); model.set({ id: messageId }); + + if (setToExpire) { + await model.setToExpire(); + } + MessageController.getInstance().register(messageId, model); + window.Whisper.events.trigger('messageAdded', { conversationKey: this.id, messageModel: model, }); + return model; } public async leaveGroup() { - if (this.get('type') !== ConversationType.GROUP) { - window.log.error('Cannot leave a non-group conversation'); - return; - } - if (this.isMediumGroup()) { await leaveClosedGroup(this.id); } else { + window.log.error('Cannot leave a non-medium group conversation'); throw new Error( 'Legacy group are not supported anymore. You need to create this group again.' ); diff --git a/ts/models/message.ts b/ts/models/message.ts index 6db4f4427..956da58c9 100644 --- a/ts/models/message.ts +++ b/ts/models/message.ts @@ -11,7 +11,7 @@ import { DataMessage, OpenGroupMessage, } from '../../ts/session/messages/outgoing'; -import { ClosedGroupChatMessage } from '../../ts/session/messages/outgoing/content/data/group'; +import { ClosedGroupChatMessage } from '../../ts/session/messages/outgoing/content/data/group/ClosedGroupChatMessage'; import { EncryptionType, PubKey } from '../../ts/session/types'; import { ToastUtils, UserUtils } from '../../ts/session/utils'; import { @@ -41,9 +41,6 @@ export class MessageModel extends Backbone.Model { ); } - this.on('destroy', this.onDestroy); - this.on('change:expirationStartTimestamp', this.setToExpire); - this.on('change:expireTimer', this.setToExpire); // this.on('expired', this.onExpired); void this.setToExpire(); autoBind(this); @@ -674,7 +671,9 @@ export class MessageModel extends Backbone.Model { ? [this.get('source')] : _.union( this.get('sent_to') || [], - this.get('recipients') || this.getConversation().getRecipients() + this.get('recipients') || + this.getConversation()?.getRecipients() || + [] ); // This will make the error message for outgoing key errors a bit nicer @@ -750,8 +749,20 @@ export class MessageModel extends Backbone.Model { resolve: async () => { const source = this.get('source'); const conversation = this.getConversation(); + if (!conversation) { + window.log.info( + 'cannot ban user, the corresponding conversation was not found.' + ); + return; + } const channelAPI = await conversation.getPublicSendData(); + if (!channelAPI) { + window.log.info( + 'cannot ban user, the corresponding channelAPI was not found.' + ); + return; + } const success = await channelAPI.banUser(source); if (success) { @@ -805,7 +816,8 @@ export class MessageModel extends Backbone.Model { const conversation = this.getConversation(); const openGroup = - conversation && conversation.isPublic() && conversation.toOpenGroup(); + (conversation && conversation.isPublic() && conversation.toOpenGroup()) || + undefined; const { AttachmentUtils } = Utils; const [attachments, preview, quote] = await Promise.all([ @@ -836,9 +848,12 @@ export class MessageModel extends Backbone.Model { await this.commit(); try { const conversation = this.getConversation(); - const intendedRecipients = this.get('recipients') || []; - const successfulRecipients = this.get('sent_to') || []; - const currentRecipients = conversation.getRecipients(); + if (!conversation) { + window.log.info( + 'cannot retry send message, the corresponding conversation was not found.' + ); + return; + } if (conversation.isPublic()) { const openGroup = { @@ -858,19 +873,8 @@ export class MessageModel extends Backbone.Model { return getMessageQueue().sendToGroup(openGroupMessage); } - let recipients = _.intersection(intendedRecipients, currentRecipients); - recipients = recipients.filter( - key => !successfulRecipients.includes(key) - ); - - if (!recipients.length) { - window.log.warn('retrySend: Nobody to send to!'); - - return this.commit(); - } - const { body, attachments, preview, quote } = await this.uploadData(); - const ourNumber = window.storage.get('primaryDevicePubKey'); + const ourNumber = UserUtils.getOurPubKeyStrFromCache(); const ourConversation = ConversationController.getInstance().get( ourNumber ); @@ -893,86 +897,33 @@ export class MessageModel extends Backbone.Model { const chatMessage = new ChatMessage(chatParams); // Special-case the self-send case - we send only a sync message - if (recipients.length === 1) { - const isOurDevice = UserUtils.isUsFromCache(recipients[0]); - if (isOurDevice) { - return this.sendSyncMessageOnly(chatMessage); - } + if (conversation.isMe()) { + return this.sendSyncMessageOnly(chatMessage); } if (conversation.isPrivate()) { - const [number] = recipients; - const recipientPubKey = new PubKey(number); + return getMessageQueue().sendToPubKey( + PubKey.cast(conversation.id), + chatMessage + ); + } - return getMessageQueue().sendToPubKey(recipientPubKey, chatMessage); + // Here, the convo is neither an open group, a private convo or ourself. It can only be a medium group. + // For a medium group, retry send only means trigger a send again to all recipients + // as they are all polling from the same group swarm pubkey + if (!conversation.isMediumGroup()) { + throw new Error( + 'We should only end up with a medium group here. Anything else is an error' + ); } - // TODO should we handle medium groups message here too? - // Not sure there is the concept of retrySend for those const closedGroupChatMessage = new ClosedGroupChatMessage({ identifier: this.id, chatMessage, groupId: this.get('conversationId'), }); - // Because this is a partial group send, we send the message with the groupId field set, but individually - // to each recipient listed - return Promise.all( - recipients.map(async r => { - const recipientPubKey = new PubKey(r); - return getMessageQueue().sendToPubKey( - recipientPubKey, - closedGroupChatMessage - ); - }) - ); - } catch (e) { - await this.saveErrors(e); - return null; - } - } - - // Called when the user ran into an error with a specific user, wants to send to them - public async resend(number: string) { - const error = this.removeOutgoingErrors(number); - if (!error) { - window.log.warn('resend: requested number was not present in errors'); - return null; - } - try { - const { body, attachments, preview, quote } = await this.uploadData(); - - const chatMessage = new ChatMessage({ - identifier: this.id, - body, - timestamp: this.get('sent_at') || Date.now(), - expireTimer: this.get('expireTimer'), - attachments, - preview, - quote, - }); - - // Special-case the self-send case - we send only a sync message - if (UserUtils.isUsFromCache(number)) { - return this.sendSyncMessageOnly(chatMessage); - } - - const conversation = this.getConversation(); - const recipientPubKey = new PubKey(number); - - if (conversation.isPrivate()) { - return getMessageQueue().sendToPubKey(recipientPubKey, chatMessage); - } - - const closedGroupChatMessage = new ClosedGroupChatMessage({ - chatMessage, - groupId: this.get('conversationId'), - }); - // resend tries to send the message to that specific user only in the context of a closed group - return getMessageQueue().sendToPubKey( - recipientPubKey, - closedGroupChatMessage - ); + return getMessageQueue().sendToGroup(closedGroupChatMessage); } catch (e) { await this.saveErrors(e); return null; @@ -1085,9 +1036,7 @@ export class MessageModel extends Backbone.Model { await this.commit(); - this.getConversation().updateLastMessage(); - - this.trigger('sent', this); + this.getConversation()?.updateLastMessage(); } public async handleMessageSentFailure(sentMessage: any, error: any) { @@ -1113,8 +1062,7 @@ export class MessageModel extends Backbone.Model { }); await this.commit(); - this.getConversation().updateLastMessage(); - this.trigger('done'); + this.getConversation()?.updateLastMessage(); } public getConversation() { diff --git a/ts/models/messageType.ts b/ts/models/messageType.ts index ea7f20f9b..d954a59a5 100644 --- a/ts/models/messageType.ts +++ b/ts/models/messageType.ts @@ -13,6 +13,8 @@ export type MessageDeliveryStatus = | 'error'; export interface MessageAttributes { + // the id of the message + // this can have several uses: id: string; source: string; quote?: any; diff --git a/ts/session/conversations/index.ts b/ts/session/conversations/index.ts index 6694847e4..6fc35493d 100644 --- a/ts/session/conversations/index.ts +++ b/ts/session/conversations/index.ts @@ -51,7 +51,7 @@ export class ConversationController { ); } // Needed for some model setup which happens during the initial fetch() call below - public getUnsafe(id: string) { + public getUnsafe(id: string): ConversationModel | undefined { return this.conversations.get(id); } diff --git a/ts/session/group/index.ts b/ts/session/group/index.ts index 4c4a063ec..df1da6716 100644 --- a/ts/session/group/index.ts +++ b/ts/session/group/index.ts @@ -194,7 +194,7 @@ export async function addUpdateMessage( const unread = type === 'incoming'; - const message = await convo.addMessage({ + const message = await convo.addSingleMessage({ conversationId: convo.get('id'), type, sent_at: now, @@ -340,7 +340,7 @@ export async function leaveClosedGroup(groupId: string) { convo.set({ groupAdmins: admins }); await convo.commit(); - const dbMessage = await convo.addMessage({ + const dbMessage = await convo.addSingleMessage({ group_update: { left: 'You' }, conversationId: groupId, type: 'outgoing', diff --git a/ts/session/messages/outgoing/content/data/group/index.ts b/ts/session/messages/outgoing/content/data/group/index.ts index c16a052c1..636bc0001 100644 --- a/ts/session/messages/outgoing/content/data/group/index.ts +++ b/ts/session/messages/outgoing/content/data/group/index.ts @@ -1,4 +1,3 @@ -export * from './ClosedGroupChatMessage'; export * from './ClosedGroupEncryptionPairMessage'; export * from './ClosedGroupNewMessage'; export * from './ClosedGroupAddedMembersMessage'; diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index f8c2440db..5d78bd04f 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -56,7 +56,6 @@ export interface ConversationType { index?: number; activeAt?: number; - timestamp: number; lastMessage?: { status: 'error' | 'sending' | 'sent' | 'delivered' | 'read'; text: string; @@ -443,15 +442,26 @@ function sortMessages( isPublic: boolean ): Array { // we order by serverTimestamp for public convos + // be sure to update the sorting order to fetch messages from the DB too at getMessagesByConversation if (isPublic) { return messages.sort( (a: any, b: any) => b.attributes.serverTimestamp - a.attributes.serverTimestamp ); } - return messages.sort( - (a: any, b: any) => b.attributes.timestamp - a.attributes.timestamp + if (messages.some(n => !n.attributes.sent_at && !n.attributes.received_at)) { + throw new Error('Found some messages without any timestamp set'); + } + + // for non public convos, we order by sent_at or received_at timestamp. + // we assume that a message has either a sent_at or a received_at field set. + const messagesSorted = messages.sort( + (a: any, b: any) => + (b.attributes.sent_at || b.attributes.received_at) - + (a.attributes.sent_at || a.attributes.received_at) ); + + return messagesSorted; } function handleMessageAdded( @@ -488,12 +498,13 @@ function handleMessageChanged( state: ConversationsStateType, action: MessageChangedActionType ) { + const { payload } = action; const messageInStoreIndex = state?.messages?.findIndex( - m => m.id === action.payload.id + m => m.id === payload.id ); if (messageInStoreIndex >= 0) { const changedMessage = _.pick( - action.payload as any, + 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 @@ -503,7 +514,10 @@ function handleMessageChanged( ...state.messages.slice(messageInStoreIndex + 1), ]; + const convo = state.conversationLookup[payload.get('conversationId')]; + const isPublic = convo?.isPublic || false; // reorder the messages depending on the timestamp (we might have an updated serverTimestamp now) + const sortedMessage = sortMessages(editedMessages, isPublic); const updatedWithFirstMessageOfSeries = updateFirstMessageOfSeries( editedMessages ); diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index aa0e5200a..9929f57e7 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -142,7 +142,7 @@ export const _getLeftPaneLists = ( } // Show loading icon while fetching messages - if (conversation.isPublic && !conversation.timestamp) { + if (conversation.isPublic && !conversation.activeAt) { conversation.lastMessage = { status: 'sending', text: '', diff --git a/ts/test/state/selectors/conversations_test.ts b/ts/test/state/selectors/conversations_test.ts index 35fefeb07..c814d829b 100644 --- a/ts/test/state/selectors/conversations_test.ts +++ b/ts/test/state/selectors/conversations_test.ts @@ -13,9 +13,8 @@ describe('state/selectors/conversations', () => { const data: ConversationLookupType = { id1: { id: 'id1', - activeAt: Date.now(), + activeAt: 0, name: 'No timestamp', - timestamp: 0, phoneNumber: 'notused', type: 'direct', @@ -30,9 +29,8 @@ describe('state/selectors/conversations', () => { }, id2: { id: 'id2', - activeAt: Date.now(), + activeAt: 20, name: 'B', - timestamp: 20, phoneNumber: 'notused', type: 'direct', @@ -47,9 +45,8 @@ describe('state/selectors/conversations', () => { }, id3: { id: 'id3', - activeAt: Date.now(), + activeAt: 20, name: 'C', - timestamp: 20, phoneNumber: 'notused', type: 'direct', @@ -64,9 +61,8 @@ describe('state/selectors/conversations', () => { }, id4: { id: 'id4', - activeAt: Date.now(), + activeAt: 20, name: 'Á', - timestamp: 20, phoneNumber: 'notused', type: 'direct', isMe: false, @@ -80,9 +76,8 @@ describe('state/selectors/conversations', () => { }, id5: { id: 'id5', - activeAt: Date.now(), + activeAt: 30, name: 'First!', - timestamp: 30, phoneNumber: 'notused', type: 'direct', isMe: false, diff --git a/ts/test/test-utils/utils/message.ts b/ts/test/test-utils/utils/message.ts index 84be09db1..cecc2f308 100644 --- a/ts/test/test-utils/utils/message.ts +++ b/ts/test/test-utils/utils/message.ts @@ -5,7 +5,7 @@ import { import { v4 as uuid } from 'uuid'; import { OpenGroup } from '../../../session/types'; import { generateFakePubKey, generateFakePubKeys } from './pubkey'; -import { ClosedGroupChatMessage } from '../../../session/messages/outgoing/content/data/group'; +import { ClosedGroupChatMessage } from '../../../session/messages/outgoing/content/data/group/ClosedGroupChatMessage'; import { ConversationAttributes } from '../../../models/conversation'; export function generateChatMessage(identifier?: string): ChatMessage {