diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 4cf66a8f7dc0..c579a4a26dad 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -1172,7 +1172,8 @@ @include color-svg('../images/double-check.svg', $color-white-alpha-80); } } -.module-message__metadata__status-icon--read { +.module-message__metadata__status-icon--read, +.module-message__metadata__status-icon--viewed { width: 18px; @include light-theme { diff --git a/stylesheets/components/MessageAudio.scss b/stylesheets/components/MessageAudio.scss index b0d2c53bfaf4..49d1887a2725 100644 --- a/stylesheets/components/MessageAudio.scss +++ b/stylesheets/components/MessageAudio.scss @@ -188,22 +188,59 @@ $audio-attachment-button-margin-small: 4px; } .module-message__audio-attachment__countdown { - flex-shrink: 1; - - user-select: none; + $unplayed-dot-margin: 6px; @include font-caption; + align-items: center; + display: flex; + flex-shrink: 1; + user-select: none; + + &:after { + content: ''; + display: block; + width: 6px; + height: 6px; + border-radius: 100%; + transition: background 100ms ease-out; + } + + &--played:after { + background: transparent; + } .module-message__audio-attachment--incoming & { + flex-direction: row-reverse; + + &:after { + margin-right: $unplayed-dot-margin; + } + @include light-theme { - color: $color-black-alpha-60; + $color: $color-black-alpha-60; + color: $color; + &--unplayed:after { + background: $color; + } } @include dark-theme { - color: $color-white-alpha-80; + $color: $color-white-alpha-80; + color: $color; + &--unplayed:after { + background: $color; + } } } .module-message__audio-attachment--outgoing & { color: $color-white-alpha-80; + + &:after { + margin-left: $unplayed-dot-margin; + } + + &--unplayed:after { + background: $color-white-alpha-80; + } } } diff --git a/ts/background.ts b/ts/background.ts index 7110b0454de4..aae32ce35a38 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -49,6 +49,7 @@ import { MessageEvent, MessageEventData, ReadEvent, + ViewEvent, ConfigurationEvent, ViewOnceOpenSyncEvent, MessageRequestResponseEvent, @@ -2186,6 +2187,10 @@ export async function startApp(): Promise { 'read', queuedEventListener(onReadReceipt) ); + messageReceiver.addEventListener( + 'view', + queuedEventListener(onViewReceipt) + ); messageReceiver.addEventListener( 'verified', queuedEventListener(onVerified) @@ -3711,14 +3716,38 @@ export async function startApp(): Promise { MessageRequests.getSingleton().onResponse(sync); } - function onReadReceipt(ev: ReadEvent) { + function onReadReceipt(event: Readonly) { + onReadOrViewReceipt({ + logTitle: 'read receipt', + event, + type: MessageReceiptType.Read, + }); + } + + function onViewReceipt(event: Readonly): void { + onReadOrViewReceipt({ + logTitle: 'view receipt', + event, + type: MessageReceiptType.View, + }); + } + + function onReadOrViewReceipt({ + event, + logTitle, + type, + }: Readonly<{ + event: ReadEvent | ViewEvent; + logTitle: string; + type: MessageReceiptType.Read | MessageReceiptType.View; + }>): void { const { envelopeTimestamp, timestamp, source, sourceUuid, sourceDevice, - } = ev.read; + } = event.receipt; const sourceConversationId = window.ConversationController.ensureContactIds( { e164: source, @@ -3727,7 +3756,7 @@ export async function startApp(): Promise { } ); window.log.info( - 'read receipt', + logTitle, source, sourceUuid, sourceDevice, @@ -3737,7 +3766,7 @@ export async function startApp(): Promise { timestamp ); - ev.confirm(); + event.confirm(); if (!window.storage.get('read-receipt-setting') || !sourceConversationId) { return; @@ -3748,7 +3777,7 @@ export async function startApp(): Promise { receiptTimestamp: envelopeTimestamp, sourceConversationId, sourceDevice, - type: MessageReceiptType.Read, + type, }); // Note: We do not wait for completion here diff --git a/ts/components/conversation/Message.stories.tsx b/ts/components/conversation/Message.stories.tsx index 5adc423f94c8..cdd108322bb2 100644 --- a/ts/components/conversation/Message.stories.tsx +++ b/ts/components/conversation/Message.stories.tsx @@ -880,6 +880,21 @@ story.add('Audio', () => { return renderBothDirections(props); }); +story.add('Audio (played)', () => { + const props = createProps({ + attachments: [ + { + contentType: AUDIO_MP3, + fileName: 'incompetech-com-Agnus-Dei-X.mp3', + url: '/fixtures/incompetech-com-Agnus-Dei-X.mp3', + }, + ], + status: 'viewed', + }); + + return renderBothDirections(props); +}); + story.add('Long Audio', () => { const props = createProps({ attachments: [ diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 58e75f5218d2..e23199543ec0 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -52,6 +52,7 @@ import { ContactType } from '../../types/Contact'; import { getIncrement } from '../../util/timer'; import { isFileDangerous } from '../../util/isFileDangerous'; +import { missingCaseError } from '../../util/missingCaseError'; import { BodyRangesType, LocalizerType, ThemeType } from '../../types/Util'; import { ContactNameColorType, @@ -80,6 +81,7 @@ export const MessageStatuses = [ 'read', 'sending', 'sent', + 'viewed', ] as const; export type MessageStatusType = typeof MessageStatuses[number]; @@ -99,6 +101,7 @@ export type AudioAttachmentProps = { expirationLength?: number; expirationTimestamp?: number; id: string; + played: boolean; showMessageDetail: (id: string) => void; status?: MessageStatusType; textPending?: boolean; @@ -764,6 +767,21 @@ export class Message extends React.Component { } } if (isAudio(attachments)) { + let played: boolean; + switch (direction) { + case 'outgoing': + played = status === 'viewed'; + break; + case 'incoming': + // Not implemented yet. See DESKTOP-1855. + played = true; + break; + default: + window.log.error(missingCaseError(direction)); + played = false; + break; + } + return renderAudioAttachment({ i18n, buttonRef: this.audioButtonRef, @@ -777,6 +795,7 @@ export class Message extends React.Component { expirationLength, expirationTimestamp, id, + played, showMessageDetail, status, textPending, diff --git a/ts/components/conversation/MessageAudio.tsx b/ts/components/conversation/MessageAudio.tsx index ee76ced73295..df8ee9144a4b 100644 --- a/ts/components/conversation/MessageAudio.tsx +++ b/ts/components/conversation/MessageAudio.tsx @@ -25,6 +25,7 @@ export type Props = { expirationLength?: number; expirationTimestamp?: number; id: string; + played: boolean; showMessageDetail: (id: string) => void; status?: MessageStatusType; textPending?: boolean; @@ -153,6 +154,7 @@ export const MessageAudio: React.FC = (props: Props) => { expirationLength, expirationTimestamp, id, + played, showMessageDetail, status, textPending, @@ -531,7 +533,14 @@ export const MessageAudio: React.FC = (props: Props) => { timestamp={timestamp} /> )} -
{timeToText(countDown)}
+
+ {timeToText(countDown)} +
); diff --git a/ts/messageModifiers/MessageReceipts.ts b/ts/messageModifiers/MessageReceipts.ts index e2954b32b8cf..b7244a1aa07e 100644 --- a/ts/messageModifiers/MessageReceipts.ts +++ b/ts/messageModifiers/MessageReceipts.ts @@ -21,6 +21,7 @@ const { deleteSentProtoRecipient } = dataInterface; export enum MessageReceiptType { Delivery = 'Delivery', Read = 'Read', + View = 'View', } type MessageReceiptAttributesType = { @@ -151,6 +152,9 @@ export class MessageReceipts extends Collection { case MessageReceiptType.Read: sendActionType = SendActionType.GotReadReceipt; break; + case MessageReceiptType.View: + sendActionType = SendActionType.GotViewedReceipt; + break; default: throw missingCaseError(type); } diff --git a/ts/messages/MessageSendState.ts b/ts/messages/MessageSendState.ts index 391b56de1c10..7909c04932d7 100644 --- a/ts/messages/MessageSendState.ts +++ b/ts/messages/MessageSendState.ts @@ -51,6 +51,8 @@ const STATUS_NUMBERS: Record = { export const maxStatus = (a: SendStatus, b: SendStatus): SendStatus => STATUS_NUMBERS[a] > STATUS_NUMBERS[b] ? a : b; +export const isViewed = (status: SendStatus): boolean => + status === SendStatus.Viewed; export const isRead = (status: SendStatus): boolean => STATUS_NUMBERS[status] >= STATUS_NUMBERS[SendStatus.Read]; export const isDelivered = (status: SendStatus): boolean => diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index e85630b2719b..118ba75d640e 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -37,7 +37,8 @@ export type LastMessageStatus = | 'sending' | 'sent' | 'delivered' - | 'read'; + | 'read' + | 'viewed'; type TaskResultType = any; diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 84460acd4299..ff43c8f473be 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -3206,6 +3206,9 @@ export class MessageModel extends window.Backbone.Model { case MessageReceiptType.Read: sendActionType = SendActionType.GotReadReceipt; break; + case MessageReceiptType.View: + sendActionType = SendActionType.GotViewedReceipt; + break; default: throw missingCaseError(receiptType); } diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index 1511c0ff021b..e0697d243d05 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -52,6 +52,7 @@ import { isMessageJustForMe, isRead, isSent, + isViewed, maxStatus, someSendStatus, } from '../../messages/MessageSendState'; @@ -914,7 +915,7 @@ export function getMessagePropStatus( if (hasErrors(message)) { return sent ? 'partial-sent' : 'error'; } - return sent ? 'read' : 'sending'; + return sent ? 'viewed' : 'sending'; } const sendStates = Object.values( @@ -928,6 +929,9 @@ export function getMessagePropStatus( if (hasErrors(message)) { return isSent(highestSuccessfulStatus) ? 'partial-sent' : 'error'; } + if (isViewed(highestSuccessfulStatus)) { + return 'viewed'; + } if (isRead(highestSuccessfulStatus)) { return 'read'; } diff --git a/ts/state/smart/MessageAudio.tsx b/ts/state/smart/MessageAudio.tsx index d561b019525e..26d5b8984885 100644 --- a/ts/state/smart/MessageAudio.tsx +++ b/ts/state/smart/MessageAudio.tsx @@ -28,6 +28,7 @@ export type Props = { expirationLength?: number; expirationTimestamp?: number; id: string; + played: boolean; showMessageDetail: (id: string) => void; status?: MessageStatusType; textPending?: boolean; diff --git a/ts/test-both/messages/MessageSendState_test.ts b/ts/test-both/messages/MessageSendState_test.ts index afd70d40372d..6546c52241c6 100644 --- a/ts/test-both/messages/MessageSendState_test.ts +++ b/ts/test-both/messages/MessageSendState_test.ts @@ -15,6 +15,7 @@ import { isMessageJustForMe, isRead, isSent, + isViewed, maxStatus, sendStateReducer, someSendStatus, @@ -49,6 +50,20 @@ describe('message send state utilities', () => { }); }); + describe('isViewed', () => { + it('returns true for viewed statuses', () => { + assert.isTrue(isViewed(SendStatus.Viewed)); + }); + + it('returns false for non-viewed statuses', () => { + assert.isFalse(isViewed(SendStatus.Read)); + assert.isFalse(isViewed(SendStatus.Delivered)); + assert.isFalse(isViewed(SendStatus.Sent)); + assert.isFalse(isViewed(SendStatus.Pending)); + assert.isFalse(isViewed(SendStatus.Failed)); + }); + }); + describe('isRead', () => { it('returns true for read and viewed statuses', () => { assert.isTrue(isRead(SendStatus.Read)); diff --git a/ts/test-electron/state/selectors/messages_test.ts b/ts/test-electron/state/selectors/messages_test.ts index f9a64d28f8d2..5b0456c9985f 100644 --- a/ts/test-electron/state/selectors/messages_test.ts +++ b/ts/test-electron/state/selectors/messages_test.ts @@ -216,7 +216,7 @@ describe('state/selectors/messages', () => { ); }); - it('returns "read" if the message is just for you and has been sent', () => { + it('returns "viewed" if the message is just for you and has been sent', () => { const message = createMessage({ sendStateByConversationId: { [ourConversationId]: { @@ -228,23 +228,19 @@ describe('state/selectors/messages', () => { assert.strictEqual( getMessagePropStatus(message, ourConversationId), - 'read' + 'viewed' ); }); - it('returns "read" if the message was read by at least one person', () => { - const readMessage = createMessage({ + it('returns "viewed" if the message was viewed by at least one person', () => { + const message = createMessage({ sendStateByConversationId: { [ourConversationId]: { status: SendStatus.Sent, updatedAt: Date.now(), }, [uuid()]: { - status: SendStatus.Pending, - updatedAt: Date.now(), - }, - [uuid()]: { - status: SendStatus.Delivered, + status: SendStatus.Viewed, updatedAt: Date.now(), }, [uuid()]: { @@ -254,20 +250,26 @@ describe('state/selectors/messages', () => { }, }); assert.strictEqual( - getMessagePropStatus(readMessage, ourConversationId), - 'read' + getMessagePropStatus(message, ourConversationId), + 'viewed' ); + }); - const viewedMessage = createMessage({ + it('returns "read" if the message was read by at least one person', () => { + const message = createMessage({ sendStateByConversationId: { + [ourConversationId]: { + status: SendStatus.Sent, + updatedAt: Date.now(), + }, [uuid()]: { - status: SendStatus.Viewed, + status: SendStatus.Read, updatedAt: Date.now(), }, }, }); assert.strictEqual( - getMessagePropStatus(viewedMessage, ourConversationId), + getMessagePropStatus(message, ourConversationId), 'read' ); }); diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index d4a0fc2bcbdd..957c10d42667 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -86,6 +86,7 @@ import { MessageEvent, RetryRequestEvent, ReadEvent, + ViewEvent, ConfigurationEvent, ViewOnceOpenSyncEvent, MessageRequestResponseEvent, @@ -480,6 +481,8 @@ export default class MessageReceiver extends EventTarget { public addEventListener(name: 'read', handler: (ev: ReadEvent) => void): void; + public addEventListener(name: 'view', handler: (ev: ViewEvent) => void): void; + public addEventListener( name: 'configuration', handler: (ev: ConfigurationEvent) => void @@ -2174,38 +2177,40 @@ export default class MessageReceiver extends EventTarget { envelope: ProcessedEnvelope, receiptMessage: Proto.IReceiptMessage ): Promise { - const results = []; strictAssert(receiptMessage.timestamp, 'Receipt message without timestamp'); - if (receiptMessage.type === Proto.ReceiptMessage.Type.DELIVERY) { - for (let i = 0; i < receiptMessage.timestamp.length; i += 1) { - const ev = new DeliveryEvent( - { - timestamp: normalizeNumber(receiptMessage.timestamp[i]), - envelopeTimestamp: envelope.timestamp, - source: envelope.source, - sourceUuid: envelope.sourceUuid, - sourceDevice: envelope.sourceDevice, - }, - this.removeFromCache.bind(this, envelope) - ); - results.push(this.dispatchAndWait(ev)); - } - } else if (receiptMessage.type === Proto.ReceiptMessage.Type.READ) { - for (let i = 0; i < receiptMessage.timestamp.length; i += 1) { - const ev = new ReadEvent( - { - timestamp: normalizeNumber(receiptMessage.timestamp[i]), - envelopeTimestamp: envelope.timestamp, - source: envelope.source, - sourceUuid: envelope.sourceUuid, - sourceDevice: envelope.sourceDevice, - }, - this.removeFromCache.bind(this, envelope) - ); - results.push(this.dispatchAndWait(ev)); - } + + let EventClass: typeof DeliveryEvent | typeof ReadEvent | typeof ViewEvent; + switch (receiptMessage.type) { + case Proto.ReceiptMessage.Type.DELIVERY: + EventClass = DeliveryEvent; + break; + case Proto.ReceiptMessage.Type.READ: + EventClass = ReadEvent; + break; + case Proto.ReceiptMessage.Type.VIEWED: + EventClass = ViewEvent; + break; + default: + // This can happen if we get a receipt type we don't know about yet, which + // is totally fine. + return; } - await Promise.all(results); + + await Promise.all( + receiptMessage.timestamp.map(async rawTimestamp => { + const ev = new EventClass( + { + timestamp: normalizeNumber(rawTimestamp), + envelopeTimestamp: envelope.timestamp, + source: envelope.source, + sourceUuid: envelope.sourceUuid, + sourceDevice: envelope.sourceDevice, + }, + this.removeFromCache.bind(this, envelope) + ); + await this.dispatchAndWait(ev); + }) + ); } private async handleTypingMessage( diff --git a/ts/textsecure/messageReceiverEvents.ts b/ts/textsecure/messageReceiverEvents.ts index 1ab755a51975..0524a8a2f60a 100644 --- a/ts/textsecure/messageReceiverEvents.ts +++ b/ts/textsecure/messageReceiverEvents.ts @@ -222,7 +222,7 @@ export class MessageEvent extends ConfirmableEvent { } } -export type ReadEventData = Readonly<{ +export type ReadOrViewEventData = Readonly<{ timestamp: number; envelopeTimestamp: number; source?: string; @@ -231,11 +231,23 @@ export type ReadEventData = Readonly<{ }>; export class ReadEvent extends ConfirmableEvent { - constructor(public readonly read: ReadEventData, confirm: ConfirmCallback) { + constructor( + public readonly receipt: ReadOrViewEventData, + confirm: ConfirmCallback + ) { super('read', confirm); } } +export class ViewEvent extends ConfirmableEvent { + constructor( + public readonly receipt: ReadOrViewEventData, + confirm: ConfirmCallback + ) { + super('view', confirm); + } +} + export class ConfigurationEvent extends ConfirmableEvent { constructor( public readonly configuration: Proto.SyncMessage.IConfiguration,