Show "unplayed" dot on outgoing audio messages

This commit is contained in:
Evan Hahn 2021-07-27 10:42:25 -05:00 committed by GitHub
parent b73c029d5f
commit 14929fb408
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 219 additions and 60 deletions

View file

@ -1172,7 +1172,8 @@
@include color-svg('../images/double-check.svg', $color-white-alpha-80); @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; width: 18px;
@include light-theme { @include light-theme {

View file

@ -188,22 +188,59 @@ $audio-attachment-button-margin-small: 4px;
} }
.module-message__audio-attachment__countdown { .module-message__audio-attachment__countdown {
flex-shrink: 1; $unplayed-dot-margin: 6px;
user-select: none;
@include font-caption; @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 & { .module-message__audio-attachment--incoming & {
flex-direction: row-reverse;
&:after {
margin-right: $unplayed-dot-margin;
}
@include light-theme { @include light-theme {
color: $color-black-alpha-60; $color: $color-black-alpha-60;
color: $color;
&--unplayed:after {
background: $color;
}
} }
@include dark-theme { @include dark-theme {
color: $color-white-alpha-80; $color: $color-white-alpha-80;
color: $color;
&--unplayed:after {
background: $color;
}
} }
} }
.module-message__audio-attachment--outgoing & { .module-message__audio-attachment--outgoing & {
color: $color-white-alpha-80; color: $color-white-alpha-80;
&:after {
margin-left: $unplayed-dot-margin;
}
&--unplayed:after {
background: $color-white-alpha-80;
}
} }
} }

View file

@ -49,6 +49,7 @@ import {
MessageEvent, MessageEvent,
MessageEventData, MessageEventData,
ReadEvent, ReadEvent,
ViewEvent,
ConfigurationEvent, ConfigurationEvent,
ViewOnceOpenSyncEvent, ViewOnceOpenSyncEvent,
MessageRequestResponseEvent, MessageRequestResponseEvent,
@ -2186,6 +2187,10 @@ export async function startApp(): Promise<void> {
'read', 'read',
queuedEventListener(onReadReceipt) queuedEventListener(onReadReceipt)
); );
messageReceiver.addEventListener(
'view',
queuedEventListener(onViewReceipt)
);
messageReceiver.addEventListener( messageReceiver.addEventListener(
'verified', 'verified',
queuedEventListener(onVerified) queuedEventListener(onVerified)
@ -3711,14 +3716,38 @@ export async function startApp(): Promise<void> {
MessageRequests.getSingleton().onResponse(sync); MessageRequests.getSingleton().onResponse(sync);
} }
function onReadReceipt(ev: ReadEvent) { function onReadReceipt(event: Readonly<ReadEvent>) {
onReadOrViewReceipt({
logTitle: 'read receipt',
event,
type: MessageReceiptType.Read,
});
}
function onViewReceipt(event: Readonly<ViewEvent>): 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 { const {
envelopeTimestamp, envelopeTimestamp,
timestamp, timestamp,
source, source,
sourceUuid, sourceUuid,
sourceDevice, sourceDevice,
} = ev.read; } = event.receipt;
const sourceConversationId = window.ConversationController.ensureContactIds( const sourceConversationId = window.ConversationController.ensureContactIds(
{ {
e164: source, e164: source,
@ -3727,7 +3756,7 @@ export async function startApp(): Promise<void> {
} }
); );
window.log.info( window.log.info(
'read receipt', logTitle,
source, source,
sourceUuid, sourceUuid,
sourceDevice, sourceDevice,
@ -3737,7 +3766,7 @@ export async function startApp(): Promise<void> {
timestamp timestamp
); );
ev.confirm(); event.confirm();
if (!window.storage.get('read-receipt-setting') || !sourceConversationId) { if (!window.storage.get('read-receipt-setting') || !sourceConversationId) {
return; return;
@ -3748,7 +3777,7 @@ export async function startApp(): Promise<void> {
receiptTimestamp: envelopeTimestamp, receiptTimestamp: envelopeTimestamp,
sourceConversationId, sourceConversationId,
sourceDevice, sourceDevice,
type: MessageReceiptType.Read, type,
}); });
// Note: We do not wait for completion here // Note: We do not wait for completion here

View file

@ -880,6 +880,21 @@ story.add('Audio', () => {
return renderBothDirections(props); 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', () => { story.add('Long Audio', () => {
const props = createProps({ const props = createProps({
attachments: [ attachments: [

View file

@ -52,6 +52,7 @@ import { ContactType } from '../../types/Contact';
import { getIncrement } from '../../util/timer'; import { getIncrement } from '../../util/timer';
import { isFileDangerous } from '../../util/isFileDangerous'; import { isFileDangerous } from '../../util/isFileDangerous';
import { missingCaseError } from '../../util/missingCaseError';
import { BodyRangesType, LocalizerType, ThemeType } from '../../types/Util'; import { BodyRangesType, LocalizerType, ThemeType } from '../../types/Util';
import { import {
ContactNameColorType, ContactNameColorType,
@ -80,6 +81,7 @@ export const MessageStatuses = [
'read', 'read',
'sending', 'sending',
'sent', 'sent',
'viewed',
] as const; ] as const;
export type MessageStatusType = typeof MessageStatuses[number]; export type MessageStatusType = typeof MessageStatuses[number];
@ -99,6 +101,7 @@ export type AudioAttachmentProps = {
expirationLength?: number; expirationLength?: number;
expirationTimestamp?: number; expirationTimestamp?: number;
id: string; id: string;
played: boolean;
showMessageDetail: (id: string) => void; showMessageDetail: (id: string) => void;
status?: MessageStatusType; status?: MessageStatusType;
textPending?: boolean; textPending?: boolean;
@ -764,6 +767,21 @@ export class Message extends React.Component<Props, State> {
} }
} }
if (isAudio(attachments)) { 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({ return renderAudioAttachment({
i18n, i18n,
buttonRef: this.audioButtonRef, buttonRef: this.audioButtonRef,
@ -777,6 +795,7 @@ export class Message extends React.Component<Props, State> {
expirationLength, expirationLength,
expirationTimestamp, expirationTimestamp,
id, id,
played,
showMessageDetail, showMessageDetail,
status, status,
textPending, textPending,

View file

@ -25,6 +25,7 @@ export type Props = {
expirationLength?: number; expirationLength?: number;
expirationTimestamp?: number; expirationTimestamp?: number;
id: string; id: string;
played: boolean;
showMessageDetail: (id: string) => void; showMessageDetail: (id: string) => void;
status?: MessageStatusType; status?: MessageStatusType;
textPending?: boolean; textPending?: boolean;
@ -153,6 +154,7 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
expirationLength, expirationLength,
expirationTimestamp, expirationTimestamp,
id, id,
played,
showMessageDetail, showMessageDetail,
status, status,
textPending, textPending,
@ -531,7 +533,14 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
timestamp={timestamp} timestamp={timestamp}
/> />
)} )}
<div className={`${CSS_BASE}__countdown`}>{timeToText(countDown)}</div> <div
className={classNames(
`${CSS_BASE}__countdown`,
`${CSS_BASE}__countdown--${played ? 'played' : 'unplayed'}`
)}
>
{timeToText(countDown)}
</div>
</div> </div>
); );

View file

@ -21,6 +21,7 @@ const { deleteSentProtoRecipient } = dataInterface;
export enum MessageReceiptType { export enum MessageReceiptType {
Delivery = 'Delivery', Delivery = 'Delivery',
Read = 'Read', Read = 'Read',
View = 'View',
} }
type MessageReceiptAttributesType = { type MessageReceiptAttributesType = {
@ -151,6 +152,9 @@ export class MessageReceipts extends Collection<MessageReceiptModel> {
case MessageReceiptType.Read: case MessageReceiptType.Read:
sendActionType = SendActionType.GotReadReceipt; sendActionType = SendActionType.GotReadReceipt;
break; break;
case MessageReceiptType.View:
sendActionType = SendActionType.GotViewedReceipt;
break;
default: default:
throw missingCaseError(type); throw missingCaseError(type);
} }

View file

@ -51,6 +51,8 @@ const STATUS_NUMBERS: Record<SendStatus, number> = {
export const maxStatus = (a: SendStatus, b: SendStatus): SendStatus => export const maxStatus = (a: SendStatus, b: SendStatus): SendStatus =>
STATUS_NUMBERS[a] > STATUS_NUMBERS[b] ? a : b; STATUS_NUMBERS[a] > STATUS_NUMBERS[b] ? a : b;
export const isViewed = (status: SendStatus): boolean =>
status === SendStatus.Viewed;
export const isRead = (status: SendStatus): boolean => export const isRead = (status: SendStatus): boolean =>
STATUS_NUMBERS[status] >= STATUS_NUMBERS[SendStatus.Read]; STATUS_NUMBERS[status] >= STATUS_NUMBERS[SendStatus.Read];
export const isDelivered = (status: SendStatus): boolean => export const isDelivered = (status: SendStatus): boolean =>

3
ts/model-types.d.ts vendored
View file

@ -37,7 +37,8 @@ export type LastMessageStatus =
| 'sending' | 'sending'
| 'sent' | 'sent'
| 'delivered' | 'delivered'
| 'read'; | 'read'
| 'viewed';
type TaskResultType = any; type TaskResultType = any;

View file

@ -3206,6 +3206,9 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
case MessageReceiptType.Read: case MessageReceiptType.Read:
sendActionType = SendActionType.GotReadReceipt; sendActionType = SendActionType.GotReadReceipt;
break; break;
case MessageReceiptType.View:
sendActionType = SendActionType.GotViewedReceipt;
break;
default: default:
throw missingCaseError(receiptType); throw missingCaseError(receiptType);
} }

View file

@ -52,6 +52,7 @@ import {
isMessageJustForMe, isMessageJustForMe,
isRead, isRead,
isSent, isSent,
isViewed,
maxStatus, maxStatus,
someSendStatus, someSendStatus,
} from '../../messages/MessageSendState'; } from '../../messages/MessageSendState';
@ -914,7 +915,7 @@ export function getMessagePropStatus(
if (hasErrors(message)) { if (hasErrors(message)) {
return sent ? 'partial-sent' : 'error'; return sent ? 'partial-sent' : 'error';
} }
return sent ? 'read' : 'sending'; return sent ? 'viewed' : 'sending';
} }
const sendStates = Object.values( const sendStates = Object.values(
@ -928,6 +929,9 @@ export function getMessagePropStatus(
if (hasErrors(message)) { if (hasErrors(message)) {
return isSent(highestSuccessfulStatus) ? 'partial-sent' : 'error'; return isSent(highestSuccessfulStatus) ? 'partial-sent' : 'error';
} }
if (isViewed(highestSuccessfulStatus)) {
return 'viewed';
}
if (isRead(highestSuccessfulStatus)) { if (isRead(highestSuccessfulStatus)) {
return 'read'; return 'read';
} }

View file

@ -28,6 +28,7 @@ export type Props = {
expirationLength?: number; expirationLength?: number;
expirationTimestamp?: number; expirationTimestamp?: number;
id: string; id: string;
played: boolean;
showMessageDetail: (id: string) => void; showMessageDetail: (id: string) => void;
status?: MessageStatusType; status?: MessageStatusType;
textPending?: boolean; textPending?: boolean;

View file

@ -15,6 +15,7 @@ import {
isMessageJustForMe, isMessageJustForMe,
isRead, isRead,
isSent, isSent,
isViewed,
maxStatus, maxStatus,
sendStateReducer, sendStateReducer,
someSendStatus, 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', () => { describe('isRead', () => {
it('returns true for read and viewed statuses', () => { it('returns true for read and viewed statuses', () => {
assert.isTrue(isRead(SendStatus.Read)); assert.isTrue(isRead(SendStatus.Read));

View file

@ -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({ const message = createMessage({
sendStateByConversationId: { sendStateByConversationId: {
[ourConversationId]: { [ourConversationId]: {
@ -228,23 +228,19 @@ describe('state/selectors/messages', () => {
assert.strictEqual( assert.strictEqual(
getMessagePropStatus(message, ourConversationId), getMessagePropStatus(message, ourConversationId),
'read' 'viewed'
); );
}); });
it('returns "read" if the message was read by at least one person', () => { it('returns "viewed" if the message was viewed by at least one person', () => {
const readMessage = createMessage({ const message = createMessage({
sendStateByConversationId: { sendStateByConversationId: {
[ourConversationId]: { [ourConversationId]: {
status: SendStatus.Sent, status: SendStatus.Sent,
updatedAt: Date.now(), updatedAt: Date.now(),
}, },
[uuid()]: { [uuid()]: {
status: SendStatus.Pending, status: SendStatus.Viewed,
updatedAt: Date.now(),
},
[uuid()]: {
status: SendStatus.Delivered,
updatedAt: Date.now(), updatedAt: Date.now(),
}, },
[uuid()]: { [uuid()]: {
@ -254,20 +250,26 @@ describe('state/selectors/messages', () => {
}, },
}); });
assert.strictEqual( assert.strictEqual(
getMessagePropStatus(readMessage, ourConversationId), getMessagePropStatus(message, ourConversationId),
'read' 'viewed'
); );
});
const viewedMessage = createMessage({ it('returns "read" if the message was read by at least one person', () => {
const message = createMessage({
sendStateByConversationId: { sendStateByConversationId: {
[ourConversationId]: {
status: SendStatus.Sent,
updatedAt: Date.now(),
},
[uuid()]: { [uuid()]: {
status: SendStatus.Viewed, status: SendStatus.Read,
updatedAt: Date.now(), updatedAt: Date.now(),
}, },
}, },
}); });
assert.strictEqual( assert.strictEqual(
getMessagePropStatus(viewedMessage, ourConversationId), getMessagePropStatus(message, ourConversationId),
'read' 'read'
); );
}); });

View file

@ -86,6 +86,7 @@ import {
MessageEvent, MessageEvent,
RetryRequestEvent, RetryRequestEvent,
ReadEvent, ReadEvent,
ViewEvent,
ConfigurationEvent, ConfigurationEvent,
ViewOnceOpenSyncEvent, ViewOnceOpenSyncEvent,
MessageRequestResponseEvent, MessageRequestResponseEvent,
@ -480,6 +481,8 @@ export default class MessageReceiver extends EventTarget {
public addEventListener(name: 'read', handler: (ev: ReadEvent) => void): void; public addEventListener(name: 'read', handler: (ev: ReadEvent) => void): void;
public addEventListener(name: 'view', handler: (ev: ViewEvent) => void): void;
public addEventListener( public addEventListener(
name: 'configuration', name: 'configuration',
handler: (ev: ConfigurationEvent) => void handler: (ev: ConfigurationEvent) => void
@ -2174,38 +2177,40 @@ export default class MessageReceiver extends EventTarget {
envelope: ProcessedEnvelope, envelope: ProcessedEnvelope,
receiptMessage: Proto.IReceiptMessage receiptMessage: Proto.IReceiptMessage
): Promise<void> { ): Promise<void> {
const results = [];
strictAssert(receiptMessage.timestamp, 'Receipt message without timestamp'); strictAssert(receiptMessage.timestamp, 'Receipt message without timestamp');
if (receiptMessage.type === Proto.ReceiptMessage.Type.DELIVERY) {
for (let i = 0; i < receiptMessage.timestamp.length; i += 1) { let EventClass: typeof DeliveryEvent | typeof ReadEvent | typeof ViewEvent;
const ev = new DeliveryEvent( switch (receiptMessage.type) {
{ case Proto.ReceiptMessage.Type.DELIVERY:
timestamp: normalizeNumber(receiptMessage.timestamp[i]), EventClass = DeliveryEvent;
envelopeTimestamp: envelope.timestamp, break;
source: envelope.source, case Proto.ReceiptMessage.Type.READ:
sourceUuid: envelope.sourceUuid, EventClass = ReadEvent;
sourceDevice: envelope.sourceDevice, break;
}, case Proto.ReceiptMessage.Type.VIEWED:
this.removeFromCache.bind(this, envelope) EventClass = ViewEvent;
); break;
results.push(this.dispatchAndWait(ev)); default:
} // This can happen if we get a receipt type we don't know about yet, which
} else if (receiptMessage.type === Proto.ReceiptMessage.Type.READ) { // is totally fine.
for (let i = 0; i < receiptMessage.timestamp.length; i += 1) { return;
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));
}
} }
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( private async handleTypingMessage(

View file

@ -222,7 +222,7 @@ export class MessageEvent extends ConfirmableEvent {
} }
} }
export type ReadEventData = Readonly<{ export type ReadOrViewEventData = Readonly<{
timestamp: number; timestamp: number;
envelopeTimestamp: number; envelopeTimestamp: number;
source?: string; source?: string;
@ -231,11 +231,23 @@ export type ReadEventData = Readonly<{
}>; }>;
export class ReadEvent extends ConfirmableEvent { export class ReadEvent extends ConfirmableEvent {
constructor(public readonly read: ReadEventData, confirm: ConfirmCallback) { constructor(
public readonly receipt: ReadOrViewEventData,
confirm: ConfirmCallback
) {
super('read', confirm); super('read', confirm);
} }
} }
export class ViewEvent extends ConfirmableEvent {
constructor(
public readonly receipt: ReadOrViewEventData,
confirm: ConfirmCallback
) {
super('view', confirm);
}
}
export class ConfigurationEvent extends ConfirmableEvent { export class ConfigurationEvent extends ConfirmableEvent {
constructor( constructor(
public readonly configuration: Proto.SyncMessage.IConfiguration, public readonly configuration: Proto.SyncMessage.IConfiguration,