Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## [Unreleased][unreleased]

### Fixed
- fix: remove the option to select info messages with Ctrl + Up/Down #5337

<a id="2_22_0"></a>

## [2.22.0] - 2025-10-17
Expand Down
335 changes: 1 addition & 334 deletions packages/frontend/src/components/composer/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,13 @@ import {
Type,
} from '../../backend-com'
import { selectedAccountId } from '../../ScreenController'
import { MessageTypeAttachmentSubset } from '../attachment/Attachment'
import { runtime } from '@deltachat-desktop/runtime-interface'
import { confirmDialog } from '../message/messageFunctions'
import useDialog from '../../hooks/dialog/useDialog'
import useTranslationFunction from '../../hooks/useTranslationFunction'
import useMessage from '../../hooks/chat/useMessage'
import useChat from '../../hooks/chat/useChat'
import { useDraft, type DraftObject } from '../../hooks/chat/useDraft'

import type { EmojiData, BaseEmoji } from 'emoji-mart/index'
import { VisualVCardComponent } from '../message/VCard'
Expand All @@ -43,7 +43,6 @@ import { enterKeySendsKeyboardShortcuts } from '../KeyboardShortcutHint'
import { AppPicker } from '../AppPicker'
import { AppInfo, AppStoreUrl } from '../AppPicker'
import OutsideClickHelper from '../OutsideClickHelper'
import { basename } from 'path'
import { useHasChanged2 } from '../../hooks/useHasChanged'
import { ScreenContext } from '../../contexts/ScreenContext'
import {
Expand Down Expand Up @@ -740,338 +739,6 @@ const Composer = forwardRef<

export default Composer

export type DraftObject = { chatId: number } & Pick<
Type.Message,
'id' | 'text' | 'file' | 'quote' | 'viewType' | 'vcardContact'
> &
MessageTypeAttachmentSubset

function emptyDraft(chatId: number | null): DraftObject {
return {
id: 0,
chatId: chatId || 0,
text: '',
file: null,
fileBytes: 0,
fileMime: null,
fileName: null,
quote: null,
viewType: 'Text',
vcardContact: null,
}
}

export function useDraft(
accountId: number,
chatId: number | null,
isContactRequest: boolean,
canSend: boolean, // no draft needed in chats we can't send messages
inputRef: React.RefObject<ComposerMessageInput | null>
): {
draftState: DraftObject
onSelectReplyToShortcut: (
upOrDown:
| KeybindAction.Composer_SelectReplyToUp
| KeybindAction.Composer_SelectReplyToDown
) => void
removeQuote: () => void
updateDraftText: (text: string, InputChatId: number) => void
addFileToDraft: (
file: string,
fileName: string,
viewType: T.Viewtype
) => Promise<void>
removeFile: () => void
clearDraftStateButKeepTextareaValue: () => void
clearDraftStateAndUpdateTextareaValue: () => void
setDraftStateAndUpdateTextareaValue: (newValue: DraftObject) => void
} {
const [
draftState,
/**
* Set `draftState`, but don't update the value of the message textarea
* (because it's managed by a separate piece of state).
*
* This will not save the draft to the backend.
*/
_setDraftStateButKeepTextareaValue,
] = useState<DraftObject>(emptyDraft(chatId))
/**
* `draftRef.current` gets set to `draftState` on every render.
* That is, when you mutate the value of this ref,
* `draftState` also gets mutated.
*
* Having this `ref` is just a hack to perform direct state mutations
* without triggering a re-render or linter's warnings about the missing
* `draftState` hook dependency.
*
* TODO figure out why this is needed.
*/
const draftRef = useRef<DraftObject>(emptyDraft(chatId))
draftRef.current = draftState

/**
* @see {@link _setDraftStateButKeepTextareaValue}.
*/
const setDraftStateAndUpdateTextareaValue = useCallback(
(newValue: DraftObject) => {
_setDraftStateButKeepTextareaValue(newValue)
inputRef.current?.setText(newValue.text)
},
[inputRef]
)

/**
* Reset `draftState` to "empty draft" value,
* but don't save it to backend and don't change the value
* of the textarea.
*/
const clearDraftStateButKeepTextareaValue = useCallback(() => {
_setDraftStateButKeepTextareaValue(_ => emptyDraft(chatId))
}, [chatId])
/**
* @see {@link clearDraftStateButKeepTextareaValue}
*/
const clearDraftStateAndUpdateTextareaValue = useCallback(() => {
setDraftStateAndUpdateTextareaValue(emptyDraft(chatId))
}, [chatId, setDraftStateAndUpdateTextareaValue])

const loadDraft = useCallback(
(chatId: number) => {
if (chatId === null || !canSend) {
clearDraftStateButKeepTextareaValue()
return
}
inputRef.current?.setState({ loadingDraft: true })
BackendRemote.rpc.getDraft(selectedAccountId(), chatId).then(newDraft => {
if (!newDraft) {
log.debug('no draft')
clearDraftStateButKeepTextareaValue()
inputRef.current?.setText('')
} else {
_setDraftStateButKeepTextareaValue(old => ({
...old,
id: newDraft.id,
text: newDraft.text || '',
file: newDraft.file,
fileBytes: newDraft.fileBytes,
fileMime: newDraft.fileMime,
fileName: newDraft.fileName,
viewType: newDraft.viewType,
quote: newDraft.quote,
vcardContact: newDraft.vcardContact,
}))
inputRef.current?.setText(newDraft.text)
}
inputRef.current?.setState({ loadingDraft: false })
setTimeout(() => {
inputRef.current?.focus()
})
})
},
[clearDraftStateButKeepTextareaValue, inputRef, canSend]
)

useEffect(() => {
log.debug('reloading chat because id changed', chatId)
//load
loadDraft(chatId || 0)
window.__reloadDraft = loadDraft.bind(null, chatId || 0)
return () => {
window.__reloadDraft = null
}
}, [chatId, loadDraft, isContactRequest])

const saveDraft = useCallback(async () => {
if (chatId === null || !canSend) {
return
}
const accountId = selectedAccountId()

const draft = draftRef.current
const oldChatId = chatId
if (
(draft.text && draft.text.length > 0) ||
(draft.file && draft.file != '') ||
!!draft.quote
) {
const fileName =
draft.fileName ?? (draft.file ? basename(draft.file) : null)
await BackendRemote.rpc.miscSetDraft(
accountId,
chatId,
draft.text,
draft.file !== '' ? draft.file : null,
fileName ?? null,
draft.quote?.kind === 'WithMessage' ? draft.quote.messageId : null,
draft.viewType
)
} else {
await BackendRemote.rpc.removeDraft(accountId, chatId)
}

if (oldChatId !== chatId) {
log.debug('switched chat no reloading of draft required')
return
}
const newDraft = chatId
? await BackendRemote.rpc.getDraft(accountId, chatId)
: null
if (newDraft) {
_setDraftStateButKeepTextareaValue(old => ({
...old,
id: newDraft.id,
file: newDraft.file,
fileBytes: newDraft.fileBytes,
fileMime: newDraft.fileMime,
fileName: newDraft.fileName,
viewType: newDraft.viewType,
quote: newDraft.quote,
vcardContact: newDraft.vcardContact,
}))
// don't load text to prevent bugging back
} else {
clearDraftStateButKeepTextareaValue()
}
inputRef.current?.setState({ loadingDraft: false })
}, [chatId, clearDraftStateButKeepTextareaValue, canSend, inputRef])

const updateDraftText = (text: string, InputChatId: number) => {
if (chatId !== InputChatId) {
log.warn("chat Id and InputChatId don't match, do nothing")
} else {
if (draftRef.current) {
draftRef.current.text = text // don't need to rerender on text change
}
saveDraft()
}
}

const removeQuote = useCallback(() => {
if (draftRef.current) {
draftRef.current.quote = null
}
saveDraft()
inputRef.current?.focus()
}, [inputRef, saveDraft])

const removeFile = useCallback(() => {
draftRef.current.file = ''
draftRef.current.viewType = 'Text'
saveDraft()
inputRef.current?.focus()
}, [inputRef, saveDraft])

const addFileToDraft = useCallback(
async (file: string, fileName: string, viewType: T.Viewtype) => {
draftRef.current.file = file
draftRef.current.fileName = fileName
draftRef.current.viewType = viewType
inputRef.current?.focus()
return saveDraft()
},
[inputRef, saveDraft]
)

const { jumpToMessage } = useMessage()
const onSelectReplyToShortcut = async (
upOrDown:
| KeybindAction.Composer_SelectReplyToUp
| KeybindAction.Composer_SelectReplyToDown
) => {
if (chatId == undefined || !canSend) {
return
}
const quoteMessage = (messageId: number) => {
draftRef.current.quote = {
kind: 'WithMessage',
messageId,
} as Type.MessageQuote
saveDraft()

// TODO perf: jumpToMessage is not instant, but it should be
// since the message is (almost?) always already rendered.
jumpToMessage({
accountId,
msgId: messageId,
msgChatId: chatId,
highlight: true,
focus: false,
// The message is usually already in view,
// so let's not scroll at all if so.
scrollIntoViewArg: { block: 'nearest' },
})
}
// TODO perf: I imagine this is pretty slow, given IPC and some chats
// being quite large. Perhaps we could hook into the
// MessageList component, or share the list of messages with it.
// If not, at least cache this list. Use the cached version first,
// then, when the Promise resolves, execute this code again in case
// the message list got updated so that it feels more reponsive.
const messageIds = await BackendRemote.rpc.getMessageIds(
accountId,
chatId,
false,
false
)
const currQuote = draftRef.current.quote
if (!currQuote) {
if (upOrDown === KeybindAction.Composer_SelectReplyToUp) {
quoteMessage(messageIds[messageIds.length - 1])
}
return
}
if (currQuote.kind !== 'WithMessage') {
// Or shall we override with the last message?
return
}
const currQuoteMessageIdInd = messageIds.lastIndexOf(currQuote.messageId)
if (
currQuoteMessageIdInd === messageIds.length - 1 && // Last message
upOrDown === KeybindAction.Composer_SelectReplyToDown
) {
removeQuote()
return
}
const newId: number | undefined =
messageIds[
upOrDown === KeybindAction.Composer_SelectReplyToUp
? currQuoteMessageIdInd - 1
: currQuoteMessageIdInd + 1
]
if (newId == undefined) {
return
}
quoteMessage(newId)
}

useEffect(() => {
window.__setQuoteInDraft = (messageId: number) => {
draftRef.current.quote = {
kind: 'WithMessage',
messageId,
} as Partial<Type.MessageQuote> as any as Type.MessageQuote
saveDraft()
inputRef.current?.focus()
}
return () => {
window.__setQuoteInDraft = null
}
}, [draftRef, inputRef, saveDraft])

return {
draftState,
onSelectReplyToShortcut,
removeQuote,
updateDraftText,
addFileToDraft,
removeFile,
clearDraftStateButKeepTextareaValue,
clearDraftStateAndUpdateTextareaValue,
setDraftStateAndUpdateTextareaValue,
}
}

function useMessageEditing(
accountId: number,
chatId: T.BasicChat['id'],
Expand Down
Loading