diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 9fda6af0bc8..037fe685635 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1784,10 +1784,48 @@ "message": "Draft:", "description": "Prefix shown in italic in conversation view when a draft is saved" }, + "message--getNotificationText--gif": { + "message": "GIF", + "description": "Shown in notifications and in the left pane when a GIF is received." + }, + "message--getNotificationText--photo": { + "message": "Photo", + "description": "Shown in notifications and in the left pane when a photo is received." + }, + "message--getNotificationText--video": { + "message": "Video", + "description": "Shown in notifications and in the left pane when a video is received." + }, + "message--getNotificationText--voice-message": { + "message": "Voice Message", + "description": "Shown in notifications and in the left pane when a voice message is received." + }, + "message--getNotificationText--audio-message": { + "message": "Audio Message", + "description": "Shown in notifications and in the left pane when an audio message is received." + }, + "message--getNotificationText--file": { + "message": "File", + "description": "Shown in notifications and in the left pane when a generic file is received." + }, "message--getNotificationText--stickers": { "message": "Sticker message", "description": "Shown in notifications and in the left pane instead of sticker image." }, + "message--getNotificationText--text-with-emoji": { + "message": "$emoji$ $text$", + "placeholders": { + "emoji": { + "content": "$1", + "example": "📷" + }, + "text": { + "content": "$2", + "example": "Photo" + } + }, + "description": "Shown in notifications and in the left pane when text has an emoji. Probably always [emoji] [text] on LTR languages and [text] [emoji] on RTL languages." + }, "message--getDescription--unsupported-message": { "message": "Unsupported message", "description": "Shown in notifications and in the left pane when a message has features too new for this signal install." diff --git a/js/models/messages.js b/js/models/messages.js index cd7ec895c77..a1a5a06ae6a 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -20,7 +20,14 @@ window.Whisper = window.Whisper || {}; - const { Message: TypedMessage, Contact, PhoneNumber, Errors } = Signal.Types; + const { + Message: TypedMessage, + Attachment, + MIME, + Contact, + PhoneNumber, + Errors, + } = Signal.Types; const { deleteExternalMessageFiles, getAbsoluteAttachmentPath, @@ -878,40 +885,46 @@ }); }, - // More display logic - getDescription() { + getNotificationData() /* : { text: string, emoji?: string } */ { if (this.isUnsupportedMessage()) { - return i18n('message--getDescription--unsupported-message'); + return { text: i18n('message--getDescription--unsupported-message') }; } + if (this.isProfileChange()) { const change = this.get('profileChange'); const changedId = this.get('changedId'); const changedContact = this.findAndFormatContact(changedId); - return Signal.Util.getStringForProfileChange( - change, - changedContact, - i18n - ); + return { + text: Signal.Util.getStringForProfileChange( + change, + changedContact, + i18n + ), + }; } + + const attachments = this.get('attachments') || []; + if (this.isTapToView()) { if (this.isErased()) { - return i18n('message--getDescription--disappearing-media'); + return { text: i18n('message--getDescription--disappearing-media') }; } - const attachments = this.get('attachments'); - if (!attachments || !attachments[0]) { - return i18n('mediaMessage'); + if (Attachment.isImage(attachments)) { + return { + text: i18n('message--getDescription--disappearing-photo'), + emoji: '📷', + }; + } else if (Attachment.isVideo(attachments)) { + return { + text: i18n('message--getDescription--disappearing-video'), + emoji: '🎥', + }; } - - const { contentType } = attachments[0]; - if (GoogleChrome.isImageTypeSupported(contentType)) { - return i18n('message--getDescription--disappearing-photo'); - } else if (GoogleChrome.isVideoTypeSupported(contentType)) { - return i18n('message--getDescription--disappearing-video'); - } - - return i18n('mediaMessage'); + // There should be an image or video attachment, but we have a fallback just in + // case. + return { text: i18n('mediaMessage'), emoji: '📎' }; } if (this.isGroupUpdate()) { @@ -920,15 +933,17 @@ const messages = []; if (groupUpdate.left === 'You') { - return i18n('youLeftTheGroup'); + return { text: i18n('youLeftTheGroup') }; } else if (groupUpdate.left) { - return i18n('leftTheGroup', [ - this.getNameForNumber(groupUpdate.left), - ]); + return { + text: i18n('leftTheGroup', [ + this.getNameForNumber(groupUpdate.left), + ]), + }; } if (!fromContact) { - return ''; + return { text: '' }; } if (fromContact.isMe()) { @@ -979,56 +994,123 @@ messages.push(i18n('updatedGroupAvatar')); } - return messages.join(' '); + return { text: messages.join(' ') }; } if (this.isEndSession()) { - return i18n('sessionEnded'); + return { text: i18n('sessionEnded') }; } if (this.isIncoming() && this.hasErrors()) { - return i18n('incomingError'); + return { text: i18n('incomingError') }; } - return this.get('body'); - }, - getNotificationText() { - const description = this.getDescription(); - if (description) { - return description; + + const body = (this.get('body') || '').trim(); + + if (attachments.length) { + // This should never happen but we want to be extra-careful. + const attachment = attachments[0] || {}; + const { contentType } = attachment; + + if (contentType === MIME.IMAGE_GIF) { + return { + text: body || i18n('message--getNotificationText--gif'), + emoji: '🎡', + }; + } else if (Attachment.isImage(attachments)) { + return { + text: body || i18n('message--getNotificationText--photo'), + emoji: '📷', + }; + } else if (Attachment.isVideo(attachments)) { + return { + text: body || i18n('message--getNotificationText--video'), + emoji: '🎥', + }; + } else if (Attachment.isVoiceMessage(attachment)) { + return { + text: body || i18n('message--getNotificationText--voice-message'), + emoji: '🎤', + }; + } else if (Attachment.isAudio(attachments)) { + return { + text: body || i18n('message--getNotificationText--audio-message'), + emoji: '🔈', + }; + } + return { + text: body || i18n('message--getNotificationText--file'), + emoji: '📎', + }; } - if (this.get('attachments').length > 0) { - return i18n('mediaMessage'); - } - if (this.get('sticker')) { - return i18n('message--getNotificationText--stickers'); - } - if (this.isCallHistory()) { - return window.Signal.Components.getCallingNotificationText( - this.get('callHistoryDetails'), - window.i18n + + const stickerData = this.get('sticker'); + if (stickerData) { + const sticker = Signal.Stickers.getSticker( + stickerData.packId, + stickerData.stickerId ); + const { emoji } = sticker || {}; + if (!emoji) { + window.log.warn('Unable to get emoji for sticker'); + } + return { + text: i18n('message--getNotificationText--stickers'), + emoji, + }; + } + + if (this.isCallHistory()) { + return { + text: window.Signal.Components.getCallingNotificationText( + this.get('callHistoryDetails'), + window.i18n + ), + }; } if (this.isExpirationTimerUpdate()) { const { expireTimer } = this.get('expirationTimerUpdate'); if (!expireTimer) { - return i18n('disappearingMessagesDisabled'); + return { text: i18n('disappearingMessagesDisabled') }; } return i18n('timerSetTo', [ Whisper.ExpirationTimerOptions.getAbbreviated(expireTimer || 0), ]); } + if (this.isKeyChange()) { const identifier = this.get('key_changed'); const conversation = this.findContact(identifier); - return i18n('safetyNumberChangedGroup', [ - conversation ? conversation.getTitle() : null, - ]); + return { + text: i18n('safetyNumberChangedGroup', [ + conversation ? conversation.getTitle() : null, + ]), + }; } const contacts = this.get('contact'); if (contacts && contacts.length) { - return Contact.getName(contacts[0]); + return { text: Contact.getName(contacts[0]), emoji: '👤' }; } - return ''; + if (body) { + return { text: body }; + } + + return { text: '' }; + }, + + getNotificationText() /* : string */ { + const { text, emoji } = this.getNotificationData(); + + // Linux emoji support is mixed, so we disable it. (Note that this doesn't touch + // the `text`, which can contain emoji.) + const shouldIncludeEmoji = Boolean(emoji) && !Signal.OS.isLinux(); + if (shouldIncludeEmoji) { + return i18n('message--getNotificationText--text-with-emoji', { + text, + emoji, + }); + } + return text; }, // General diff --git a/js/modules/types/attachment.js b/js/modules/types/attachment.js index 09b41041663..1235582b5de 100644 --- a/js/modules/types/attachment.js +++ b/js/modules/types/attachment.js @@ -211,6 +211,9 @@ exports.deleteData = deleteOnDisk => { }; }; +exports.isImage = AttachmentTS.isImage; +exports.isVideo = AttachmentTS.isVideo; +exports.isAudio = AttachmentTS.isAudio; exports.isVoiceMessage = AttachmentTS.isVoiceMessage; exports.save = AttachmentTS.save; diff --git a/test/models/messages_test.js b/test/models/messages_test.js index 2931c97db43..78faf35becd 100644 --- a/test/models/messages_test.js +++ b/test/models/messages_test.js @@ -1,4 +1,4 @@ -/* global ConversationController, i18n, Whisper, textsecure */ +/* global ConversationController, i18n, Signal, Whisper, textsecure */ 'use strict'; @@ -14,35 +14,494 @@ const source = '+1 415-555-5555'; const me = '+14155555556'; const ourUuid = window.getGuid(); -describe('MessageCollection', () => { - before(async () => { - await clearDatabase(); - ConversationController.reset(); - await ConversationController.load(); - textsecure.storage.put('number_id', `${me}.2`); - textsecure.storage.put('uuid_id', `${ourUuid}.2`); - }); - after(() => { - textsecure.storage.put('number_id', null); - textsecure.storage.put('uuid_id', null); - return clearDatabase(); - }); +before(async () => { + await clearDatabase(); + ConversationController.reset(); + await ConversationController.load(); + textsecure.storage.put('number_id', `${me}.2`); + textsecure.storage.put('uuid_id', `${ourUuid}.2`); +}); +after(() => { + textsecure.storage.put('number_id', null); + textsecure.storage.put('uuid_id', null); + return clearDatabase(); +}); - it('gets outgoing contact', () => { +describe('Message', () => { + function createMessage(attrs) { const messages = new Whisper.MessageCollection(); - const message = messages.add(attributes); - message.getContact(); - }); + return messages.add(attrs); + } - it('gets incoming contact', () => { - const messages = new Whisper.MessageCollection(); - const message = messages.add({ - type: 'incoming', - source, + describe('getContact', () => { + it('gets outgoing contact', () => { + const messages = new Whisper.MessageCollection(); + const message = messages.add(attributes); + message.getContact(); + }); + + it('gets incoming contact', () => { + const messages = new Whisper.MessageCollection(); + const message = messages.add({ + type: 'incoming', + source, + }); + message.getContact(); }); - message.getContact(); }); + describe('isIncoming', () => { + it('checks if is incoming message', () => { + const messages = new Whisper.MessageCollection(); + let message = messages.add(attributes); + assert.notOk(message.isIncoming()); + message = messages.add({ type: 'incoming' }); + assert.ok(message.isIncoming()); + }); + }); + + describe('isOutgoing', () => { + it('checks if is outgoing message', () => { + const messages = new Whisper.MessageCollection(); + let message = messages.add(attributes); + assert.ok(message.isOutgoing()); + message = messages.add({ type: 'incoming' }); + assert.notOk(message.isOutgoing()); + }); + }); + + describe('isGroupUpdate', () => { + it('checks if is group update', () => { + const messages = new Whisper.MessageCollection(); + let message = messages.add(attributes); + assert.notOk(message.isGroupUpdate()); + + message = messages.add({ group_update: true }); + assert.ok(message.isGroupUpdate()); + }); + }); + + // Note that some of this method's behavior is untested: + // - Call history + // - Contacts + // - Expiration timer updates + // - Key changes + // - Profile changes + // - Stickers + describe('getNotificationData', () => { + it('handles unsupported messages', () => { + assert.deepEqual( + createMessage({ + supportedVersionAtReceive: 0, + requiredProtocolVersion: Infinity, + }).getNotificationData(), + { text: 'Unsupported message' } + ); + }); + + it('handles erased tap-to-view messages', () => { + assert.deepEqual( + createMessage({ + isViewOnce: true, + isErased: true, + }).getNotificationData(), + { text: 'View-once Media' } + ); + }); + + it('handles tap-to-view photos', () => { + assert.deepEqual( + createMessage({ + isViewOnce: true, + isErased: false, + attachments: [ + { + contentType: 'image/png', + }, + ], + }).getNotificationData(), + { text: 'View-once Photo', emoji: '📷' } + ); + }); + + it('handles tap-to-view videos', () => { + assert.deepEqual( + createMessage({ + isViewOnce: true, + isErased: false, + attachments: [ + { + contentType: 'video/mp4', + }, + ], + }).getNotificationData(), + { text: 'View-once Video', emoji: '🎥' } + ); + }); + + it('handles non-media tap-to-view file types', () => { + assert.deepEqual( + createMessage({ + isViewOnce: true, + isErased: false, + attachments: [ + { + contentType: 'text/plain', + }, + ], + }).getNotificationData(), + { text: 'Media Message', emoji: '📎' } + ); + }); + + it('handles group updates where you left the group', () => { + assert.deepEqual( + createMessage({ + group_update: { + left: 'You', + }, + }).getNotificationData(), + { text: 'You left the group.' } + ); + }); + + it('handles group updates where someone left the group', () => { + assert.deepEqual( + createMessage({ + type: 'incoming', + source, + group_update: { + left: 'Alice', + }, + }).getNotificationData(), + { text: 'Alice left the group.' } + ); + }); + + it('handles empty group updates with a generic message', () => { + assert.deepEqual( + createMessage({ + type: 'incoming', + source: 'Alice', + group_update: {}, + }).getNotificationData(), + { text: 'Alice updated the group.' } + ); + }); + + it('handles group name updates by you', () => { + assert.deepEqual( + createMessage({ + type: 'incoming', + source: me, + group_update: { name: 'blerg' }, + }).getNotificationData(), + { + text: "You updated the group. Group name is now 'blerg'.", + } + ); + }); + + it('handles group name updates by someone else', () => { + assert.deepEqual( + createMessage({ + type: 'incoming', + source, + group_update: { name: 'blerg' }, + }).getNotificationData(), + { + text: "+1 415-555-5555 updated the group. Group name is now 'blerg'.", + } + ); + }); + + it('handles group avatar updates', () => { + assert.deepEqual( + createMessage({ + type: 'incoming', + source, + group_update: { avatarUpdated: true }, + }).getNotificationData(), + { + text: '+1 415-555-5555 updated the group. Group avatar was updated.', + } + ); + }); + + it('handles you joining the group', () => { + assert.deepEqual( + createMessage({ + type: 'incoming', + source, + group_update: { joined: [me] }, + }).getNotificationData(), + { + text: '+1 415-555-5555 updated the group. You joined the group.', + } + ); + }); + + it('handles someone else joining the group', () => { + assert.deepEqual( + createMessage({ + type: 'incoming', + source, + group_update: { joined: ['Bob'] }, + }).getNotificationData(), + { + text: '+1 415-555-5555 updated the group. Bob joined the group.', + } + ); + }); + + it('handles multiple people joining the group', () => { + assert.deepEqual( + createMessage({ + type: 'incoming', + source, + group_update: { joined: ['Bob', 'Alice', 'Eve'] }, + }).getNotificationData(), + { + text: + '+1 415-555-5555 updated the group. Bob, Alice, Eve joined the group.', + } + ); + }); + + it('handles multiple people joining the group, including you', () => { + assert.deepEqual( + createMessage({ + type: 'incoming', + source, + group_update: { joined: ['Bob', me, 'Alice', 'Eve'] }, + }).getNotificationData(), + { + text: + '+1 415-555-5555 updated the group. Bob, Alice, Eve joined the group. You joined the group.', + } + ); + }); + + it('handles multiple changes to group properties', () => { + assert.deepEqual( + createMessage({ + type: 'incoming', + source, + group_update: { joined: ['Bob'], name: 'blerg' }, + }).getNotificationData(), + { + text: + "+1 415-555-5555 updated the group. Bob joined the group. Group name is now 'blerg'.", + } + ); + }); + + it('handles a session ending', () => { + assert.deepEqual( + createMessage({ + type: 'incoming', + source, + flags: true, + }).getNotificationData(), + { text: i18n('sessionEnded') } + ); + }); + + it('handles incoming message errors', () => { + assert.deepEqual( + createMessage({ + type: 'incoming', + source, + errors: [{}], + }).getNotificationData(), + { text: i18n('incomingError') } + ); + }); + + const attachmentTestCases = [ + { + title: 'GIF', + attachment: { + contentType: 'image/gif', + }, + expectedText: 'GIF', + expectedEmoji: '🎡', + }, + { + title: 'photo', + attachment: { + contentType: 'image/png', + }, + expectedText: 'Photo', + expectedEmoji: '📷', + }, + { + title: 'video', + attachment: { + contentType: 'video/mp4', + }, + expectedText: 'Video', + expectedEmoji: '🎥', + }, + { + title: 'voice message', + attachment: { + contentType: 'audio/ogg', + flags: textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE, + }, + expectedText: 'Voice Message', + expectedEmoji: '🎤', + }, + { + title: 'audio message', + attachment: { + contentType: 'audio/ogg', + fileName: 'audio.ogg', + }, + expectedText: 'Audio Message', + expectedEmoji: '🔈', + }, + { + title: 'plain text', + attachment: { + contentType: 'text/plain', + }, + expectedText: 'File', + expectedEmoji: '📎', + }, + { + title: 'unspecified-type', + attachment: { + contentType: null, + }, + expectedText: 'File', + expectedEmoji: '📎', + }, + ]; + attachmentTestCases.forEach( + ({ title, attachment, expectedText, expectedEmoji }) => { + it(`handles single ${title} attachments`, () => { + assert.deepEqual( + createMessage({ + type: 'incoming', + source, + attachments: [attachment], + }).getNotificationData(), + { text: expectedText, emoji: expectedEmoji } + ); + }); + + it(`handles multiple attachments where the first is a ${title}`, () => { + assert.deepEqual( + createMessage({ + type: 'incoming', + source, + attachments: [ + attachment, + { + contentType: 'text/html', + }, + ], + }).getNotificationData(), + { text: expectedText, emoji: expectedEmoji } + ); + }); + + it(`respects the caption for ${title} attachments`, () => { + assert.deepEqual( + createMessage({ + type: 'incoming', + source, + attachments: [attachment], + body: 'hello world', + }).getNotificationData(), + { text: 'hello world', emoji: expectedEmoji } + ); + }); + } + ); + + it('handles a "plain" message', () => { + assert.deepEqual( + createMessage({ + type: 'incoming', + source, + body: 'hello world', + }).getNotificationData(), + { text: 'hello world' } + ); + }); + }); + + describe('getNotificationText', () => { + // Sinon isn't included in the Electron test setup so we do this. + beforeEach(function beforeEach() { + this.oldIsLinux = Signal.OS.isLinux; + }); + + afterEach(function afterEach() { + Signal.OS.isLinux = this.oldIsLinux; + }); + + it("returns a notification's text", () => { + assert.strictEqual( + createMessage({ + type: 'incoming', + source, + body: 'hello world', + }).getNotificationText(), + 'hello world' + ); + }); + + it("shows a notification's emoji on non-Linux", () => { + Signal.OS.isLinux = () => false; + + assert.strictEqual( + createMessage({ + type: 'incoming', + source, + attachments: [ + { + contentType: 'image/png', + }, + ], + }).getNotificationText(), + '📷 Photo' + ); + }); + + it('hides emoji on Linux', () => { + Signal.OS.isLinux = () => true; + + assert.strictEqual( + createMessage({ + type: 'incoming', + source, + attachments: [ + { + contentType: 'image/png', + }, + ], + }).getNotificationText(), + 'Photo' + ); + }); + }); + + describe('isEndSession', () => { + it('checks if it is end of the session', () => { + const messages = new Whisper.MessageCollection(); + let message = messages.add(attributes); + assert.notOk(message.isEndSession()); + + message = messages.add({ type: 'incoming', source, flags: true }); + assert.ok(message.isEndSession()); + }); + }); +}); + +describe('MessageCollection', () => { it('should be ordered oldest to newest', () => { const messages = new Whisper.MessageCollection(); // Timestamps @@ -61,173 +520,4 @@ describe('MessageCollection', () => { // Compare timestamps assert(firstTimestamp < secondTimestamp); }); - - it('checks if is incoming message', () => { - const messages = new Whisper.MessageCollection(); - let message = messages.add(attributes); - assert.notOk(message.isIncoming()); - message = messages.add({ type: 'incoming' }); - assert.ok(message.isIncoming()); - }); - - it('checks if is outgoing message', () => { - const messages = new Whisper.MessageCollection(); - let message = messages.add(attributes); - assert.ok(message.isOutgoing()); - message = messages.add({ type: 'incoming' }); - assert.notOk(message.isOutgoing()); - }); - - it('checks if is group update', () => { - const messages = new Whisper.MessageCollection(); - let message = messages.add(attributes); - assert.notOk(message.isGroupUpdate()); - - message = messages.add({ group_update: true }); - assert.ok(message.isGroupUpdate()); - }); - - it('returns an accurate description', () => { - const messages = new Whisper.MessageCollection(); - let message = messages.add(attributes); - - assert.equal( - message.getDescription(), - 'hi', - 'If no group updates or end session flags, return message body.' - ); - - message = messages.add({ - group_update: {}, - source: 'Alice', - type: 'incoming', - }); - assert.equal( - message.getDescription(), - 'Alice updated the group.', - 'Empty group updates - generic message.' - ); - - message = messages.add({ - type: 'incoming', - source, - group_update: { left: 'Alice' }, - }); - assert.equal( - message.getDescription(), - 'Alice left the group.', - 'Notes one person leaving the group.' - ); - - message = messages.add({ - type: 'incoming', - source: me, - group_update: { left: 'You' }, - }); - assert.equal( - message.getDescription(), - 'You left the group.', - 'Notes that you left the group.' - ); - - message = messages.add({ - type: 'incoming', - source, - group_update: { name: 'blerg' }, - }); - assert.equal( - message.getDescription(), - "+1 415-555-5555 updated the group. Group name is now 'blerg'.", - 'Returns sender and name change.' - ); - - message = messages.add({ - type: 'incoming', - source: me, - group_update: { name: 'blerg' }, - }); - assert.equal( - message.getDescription(), - "You updated the group. Group name is now 'blerg'.", - 'Includes "you" as sender along with group name change.' - ); - - message = messages.add({ - type: 'incoming', - source, - group_update: { avatarUpdated: true }, - }); - assert.equal( - message.getDescription(), - '+1 415-555-5555 updated the group. Group avatar was updated.', - 'Includes sender and avatar update.' - ); - - message = messages.add({ - type: 'incoming', - source, - group_update: { joined: [me] }, - }); - assert.equal( - message.getDescription(), - '+1 415-555-5555 updated the group. You joined the group.', - 'Includes both sender and person added with join.' - ); - - message = messages.add({ - type: 'incoming', - source, - group_update: { joined: ['Bob'] }, - }); - assert.equal( - message.getDescription(), - '+1 415-555-5555 updated the group. Bob joined the group.', - 'Returns a single notice if only group_updates.joined changes.' - ); - - message = messages.add({ - type: 'incoming', - source, - group_update: { joined: ['Bob', 'Alice', 'Eve'] }, - }); - assert.equal( - message.getDescription(), - '+1 415-555-5555 updated the group. Bob, Alice, Eve joined the group.', - 'Notes when >1 person joins the group.' - ); - - message = messages.add({ - type: 'incoming', - source, - group_update: { joined: ['Bob', me, 'Alice', 'Eve'] }, - }); - assert.equal( - message.getDescription(), - '+1 415-555-5555 updated the group. Bob, Alice, Eve joined the group. You joined the group.', - 'Splits "You" out when multiple people are added along with you.' - ); - - message = messages.add({ - type: 'incoming', - source, - group_update: { joined: ['Bob'], name: 'blerg' }, - }); - assert.equal( - message.getDescription(), - "+1 415-555-5555 updated the group. Bob joined the group. Group name is now 'blerg'.", - 'Notes when there are multiple changes to group_updates properties.' - ); - - message = messages.add({ type: 'incoming', source, flags: true }); - assert.equal(message.getDescription(), i18n('sessionEnded')); - }); - - it('checks if it is end of the session', () => { - const messages = new Whisper.MessageCollection(); - let message = messages.add(attributes); - assert.notOk(message.isEndSession()); - - message = messages.add({ type: 'incoming', source, flags: true }); - assert.ok(message.isEndSession()); - }); });