Show "unplayed" dot on outgoing audio messages
This commit is contained in:
parent
b73c029d5f
commit
14929fb408
16 changed files with 219 additions and 60 deletions
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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: [
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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
3
ts/model-types.d.ts
vendored
|
@ -37,7 +37,8 @@ export type LastMessageStatus =
|
|||
| 'sending'
|
||||
| 'sent'
|
||||
| 'delivered'
|
||||
| 'read';
|
||||
| 'read'
|
||||
| 'viewed';
|
||||
|
||||
type TaskResultType = any;
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
}
|
||||
|
|
|
@ -28,6 +28,7 @@ export type Props = {
|
|||
expirationLength?: number;
|
||||
expirationTimestamp?: number;
|
||||
id: string;
|
||||
played: boolean;
|
||||
showMessageDetail: (id: string) => void;
|
||||
status?: MessageStatusType;
|
||||
textPending?: boolean;
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in a new issue