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);
}
}
.module-message__metadata__status-icon--read {
.module-message__metadata__status-icon--read,
.module-message__metadata__status-icon--viewed {
width: 18px;
@include light-theme {

View file

@ -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;
}
}
}

View file

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

View file

@ -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: [

View file

@ -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<Props, State> {
}
}
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<Props, State> {
expirationLength,
expirationTimestamp,
id,
played,
showMessageDetail,
status,
textPending,

View file

@ -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: Props) => {
expirationLength,
expirationTimestamp,
id,
played,
showMessageDetail,
status,
textPending,
@ -531,7 +533,14 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
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>
);

View file

@ -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<MessageReceiptModel> {
case MessageReceiptType.Read:
sendActionType = SendActionType.GotReadReceipt;
break;
case MessageReceiptType.View:
sendActionType = SendActionType.GotViewedReceipt;
break;
default:
throw missingCaseError(type);
}

View file

@ -51,6 +51,8 @@ const STATUS_NUMBERS: Record<SendStatus, number> = {
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 =>

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

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

View file

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

View file

@ -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';
}

View file

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

View file

@ -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));

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({
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'
);
});

View file

@ -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<void> {
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(

View file

@ -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,