🚸(frontend) improve sending experience

First when the sending reach the timeout, instead to display a toast
error
with a message that lets believe the message cannot be sent, we display
a warning message mentionning that sending takes more time than
expected.

Then, once a message is sent, we optimistically update thread cache to
hide
immediately the sent draft and show instead the message in the thread
view.
This commit is contained in:
jbpenrath
2026-05-28 11:02:15 +02:00
parent 77b16e5dc6
commit afdfaf938a
7 changed files with 39 additions and 11 deletions
@@ -634,6 +634,7 @@
"Send and archive": "Send and archive",
"Send and receive your messages in an instant.": "Send and receive your messages in an instant.",
"Send Feedback": "Send Feedback",
"Sending is taking longer than expected. You can track your message in the Outbox.": "Sending is taking longer than expected. You can track your message in the Outbox.",
"Sending message...": "Sending message...",
"Sent": "Sent",
"Sent by {{name}}": "Sent by {{name}}",
@@ -709,7 +710,6 @@
"The forced signature will be the only one usable for new messages.": "The forced signature will be the only one usable for new messages.",
"The mailbox \"{{mailbox}}\" currently has read-only access on this thread. To assign {{user}} to it, edit permissions must be granted to this mailbox.": "The mailbox \"{{mailbox}}\" currently has read-only access on this thread. To assign {{user}} to it, edit permissions must be granted to this mailbox.",
"The message could not be sent.": "The message could not be sent.",
"The message could not be sent. Please try again later.": "The message could not be sent. Please try again later.",
"The organizer marked this event as tentative.": "The organizer marked this event as tentative.",
"The personal mailbox <strong>{{mailboxAddress}}</strong> has been created successfully.": "The personal mailbox <1>{{mailboxAddress}}</1> has been created successfully.",
"The PST archive is unreadable: the file is corrupt or its internal structure is incomplete. Retrying will not help — please try to re-generate the archive.": "The PST archive is unreadable: the file is corrupt or its internal structure is incomplete. Retrying will not help — please try to re-generate the archive.",
@@ -710,6 +710,7 @@
"Send and archive": "Envoyer et archiver",
"Send and receive your messages in an instant.": "Envoyez et recevez vos messages en un instant.",
"Send Feedback": "Envoyer le message",
"Sending is taking longer than expected. You can track your message in the Outbox.": "L'envoi prend plus de temps que prévu. Vous pouvez suivre votre message dans la boîte d'envoi.",
"Sending message...": "Envoi du message en cours...",
"Sent": "Envoyés",
"Sent by {{name}}": "Envoyé par {{name}}",
@@ -788,7 +789,6 @@
"The forced signature will be the only one usable for new messages.": "La signature forcée sera la seule utilisable pour les nouveaux messages.",
"The mailbox \"{{mailbox}}\" currently has read-only access on this thread. To assign {{user}} to it, edit permissions must be granted to this mailbox.": "La boîte « {{mailbox}} » n'a actuellement que les droits en lecture sur cette conversation. Pour y assigner {{user}}, les droits en édition doivent être accordés à cette boîte.",
"The message could not be sent.": "Le message n'a pas pu être envoyé.",
"The message could not be sent. Please try again later.": "Le message n'a pas pu être envoyé. Veuillez réessayer plus tard.",
"The organizer marked this event as tentative.": "L'organisateur a marqué cet événement comme provisoire.",
"The personal mailbox <strong>{{mailboxAddress}}</strong> has been created successfully.": "L'adresse personnelle <strong>{{mailboxAddress}}</strong> a été créée avec succès.",
"The PST archive is unreadable: the file is corrupt or its internal structure is incomplete. Retrying will not help — please try to re-generate the archive.": "L'archive PST est illisible : le fichier est corrompu ou sa structure interne est incomplète. Réessayer ne servira à rien — veuillez essayer de régénérer l'archive.",
@@ -106,7 +106,7 @@ export const MessageForm = ({
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
const saveDraftRef = useRef<() => void>(() => {});
const quoteType: QuoteType | undefined = mode !== "new" ? (mode === "forward" ? "forward" : "reply") : undefined;
const { selectedMailbox, selectedThread, mailboxes, removeMessages, invalidateMailbox, invalidateThreadsStats, unselectThread, unpinThreads, pinThreads } = useMailboxContext();
const { selectedMailbox, selectedThread, mailboxes, removeMessages, patchMessages, invalidateMailbox, invalidateThreadsStats, unselectThread, unpinThreads, pinThreads } = useMailboxContext();
const hideSubjectField = Boolean(draftMessage?.parent_id ?? parentMessage);
// For replies/forwards, only allow sending from a mailbox that has access to the thread.
const availableMailboxes = useMemo(() => {
@@ -287,8 +287,20 @@ export const MessageForm = ({
form.clearErrors();
toast.dismiss(DRAFT_TOAST_ID);
},
onSuccess: async (response) => {
onSuccess: async (response, variables) => {
const data = (response as sendCreateResponse200).data;
// The backend un-drafts the message synchronously before queuing
// the SMTP task. Reflect that optimistically so the thread renders
// it as a sending message right away instead of keeping the draft
// form open until the background refetch lands.
const sentThreadId = draft?.thread_id ?? selectedThread?.id;
if (sentThreadId) {
patchMessages(sentThreadId, (message) =>
message.id === variables.data.messageId
? { ...message, is_draft: false }
: message
);
}
addQueuedMessage(data.task_id);
onSuccess?.();
}
@@ -17,10 +17,13 @@ const MessageReplyForm = ({ handleClose, message, mode }: MessageReplyFormProps)
draftMessage={message.is_draft ? message : undefined}
parentMessage={message.is_draft ? undefined : message}
mode={mode}
onSuccess={async () => {
// Force refetch the messages query to avoid showing the draft message in the thread view
await queryClient.refetchQueries({ queryKey: ["messages", message.thread_id] });
onSuccess={() => {
// Close right away: MessageForm has optimistically un-drafted
// the message, so the thread already shows it as sending.
handleClose();
// Reconcile with the server state (delivery status, etc.) in
// the background without blocking the form close.
void queryClient.refetchQueries({ queryKey: ["messages", message.thread_id] });
}}
onClose={handleClose}
/>
@@ -224,8 +224,7 @@
display: flex;
justify-content: center;
align-items: center;
height: 100%;
margin-block: var(--c--globals--spacings--base);
margin-bottom: var(--c--globals--spacings--base);
}
.thread-message__reply-form {
@@ -321,7 +321,12 @@ export const ThreadMessage = forwardRef<HTMLSpanElement, ThreadMessageProps>(
icon={<Icon name="update" type={IconType.OUTLINED} />}
type="warning"
fullWidth
actions={canUpdateDeliveryStatus ? [
// Only offer cancellation when there are recipients the
// backend can actually transition (retry). A message still
// being sent has pending recipients (delivery_status null),
// which the banner reports but which cannot be cancelled —
// showing the button there would POST an empty payload.
actions={canUpdateDeliveryStatus && retryRecipients.length > 0 ? [
{
label: t('Cancel those sendings'),
onClick: handleCancelRetries,
@@ -95,8 +95,17 @@ export const QueueMessage = ({ taskId, onSettled }: QueueMessageProps) => {
useEffect(() => {
if (hasTimedOut) {
// The send didn't fail: the backend already un-drafted the message and
// the SMTP task is still running. Reassure the user and point them to
// the Outbox rather than showing a misleading error. onSettled refreshes
// the stats so the Outbox folder reflects the pending message.
toast.update(toastId, {
render: <ToasterItem type="error"><span>{t('The message could not be sent. Please try again later.')}</span></ToasterItem>,
render: (
<ToasterItem type="warning">
<span className="material-icons">schedule_send</span>
<span>{t('Sending is taking longer than expected. You can track your message in the Outbox.')}</span>
</ToasterItem>
),
autoClose: QUEUED_MESSAGE_CLOSE_DELAY * 2,
});
onSettled?.();