Skip to content

Commit

Permalink
feat: implement retry mechanism for asset messages [FS-1634] (#14890)
Browse files Browse the repository at this point in the history
* feat: implement retry mechanism for asset messages [FS-1634]

* add observable to store a blob in database

* create method to store blob in database

* type send and inject response

* implement retry upload method in message repository

* call retry upload method in message wrapper component

* allow for event replacement in event repository

* bump core: allow retrying to send asset metadatas on an existing message #4995

* add js doc for newly added methods

* Apply suggestions from code review

Co-authored-by: Thomas Belin <thomasbelin4@gmail.com>

* rename storedBlob to fileData

* remove fileData from db on message succesfully sent

---------

Co-authored-by: Thomas Belin <thomasbelin4@gmail.com>
  • Loading branch information
V-Gira and atomrc committed Mar 28, 2023
1 parent 6b09a0e commit 0727207
Show file tree
Hide file tree
Showing 7 changed files with 100 additions and 18 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,14 @@ export const CompleteFailureToSendWarning = ({isTextAsset, onRetry, onDiscard}:
<>
<div>
<p css={warning}>{isTextAsset ? t('messageCouldNotBeSent') : t('messageWillNotBeSent')}</p>

<Button type="button" variant={ButtonVariant.TERTIARY} onClick={isTextAsset ? onRetry : onDiscard}>
{isTextAsset ? t('messageCouldNotBeSentRetry') : t('messageWillNotBeSentDiscard')}
</Button>
<div css={{display: 'flex'}}>
<Button type="button" variant={ButtonVariant.TERTIARY} onClick={onRetry}>
{t('messageCouldNotBeSentRetry')}
</Button>
<Button type="button" variant={ButtonVariant.TERTIARY} onClick={onDiscard}>
{t('messageWillNotBeSentDiscard')}
</Button>
</div>
</div>
</>
);
Expand Down
3 changes: 3 additions & 0 deletions src/script/components/MessagesList/Message/MessageWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export const MessageWrapper: React.FC<MessageParams & {hasMarker: boolean; isMes

const onRetry = async (message: ContentMessage) => {
const firstAsset = message.getFirstAsset();
const file = message.fileData();

if (firstAsset instanceof Text) {
const messageId = message.id;
Expand All @@ -110,6 +111,8 @@ export const MessageWrapper: React.FC<MessageParams & {hasMarker: boolean; isMes
incomingQuote && isOutgoingQuote(incomingQuote) ? (incomingQuote as OutgoingQuote) : undefined;

await messageRepository.sendTextWithLinkPreview(conversation, messageText, mentions, quote, messageId);
} else if (file) {
await messageRepository.retryUploadFile(conversation, file, firstAsset.isImage(), message.id);
}
};

Expand Down
4 changes: 4 additions & 0 deletions src/script/conversation/EventMapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,10 @@ export class EventMapper {
originalEntity.failedToSend(event.failedToSend);
}

if (event.fileData) {
originalEntity.fileData(event.fileData);
}

if (event.selected_button_id) {
originalEntity.version = event.version;
}
Expand Down
78 changes: 68 additions & 10 deletions src/script/conversation/MessageRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,12 @@ type TextMessagePayload = {
};
type EditMessagePayload = TextMessagePayload & {originalMessageId: string};

const enum SendAndInjectSendingState {
FAILED = 'FAILED',
}

type SendAndInjectResult = Omit<SendResult, 'state'> & {state: MessageSendingState | SendAndInjectSendingState};

/** A message that has already been stored in DB and has a primary key */
type StoredMessage = Message & {primary_key: string};
type StoredContentMessage = ContentMessage & {primary_key: string};
Expand Down Expand Up @@ -460,15 +466,22 @@ export class MessageRepository {
* @param conversation Conversation to post the file
* @param file File object
* @param asImage whether or not the file should be treated as an image
* @param originalId Id of the messsage currently in db, necessary to replace the original message
* @returns Resolves when file was uploaded
*/
private async uploadFile(
conversation: Conversation,
file: Blob,
asImage: boolean = false,
originalId?: string,
): Promise<EventRecord | void> {
const uploadStarted = Date.now();
const {id, state} = await this.sendAssetMetadata(conversation, file, asImage);

const {id, state} = await this.sendAssetMetadata(conversation, file, asImage, originalId);
if (state === SendAndInjectSendingState.FAILED) {
await this.storeFileInDb(conversation, id, file);
return;
}
if (state === MessageSendingState.CANCELED) {
throw new ConversationError(
ConversationError.TYPE.DEGRADED_CONVERSATION_CANCELLATION,
Expand All @@ -490,11 +503,52 @@ export class MessageRepository {
error,
);
const messageEntity = await this.getMessageInConversationById(conversation, id);
this.sendAssetUploadFailed(conversation, messageEntity.id);
await this.sendAssetUploadFailed(conversation, messageEntity.id);
return this.updateMessageAsUploadFailed(messageEntity);
}
}

/**
* Retry sending a file to a conversation when the original message failed to be sent
*
* @param conversation Conversation to post the file
* @param file File object
* @param asImage whether or not the file should be treated as an image
* @param originalId Id of the messsage currently in db, necessary to replace the original message
* @returns Resolves when file was uploaded
*/
public async retryUploadFile(
conversation: Conversation,
file: Blob,
asImage: boolean = false,
originalId: string,
): Promise<EventRecord | void> {
await this.uploadFile(conversation, file, asImage, originalId);
}

/**
* Store a file in offline db to be able to send it later
*
* @param conversation Conversation to post the file
* @param file File object to be stored in db
* @param messageId Id of the messsage in db to update
* @returns Resolves when database was updated
*/
private async storeFileInDb(conversation: Conversation, messageId: string, file: Blob) {
try {
const messageEntity = await this.getMessageInConversationById(conversation, messageId);
messageEntity.fileData(file);
return this.eventService.updateEvent(messageEntity.primary_key, {
fileData: file,
});
} catch (error) {
if ((error as any).type !== ConversationError.TYPE.MESSAGE_NOT_FOUND) {
throw error;
}
}
return undefined;
}

/**
* Update asset in UI and DB as failed
*
Expand Down Expand Up @@ -544,7 +598,12 @@ export class MessageRepository {
/**
* Send asset metadata message to specified conversation.
*/
private async sendAssetMetadata(conversation: Conversation, file: File | Blob, allowImageDetection?: boolean) {
private async sendAssetMetadata(
conversation: Conversation,
file: File | Blob,
allowImageDetection?: boolean,
originalId?: string,
) {
let metadata;
try {
metadata = await buildMetadata(file);
Expand All @@ -563,9 +622,7 @@ export class MessageRepository {
} else if (allowImageDetection && isImage(file)) {
meta.image = metadata as ImageMetaData;
}
const message = MessageBuilder.buildFileMetaDataMessage({
metaData: meta as FileMetaDataContent,
});
const message = MessageBuilder.buildFileMetaDataMessage({metaData: meta as FileMetaDataContent}, originalId);
return this.sendAndInjectMessage(message, conversation, {enableEphemeral: true});
}

Expand Down Expand Up @@ -690,7 +747,7 @@ export class MessageRepository {
} = {
syncTimestamp: true,
},
) {
): Promise<SendAndInjectResult> {
const {groupId} = conversation;

const messageTimer = conversation.messageTimer();
Expand Down Expand Up @@ -749,7 +806,7 @@ export class MessageRepository {

const shouldProceedSending = await injectOptimisticEvent();
if (shouldProceedSending === false) {
return {id: payload.messageId, state: MessageSendingState.CANCELED};
return {id: payload.messageId, sentAt: new Date().toISOString(), state: MessageSendingState.CANCELED};
}

try {
Expand All @@ -761,7 +818,7 @@ export class MessageRepository {
return result;
} catch (error) {
await this.updateMessageAsFailed(conversation, payload.messageId);
throw error;
return {id: payload.messageId, sentAt: new Date().toISOString(), state: SendAndInjectSendingState.FAILED};
}
}

Expand Down Expand Up @@ -1110,9 +1167,10 @@ export class MessageRepository {
const messageEntity = await this.getMessageInConversationById(conversationEntity, eventId);
const updatedStatus = messageEntity.readReceipts().length ? StatusType.SEEN : StatusType.SENT;
messageEntity.status(updatedStatus);
const changes: Pick<Partial<EventRecord>, 'status' | 'time' | 'failedToSend'> = {
const changes: Pick<Partial<EventRecord>, 'status' | 'time' | 'failedToSend' | 'fileData'> = {
status: updatedStatus,
failedToSend,
fileData: undefined,
};
if (isoDate) {
const timestamp = new Date(isoDate).getTime();
Expand Down
2 changes: 2 additions & 0 deletions src/script/entity/message/ContentMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export class ContentMessage extends Message {
public readonly other_likes: ko.PureComputed<User[]>;
public readonly failedToSend: ko.Observable<{queued?: QualifiedUserClients; failed?: QualifiedId[]} | undefined> =
ko.observable();
// raw content of a file that was supposed to be sent but failed. Is undefined if the message has been successfully sent
public readonly fileData: ko.Observable<Blob | undefined> = ko.observable();
public readonly quote: ko.Observable<QuoteEntity>;
// TODO: Rename to `reactionsUsers`
public readonly reactions_user_ids: ko.PureComputed<string>;
Expand Down
17 changes: 13 additions & 4 deletions src/script/event/EventRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -524,15 +524,24 @@ export class EventRepository {
const newEventData = newEvent.data;
// the preview status is not sent by the client so we fake a 'preview' status in order to cleanly handle it in the switch statement
const ASSET_PREVIEW = 'preview';
// similarly, no status is sent by the client when we retry sending a failed message
const RETRY_EVENT = 'retry';
const isPreviewEvent = !newEventData.status && !!newEventData.preview_key;
const previewStatus = isPreviewEvent ? ASSET_PREVIEW : newEventData.status;
const isRetryEvent = !!newEventData.content_length;
const handledEvent = isRetryEvent ? RETRY_EVENT : newEventData.status;
const previewStatus = isPreviewEvent ? ASSET_PREVIEW : handledEvent;

const updateEvent = () => {
const updatedData = {...originalEvent.data, ...newEventData};
const updatedEvent = {...originalEvent, data: updatedData};
return this.eventService.replaceEvent(updatedEvent);
};

switch (previewStatus) {
case ASSET_PREVIEW:
case RETRY_EVENT:
case AssetTransferState.UPLOADED: {
const updatedData = {...originalEvent.data, ...newEventData};
const updatedEvent = {...originalEvent, data: updatedData};
return this.eventService.replaceEvent(updatedEvent);
return updateEvent();
}

case AssetTransferState.UPLOAD_FAILED: {
Expand Down
2 changes: 2 additions & 0 deletions src/script/storage/record/EventRecord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ export type UserReactionMap = {[userId: string]: ReactionType};
type SentEvent = {
/** sending status of the event*/
status: StatusType;
/** raw content of a file that was supposed to be sent but failed. Is undefined if the message has been successfully sent */
fileData?: Blob;
failedToSend?: {
queue?: QualifiedUserClients;
failed?: QualifiedId[];
Expand Down

0 comments on commit 0727207

Please sign in to comment.