diff --git a/js/conversation_controller.js b/js/conversation_controller.js index 127e2946cf4..e0bcb33dbaa 100644 --- a/js/conversation_controller.js +++ b/js/conversation_controller.js @@ -8,6 +8,8 @@ window.Whisper = window.Whisper || {}; + const MAX_MESSAGE_BODY_LENGTH = 64 * 1024; + const conversations = new Whisper.ConversationCollection(); const inboxCollection = new (Backbone.Collection.extend({ initialize() { @@ -183,12 +185,25 @@ this._initialFetchComplete = true; await Promise.all( - conversations.map(conversation => { + conversations.map(async conversation => { if (!conversation.get('lastMessage')) { - return conversation.updateLastMessage(); + await conversation.updateLastMessage(); } - return null; + // In case a too-large draft was saved to the database + const draft = conversation.get('draft'); + if (draft && draft.length > MAX_MESSAGE_BODY_LENGTH) { + this.model.set({ + draft: draft.slice(0, MAX_MESSAGE_BODY_LENGTH), + }); + await window.Signal.Data.updateConversation( + conversation.id, + conversation.attributes, + { + Conversation: Whisper.Conversation, + } + ); + } }) ); window.log.info('ConversationController: done with initial fetch'); diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index 0ed600dbfe3..1fcbb131708 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -323,6 +323,7 @@ onSubmit: message => this.sendMessage(message), onEditorStateChange: (msg, caretLocation) => this.onEditorStateChange(msg, caretLocation), + onTextTooLong: () => this.showToast(Whisper.MessageBodyTooLongToast), onChooseAttachment: this.onChooseAttachment.bind(this), micCellEl, attachmentListEl, diff --git a/ts/components/CompositionArea.tsx b/ts/components/CompositionArea.tsx index 8ab0920cc5a..6d83d1a21a0 100644 --- a/ts/components/CompositionArea.tsx +++ b/ts/components/CompositionArea.tsx @@ -38,7 +38,11 @@ export type OwnProps = { export type Props = Pick< CompositionInputProps, - 'onSubmit' | 'onEditorSizeChange' | 'onEditorStateChange' | 'startingText' + | 'onSubmit' + | 'onEditorSizeChange' + | 'onEditorStateChange' + | 'onTextTooLong' + | 'startingText' > & Pick< EmojiButtonProps, @@ -76,6 +80,7 @@ export const CompositionArea = ({ compositionApi, onEditorSizeChange, onEditorStateChange, + onTextTooLong, startingText, // EmojiButton onPickEmoji, @@ -336,6 +341,7 @@ export const CompositionArea = ({ onSubmit={handleSubmit} onEditorSizeChange={onEditorSizeChange} onEditorStateChange={onEditorStateChange} + onTextTooLong={onTextTooLong} onDirtyChange={setDirty} skinTone={skinTone} startingText={startingText} diff --git a/ts/components/CompositionInput.tsx b/ts/components/CompositionInput.tsx index d0b129f6d62..7645162244f 100644 --- a/ts/components/CompositionInput.tsx +++ b/ts/components/CompositionInput.tsx @@ -29,6 +29,7 @@ import { } from './emoji/lib'; import { LocalizerType } from '../types/Util'; +const MAX_LENGTH = 64 * 1024; const colonsRegex = /(?:^|\s):[a-z0-9-_+]+:?/gi; const triggerEmojiRegex = /^(?:[-+]\d|[a-z]{2})/i; @@ -43,6 +44,7 @@ export type Props = { onDirtyChange?(dirty: boolean): unknown; onEditorStateChange?(messageText: string, caretLocation: number): unknown; onEditorSizeChange?(rect: ContentRect): unknown; + onTextTooLong(): unknown; onPickEmoji(o: EmojiPickDataType): unknown; onSubmit(message: string): unknown; }; @@ -78,6 +80,43 @@ function getTrimmedMatchAtIndex(str: string, index: number, pattern: RegExp) { return null; } +function getLengthOfSelectedText(state: EditorState): number { + const currentSelection = state.getSelection(); + let length = 0; + + const currentContent = state.getCurrentContent(); + const startKey = currentSelection.getStartKey(); + const endKey = currentSelection.getEndKey(); + const startBlock = currentContent.getBlockForKey(startKey); + const isStartAndEndBlockAreTheSame = startKey === endKey; + const startBlockTextLength = startBlock.getLength(); + const startSelectedTextLength = + startBlockTextLength - currentSelection.getStartOffset(); + const endSelectedTextLength = currentSelection.getEndOffset(); + const keyAfterEnd = currentContent.getKeyAfter(endKey); + + if (isStartAndEndBlockAreTheSame) { + length += + currentSelection.getEndOffset() - currentSelection.getStartOffset(); + } else { + let currentKey = startKey; + + while (currentKey && currentKey !== keyAfterEnd) { + if (currentKey === startKey) { + length += startSelectedTextLength + 1; + } else if (currentKey === endKey) { + length += endSelectedTextLength; + } else { + length += currentContent.getBlockForKey(currentKey).getLength() + 1; + } + + currentKey = currentContent.getKeyAfter(currentKey); + } + } + + return length; +} + function getWordAtIndex(str: string, index: number) { const start = str .slice(0, index + 1) @@ -172,6 +211,7 @@ export const CompositionInput = ({ onDirtyChange, onEditorStateChange, onEditorSizeChange, + onTextTooLong, onPickEmoji, onSubmit, skinTone, @@ -298,6 +338,51 @@ export const CompositionInput = ({ ] ); + const handleBeforeInput = React.useCallback( + (): DraftHandleValue => { + if (!editorStateRef.current) { + return 'not-handled'; + } + + const editorState = editorStateRef.current; + const plainText = editorState.getCurrentContent().getPlainText(); + const selectedTextLength = getLengthOfSelectedText(editorState); + + if (plainText.length - selectedTextLength > MAX_LENGTH - 1) { + onTextTooLong(); + + return 'handled'; + } + + return 'not-handled'; + }, + [onTextTooLong, editorStateRef] + ); + + const handlePastedText = React.useCallback( + (pastedText: string): DraftHandleValue => { + if (!editorStateRef.current) { + return 'not-handled'; + } + + const editorState = editorStateRef.current; + const plainText = editorState.getCurrentContent().getPlainText(); + const selectedTextLength = getLengthOfSelectedText(editorState); + + if ( + plainText.length + pastedText.length - selectedTextLength > + MAX_LENGTH + ) { + onTextTooLong(); + + return 'handled'; + } + + return 'not-handled'; + }, + [onTextTooLong, editorStateRef] + ); + const resetEditorState = React.useCallback( () => { const newEmptyState = EditorState.createEmpty(compositeDecorator); @@ -694,6 +779,8 @@ export const CompositionInput = ({ onEscape={handleEscapeKey} onTab={onTab} handleKeyCommand={handleEditorCommand} + handleBeforeInput={handleBeforeInput} + handlePastedText={handlePastedText} keyBindingFn={editorKeybindingFn} spellCheck={true} stripPastedStyles={true} diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 0ba7a221427..74107b39cf4 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -164,7 +164,7 @@ "rule": "jQuery-load(", "path": "js/conversation_controller.js", "line": " async load() {", - "lineNumber": 169, + "lineNumber": 171, "reasonCategory": "falseMatch", "updated": "2019-07-31T00:19:18.696Z" }, @@ -172,7 +172,7 @@ "rule": "jQuery-load(", "path": "js/conversation_controller.js", "line": " this._initialPromise = load();", - "lineNumber": 204, + "lineNumber": 219, "reasonCategory": "falseMatch", "updated": "2019-07-31T00:19:18.696Z" }, @@ -7475,7 +7475,7 @@ "rule": "DOM-innerHTML", "path": "ts/components/CompositionArea.tsx", "line": " el.innerHTML = '';", - "lineNumber": 65, + "lineNumber": 69, "reasonCategory": "usageTrusted", "updated": "2019-08-01T14:10:37.481Z", "reasonDetail": "Our code, no user input, only clearing out the dom"