signal-desktop/ts/messages/MessageSendState.ts

265 lines
8.3 KiB
TypeScript
Raw Normal View History

2023-01-03 19:55:46 +00:00
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
2024-02-27 16:01:25 +00:00
import memoizee from 'memoizee';
import { makeEnumParser } from '../util/enum';
/**
* `SendStatus` represents the send status of a message to a single recipient. For
* example, if a message is sent to 5 people, there would be 5 `SendStatus`es.
*
* Under normal conditions, the status will go down this list, in order:
*
* 1. `Pending`; the message has not been sent, and we are continuing to try
* 2. `Sent`; the message has been delivered to the server
* 3. `Delivered`; we've received a delivery receipt
* 4. `Read`; we've received a read receipt (not applicable if the recipient has disabled
* sending these receipts)
* 5. `Viewed`; we've received a viewed receipt (not applicable for all message types, or
* if the recipient has disabled sending these receipts)
*
* There's also a `Failed` state, which represents an error we don't want to recover from.
*
* There are some unusual cases where messages don't follow this pattern. For example, if
* we receive a read receipt before we receive a delivery receipt, we might skip the
* Delivered state. However, we should never go "backwards".
*
* Be careful when changing these values, as they are persisted.
*/
export enum SendStatus {
Failed = 'Failed',
Pending = 'Pending',
Sent = 'Sent',
Delivered = 'Delivered',
Read = 'Read',
Viewed = 'Viewed',
}
export const parseMessageSendStatus = makeEnumParser(
SendStatus,
SendStatus.Pending
);
export const UNDELIVERED_SEND_STATUSES = [
SendStatus.Pending,
SendStatus.Failed,
];
const STATUS_NUMBERS: Record<SendStatus, number> = {
[SendStatus.Failed]: 0,
[SendStatus.Pending]: 1,
[SendStatus.Sent]: 2,
[SendStatus.Delivered]: 3,
[SendStatus.Read]: 4,
[SendStatus.Viewed]: 5,
};
export const maxStatus = (a: SendStatus, b: SendStatus): SendStatus =>
STATUS_NUMBERS[a] > STATUS_NUMBERS[b] ? a : b;
export const isPending = (status: SendStatus): boolean =>
status === SendStatus.Pending;
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 =>
STATUS_NUMBERS[status] >= STATUS_NUMBERS[SendStatus.Delivered];
export const isSent = (status: SendStatus): boolean =>
STATUS_NUMBERS[status] >= STATUS_NUMBERS[SendStatus.Sent];
export const isFailed = (status: SendStatus): boolean =>
status === SendStatus.Failed;
/**
* `SendState` combines `SendStatus` and a timestamp. You can use it to show things to the
* user such as "this message was delivered at 6:09pm".
*
* The timestamp may be undefined if reading old data, which did not store a timestamp.
*/
export type SendState = Readonly<{
2022-08-02 19:31:55 +00:00
// When sending a story to multiple distribution lists at once, we need to
// de-duplicate the recipients. The story should only be sent once to each
// recipient in the list so the recipient only sees it rendered once.
isAlreadyIncludedInAnotherDistributionList?: boolean;
2022-07-01 00:52:03 +00:00
isAllowedToReplyToStory?: boolean;
status:
| SendStatus.Pending
| SendStatus.Failed
| SendStatus.Sent
| SendStatus.Delivered
| SendStatus.Read
| SendStatus.Viewed;
updatedAt?: number;
}>;
/**
* The reducer advances the little `SendState` state machine. It mostly follows the steps
* in the `SendStatus` documentation above, but it also handles edge cases.
*/
export function sendStateReducer(
state: Readonly<SendState>,
action: Readonly<SendAction>
): SendState {
const oldStatus = state.status;
let newStatus: SendStatus;
if (
oldStatus === SendStatus.Pending &&
action.type === SendActionType.Failed
) {
newStatus = SendStatus.Failed;
} else {
newStatus = maxStatus(oldStatus, STATE_TRANSITIONS[action.type]);
}
return newStatus === oldStatus
? state
: {
2022-11-10 03:52:53 +00:00
...state,
status: newStatus,
updatedAt: action.updatedAt,
};
}
export enum SendActionType {
Failed,
ManuallyRetried,
Sent,
GotDeliveryReceipt,
GotReadReceipt,
GotViewedReceipt,
}
export type SendAction = Readonly<{
type:
| SendActionType.Failed
| SendActionType.ManuallyRetried
| SendActionType.Sent
| SendActionType.GotDeliveryReceipt
| SendActionType.GotReadReceipt
| SendActionType.GotViewedReceipt;
// `updatedAt?: number` makes it easier to forget the property. With this type, you have
// to explicitly say it's missing.
updatedAt: undefined | number;
}>;
const STATE_TRANSITIONS: Record<SendActionType, SendStatus> = {
[SendActionType.Failed]: SendStatus.Failed,
[SendActionType.ManuallyRetried]: SendStatus.Pending,
[SendActionType.Sent]: SendStatus.Sent,
[SendActionType.GotDeliveryReceipt]: SendStatus.Delivered,
[SendActionType.GotReadReceipt]: SendStatus.Read,
[SendActionType.GotViewedReceipt]: SendStatus.Viewed,
};
export type SendStateByConversationId = Record<string, SendState>;
2024-02-27 16:01:25 +00:00
/** Test all of sendStateByConversationId for predicate */
export const someSendStatus = (
2024-02-27 16:01:25 +00:00
sendStateByConversationId: SendStateByConversationId,
predicate: (value: SendStatus) => boolean
2024-02-27 16:01:25 +00:00
): boolean => {
return [
...summarizeMessageSendStatuses(sendStateByConversationId).statuses,
].some(predicate);
};
/** Test sendStateByConversationId, excluding ourConversationId, for predicate */
export const someRecipientSendStatus = (
sendStateByConversationId: SendStateByConversationId,
ourConversationId: string | undefined,
predicate: (value: SendStatus) => boolean
): boolean => {
return getStatusesIgnoringOurConversationId(
sendStateByConversationId,
ourConversationId
).some(predicate);
};
export const isMessageJustForMe = (
2024-02-27 16:01:25 +00:00
sendStateByConversationId: SendStateByConversationId,
ourConversationId: string | undefined
): boolean => {
2024-02-27 16:01:25 +00:00
const { length } = summarizeMessageSendStatuses(sendStateByConversationId);
return (
ourConversationId !== undefined &&
length === 1 &&
Object.hasOwn(sendStateByConversationId, ourConversationId)
);
};
export const getHighestSuccessfulRecipientStatus = (
sendStateByConversationId: SendStateByConversationId,
ourConversationId: string | undefined
): SendStatus => {
return getStatusesIgnoringOurConversationId(
sendStateByConversationId,
ourConversationId
).reduce(
(result: SendStatus, status) => maxStatus(result, status),
SendStatus.Pending
);
};
2024-02-27 16:01:25 +00:00
const getStatusesIgnoringOurConversationId = (
sendStateByConversationId: SendStateByConversationId,
ourConversationId: string | undefined
): Array<SendStatus> => {
const { statuses, statusesWithOnlyOneConversationId } =
summarizeMessageSendStatuses(sendStateByConversationId);
const statusesIgnoringOurConversationId = [];
for (const status of statuses) {
if (
ourConversationId &&
statusesWithOnlyOneConversationId.get(status) === ourConversationId
) {
// ignore this status; it only applies to us
} else {
statusesIgnoringOurConversationId.push(status);
}
}
return statusesIgnoringOurConversationId;
};
// Looping through each value in sendStateByConversationId can be quite slow, especially
// if sendStateByConversationId is large (e.g. in a large group) and if it is actually a
// proxy (e.g. being called via useProxySelector) -- that's why we memoize it here.
const summarizeMessageSendStatuses = memoizee(
(
sendStateByConversationId: SendStateByConversationId
): {
statuses: Set<SendStatus>;
statusesWithOnlyOneConversationId: Map<SendStatus, string>;
length: number;
} => {
const statuses: Set<SendStatus> = new Set();
// We keep track of statuses with only one conversationId associated with it
// so that we can ignore a status if it is only for ourConversationId, as needed
const statusesWithOnlyOneConversationId: Map<SendStatus, string> =
new Map();
const entries = Object.entries(sendStateByConversationId);
for (const [conversationId, { status }] of entries) {
if (!statuses.has(status)) {
statuses.add(status);
statusesWithOnlyOneConversationId.set(status, conversationId);
} else {
statusesWithOnlyOneConversationId.delete(status);
}
}
return {
statuses,
statusesWithOnlyOneConversationId,
length: entries.length,
};
},
{ max: 100 }
);