Skip to content

Commit 167ebd9

Browse files
committed
fix: a11y: don't disable composer while sending
The primary point of this change is to remove `textarea.focus()`, which makes screen readers re-announce the composer, which is disorienting and pointless. Partially addresses #4590 (comment). This change has side-effects: - Now if the user starts typing right after sending a message, we will not ignore it, as we would if the composer was disabled, which is good. - However, as a consequence, now it's possible to type while a message is being sent, which might have unexpected consequences itself, i.e. race conditions, i.e. bugs. This also improves the alert dialog that pops up if sending fails.
1 parent 90a2ce4 commit 167ebd9

File tree

3 files changed

+90
-47
lines changed

3 files changed

+90
-47
lines changed

CHANGELOG.md

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,14 @@
22

33
## [Unreleased][unreleased]
44

5-
<<<<<<< HEAD
5+
### Added
6+
- support multiple selection (multiselect) in the list of chats, activated with Ctrl + Click, Shift + Click #5297
7+
68
### Fixed
79
- fix "Recent 3 apps" in the chat header showing apps from another chat sometimes #5265
10+
- accessibility: don't re-announce message input (composer) after sending every message #5049
811
- don't close context menues on window resize #5418
912

10-
=======
11-
### Added
12-
- support multiple selection (multiselect) in the list of chats, activated with Ctrl + Click, Shift + Click #5297
13-
>>>>>>> b9d383489 (docs: add CHANGELOG entry)
14-
1513
<a id="2_11_1"></a>
1614

1715
## [2.11.1] - 2025-09-01

packages/frontend/src/components/composer/Composer.tsx

Lines changed: 78 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ const Composer = forwardRef<
7676
) => Promise<void>
7777
removeFile: () => void
7878
clearDraftStateButKeepTextareaValue: () => void
79+
clearDraftStateAndUpdateTextareaValue: () => void
80+
setDraftStateAndUpdateTextareaValue: (newValue: DraftObject) => void
7981
}
8082
>((props, ref) => {
8183
const {
@@ -210,49 +212,68 @@ const Composer = forwardRef<
210212
if (textareaRef) {
211213
if (textareaRef.disabled) {
212214
throw new Error(
213-
'text area is disabled, this means it is either already sending or loading the draft'
215+
'text area is disabled, this means it is loading the draft'
214216
)
215217
}
216-
textareaRef.disabled = true
217218
}
218-
try {
219-
const message = regularMessageInputRef.current?.getText() || ''
220-
if (!regularMessageInputRef.current?.hasText() && !draftState.file) {
221-
log.debug(`Empty message: don't send it...`)
222-
return
223-
}
224-
225-
const sendMessagePromise = sendMessage(accountId, chatId, {
226-
text: replaceColonsSafe(message),
227-
file: draftState.file || undefined,
228-
filename: draftState.fileName || undefined,
229-
quotedMessageId:
230-
draftState.quote?.kind === 'WithMessage'
231-
? draftState.quote.messageId
232-
: null,
233-
viewtype: draftState.viewType,
234-
})
219+
const message = regularMessageInputRef.current?.getText() || ''
220+
if (!regularMessageInputRef.current?.hasText() && !draftState.file) {
221+
log.debug(`Empty message: don't send it...`)
222+
return
223+
}
235224

225+
const preSendDraftState = draftState
226+
const sendMessagePromise = sendMessage(accountId, chatId, {
227+
text: replaceColonsSafe(message),
228+
file: draftState.file || undefined,
229+
filename: draftState.fileName || undefined,
230+
quotedMessageId:
231+
draftState.quote?.kind === 'WithMessage'
232+
? draftState.quote.messageId
233+
: null,
234+
viewtype: draftState.viewType,
235+
})
236+
// _Immediately_ clear the draft from React state.
237+
// This does _not_ remove the draft from the back-end yet.
238+
// This is primarily to make sure that you can't accidentally
239+
// doube-send the same message.
240+
//
241+
// We could instead disable the textarea
242+
// and disable sending the next message
243+
// until the previous one has been sent,
244+
// but it's unnecessary to block the user in such a way,
245+
// because it's not often that `sendMessage` fails.
246+
// And also disabling an input makes it lose focus,
247+
// so we'd have to re-focus it, which would make screen readers
248+
// re-announce it, which is disorienting.
249+
// See https://github.com/deltachat/deltachat-desktop/issues/4590#issuecomment-2821985528.
250+
props.clearDraftStateAndUpdateTextareaValue()
251+
252+
let sentSuccessfully: boolean
253+
try {
236254
await sendMessagePromise
237-
238-
// Ensure that the draft is cleared
239-
// and the state is reflected in the UI.
240-
//
241-
// At this point we know that sending has succeeded,
242-
// so we do not accidentally remove the draft
243-
// if the core fails to send.
244-
await BackendRemote.rpc.removeDraft(selectedAccountId(), chatId)
245-
window.__reloadDraft && window.__reloadDraft()
246-
} catch (error) {
255+
sentSuccessfully = true
256+
} catch (err) {
257+
sentSuccessfully = false
247258
openDialog(AlertDialog, {
248-
message: unknownErrorToString(error),
259+
message:
260+
tx('systemmsg_failed_sending_to', selectedChat.name) +
261+
'\n' +
262+
tx('error_x', unknownErrorToString(err)),
249263
})
250-
log.error(error)
251-
} finally {
252-
if (textareaRef) {
253-
textareaRef.disabled = false
254-
}
255-
regularMessageInputRef.current?.focus()
264+
// Restore the draft, since we failed to send.
265+
// Note that this will not save the draft to the backend.
266+
//
267+
// TODO fix: hypothetically by this point the user
268+
// could have started typing a new message already,
269+
// and so this would override it on the frontend.
270+
props.setDraftStateAndUpdateTextareaValue(preSendDraftState)
271+
}
272+
if (sentSuccessfully) {
273+
// TODO fix: hypothetically by this point the user
274+
// could have started typing (and even have sent!)
275+
// a new message already, so this would override it on the backend.
276+
await BackendRemote.rpc.removeDraft(accountId, chatId)
256277
}
257278
}
258279

@@ -723,6 +744,8 @@ export function useDraft(
723744
) => Promise<void>
724745
removeFile: () => void
725746
clearDraftStateButKeepTextareaValue: () => void
747+
clearDraftStateAndUpdateTextareaValue: () => void
748+
setDraftStateAndUpdateTextareaValue: (newValue: DraftObject) => void
726749
} {
727750
const [
728751
draftState,
@@ -748,6 +771,17 @@ export function useDraft(
748771
const draftRef = useRef<DraftObject>(emptyDraft(chatId))
749772
draftRef.current = draftState
750773

774+
/**
775+
* @see {@link _setDraftStateButKeepTextareaValue}.
776+
*/
777+
const setDraftStateAndUpdateTextareaValue = useCallback(
778+
(newValue: DraftObject) => {
779+
_setDraftStateButKeepTextareaValue(newValue)
780+
inputRef.current?.setText(newValue.text)
781+
},
782+
[inputRef]
783+
)
784+
751785
/**
752786
* Reset `draftState` to "empty draft" value,
753787
* but don't save it to backend and don't change the value
@@ -756,6 +790,12 @@ export function useDraft(
756790
const clearDraftStateButKeepTextareaValue = useCallback(() => {
757791
_setDraftStateButKeepTextareaValue(_ => emptyDraft(chatId))
758792
}, [chatId])
793+
/**
794+
* @see {@link clearDraftStateButKeepTextareaValue}
795+
*/
796+
const clearDraftStateAndUpdateTextareaValue = useCallback(() => {
797+
setDraftStateAndUpdateTextareaValue(emptyDraft(chatId))
798+
}, [chatId, setDraftStateAndUpdateTextareaValue])
759799

760800
const loadDraft = useCallback(
761801
(chatId: number) => {
@@ -807,11 +847,6 @@ export function useDraft(
807847
if (chatId === null || !canSend) {
808848
return
809849
}
810-
if (inputRef.current?.textareaRef.current?.disabled) {
811-
// Guard against strange races
812-
log.warn('Do not save draft while sending')
813-
return
814-
}
815850
const accountId = selectedAccountId()
816851

817852
const draft = draftRef.current
@@ -993,6 +1028,8 @@ export function useDraft(
9931028
addFileToDraft,
9941029
removeFile,
9951030
clearDraftStateButKeepTextareaValue,
1031+
clearDraftStateAndUpdateTextareaValue,
1032+
setDraftStateAndUpdateTextareaValue,
9961033
}
9971034
}
9981035

packages/frontend/src/components/message/MessageListAndComposer.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@ export default function MessageListAndComposer({ accountId, chat }: Props) {
108108
addFileToDraft,
109109
removeFile,
110110
clearDraftStateButKeepTextareaValue,
111+
clearDraftStateAndUpdateTextareaValue,
112+
setDraftStateAndUpdateTextareaValue,
111113
} = useDraft(
112114
accountId,
113115
chat.id,
@@ -327,6 +329,12 @@ export default function MessageListAndComposer({ accountId, chat }: Props) {
327329
clearDraftStateButKeepTextareaValue={
328330
clearDraftStateButKeepTextareaValue
329331
}
332+
clearDraftStateAndUpdateTextareaValue={
333+
clearDraftStateAndUpdateTextareaValue
334+
}
335+
setDraftStateAndUpdateTextareaValue={
336+
setDraftStateAndUpdateTextareaValue
337+
}
330338
/>
331339
</div>
332340
)

0 commit comments

Comments
 (0)