From d74edc5b249f4c3c602882fc0da80bd96070a2c1 Mon Sep 17 00:00:00 2001 From: William Grant Date: Mon, 6 May 2024 15:53:45 +1000 Subject: [PATCH] feat: disable composition for outgoing message requests moved composition textarea to functional component --- .../composition/CompositionBox.tsx | 149 ++--------------- .../composition/CompositionButtons.tsx | 34 +++- .../composition/CompositionTextArea.tsx | 153 ++++++++++++++++++ ts/components/icon/SessionIconButton.tsx | 11 +- 4 files changed, 202 insertions(+), 145 deletions(-) create mode 100644 ts/components/conversation/composition/CompositionTextArea.tsx diff --git a/ts/components/conversation/composition/CompositionBox.tsx b/ts/components/conversation/composition/CompositionBox.tsx index e6da1fe9a..f8f78895d 100644 --- a/ts/components/conversation/composition/CompositionBox.tsx +++ b/ts/components/conversation/composition/CompositionBox.tsx @@ -4,7 +4,7 @@ import { connect } from 'react-redux'; import styled from 'styled-components'; import { AbortController } from 'abort-controller'; -import { Mention, MentionsInput, SuggestionDataItem } from 'react-mentions'; +import { SuggestionDataItem } from 'react-mentions'; import autoBind from 'auto-bind'; import { Component, RefObject, createRef } from 'react'; @@ -56,13 +56,8 @@ import { StartRecordingButton, ToggleEmojiButton, } from './CompositionButtons'; -import { renderEmojiQuickResultRow, searchEmojiForQuery } from './EmojiQuickResult'; -import { - cleanMentions, - mentionsRegex, - renderUserMentionRow, - styleForCompositionBoxSuggestions, -} from './UserMentions'; +import { CompositionTextArea } from './CompositionTextArea'; +import { cleanMentions, mentionsRegex } from './UserMentions'; export interface ReplyingToMessageProps { convoId: string; @@ -122,30 +117,6 @@ interface State { showCaptionEditor?: AttachmentType; } -const sendMessageStyle = (dir?: HTMLDirection) => { - return { - control: { - wordBreak: 'break-all', - }, - input: { - overflow: 'auto', - maxHeight: '50vh', - wordBreak: 'break-word', - padding: '0px', - margin: '0px', - }, - highlighter: { - boxSizing: 'border-box', - overflow: 'hidden', - maxHeight: '50vh', - }, - flexGrow: 1, - minHeight: '24px', - width: '100%', - ...styleForCompositionBoxSuggestions(dir), - }; -}; - const getDefaultState = (newConvoId?: string) => { return { draft: getDraftForConversation(newConvoId), @@ -276,7 +247,6 @@ class CompositionBoxInner extends Component { private readonly emojiPanelButton: any; private linkPreviewAbortController?: AbortController; private container: HTMLDivElement | null; - private lastBumpTypingMessageLength: number = 0; constructor(props: Props) { super(props); @@ -312,7 +282,6 @@ class CompositionBoxInner extends Component { // reset the state on new conversation key if (prevProps.selectedConversationKey !== this.props.selectedConversationKey) { this.setState(getDefaultState(this.props.selectedConversationKey), this.focusCompositionBox); - this.lastBumpTypingMessageLength = 0; } else if (this.props.stagedAttachments?.length !== prevProps.stagedAttachments?.length) { // if number of staged attachment changed, focus the composition box for a more natural UI this.focusCompositionBox(); @@ -450,7 +419,17 @@ class CompositionBoxInner extends Component { }} data-testid="message-input" > - {this.renderTextArea()} + { + this.setState({ draft: newDraft }); + }} + container={this.container} + textAreaRef={this.textarea} + fetchUsersForGroup={this.fetchUsersForGroup} + typingEnabled={this.props.typingEnabled} + onKeyDown={this.onKeyDown} + /> {typingEnabled && ( @@ -471,78 +450,6 @@ class CompositionBoxInner extends Component { } /* eslint-enable @typescript-eslint/no-misused-promises */ - private renderTextArea() { - const { i18n } = window; - const { draft } = this.state; - const { htmlDirection } = this.props; - - if (!this.props.selectedConversation) { - return null; - } - - const makeMessagePlaceHolderText = () => { - if (isKickedFromGroup) { - return i18n('youGotKickedFromGroup'); - } - if (left) { - return i18n('youLeftTheGroup'); - } - if (isBlocked) { - return i18n('unblockToSend'); - } - return i18n('sendMessage'); - }; - - const { isKickedFromGroup, left, isBlocked } = this.props.selectedConversation; - const messagePlaceHolder = makeMessagePlaceHolderText(); - const { typingEnabled } = this.props; - const neverMatchingRegex = /($a)/; - - const style = sendMessageStyle(htmlDirection); - - return ( - - - htmlDirection === 'rtl' ? `${display}@` : `@${display}` - } - data={this.fetchUsersForGroup} - renderSuggestion={renderUserMentionRow} - /> - - - ); - } - private fetchUsersForOpenGroup( query: string, callback: (data: Array) => void @@ -885,25 +792,6 @@ class CompositionBoxInner extends Component { }); } - private async onKeyUp() { - if (!this.props.selectedConversationKey) { - throw new Error('selectedConversationKey is needed'); - } - const { draft } = this.state; - // Called whenever the user changes the message composition field. But only - // fires if there's content in the message field after the change. - // Also, check for a message length change before firing it up, to avoid - // catching ESC, tab, or whatever which is not typing - if (draft && draft.length && draft.length !== this.lastBumpTypingMessageLength) { - const conversationModel = getConversationController().get(this.props.selectedConversationKey); - if (!conversationModel) { - return; - } - conversationModel.throttledBumpTyping(); - this.lastBumpTypingMessageLength = draft.length; - } - } - private async onSendMessage() { if (!this.props.selectedConversationKey) { throw new Error('selectedConversationKey is needed'); @@ -1085,15 +973,6 @@ class CompositionBoxInner extends Component { this.setState({ showRecordingView: false }); } - private onChange(event: any) { - if (!this.props.selectedConversationKey) { - throw new Error('selectedConversationKey is needed'); - } - const draft = event.target.value ?? ''; - this.setState({ draft }); - updateDraftForConversation({ conversationKey: this.props.selectedConversationKey, draft }); - } - private onEmojiClick(emoji: FixedBaseEmoji) { if (!this.props.selectedConversationKey) { throw new Error('selectedConversationKey is needed'); diff --git a/ts/components/conversation/composition/CompositionButtons.tsx b/ts/components/conversation/composition/CompositionButtons.tsx index 4a5d3cb89..565a6d209 100644 --- a/ts/components/conversation/composition/CompositionButtons.tsx +++ b/ts/components/conversation/composition/CompositionButtons.tsx @@ -1,22 +1,29 @@ import { forwardRef } from 'react'; import styled from 'styled-components'; +import { useIsOutgoingRequest } from '../../../hooks/useParamSelector'; +import { useSelectedConversationKey } from '../../../state/selectors/selectedConversation'; import { SessionIconButton } from '../../icon'; -const StyledChatButtonContainer = styled.div` +const StyledChatButtonContainer = styled.div<{ disabled?: boolean }>` .session-icon-button { svg { background-color: var(--chat-buttons-background-color); } - &:hover svg { + ${props => + !props.disabled && + `&:hover svg { background-color: var(--chat-buttons-background-hover-color); - } + }`} } `; export const AddStagedAttachmentButton = (props: { onClick: () => void }) => { + const selectedConvoKey = useSelectedConversationKey(); + const isOutgoingRequest = useIsOutgoingRequest(selectedConvoKey); + return ( - + void }) => { iconPadding="8px" onClick={props.onClick} dataTestId="attachments-button" + disabled={isOutgoingRequest} /> ); }; export const StartRecordingButton = (props: { onClick: () => void }) => { + const selectedConvoKey = useSelectedConversationKey(); + const isOutgoingRequest = useIsOutgoingRequest(selectedConvoKey); + return ( - + void }) => { borderRadius="300px" iconPadding="6px" onClick={props.onClick} + disabled={isOutgoingRequest} dataTestId="microphone-button" /> @@ -51,8 +63,11 @@ export const StartRecordingButton = (props: { onClick: () => void }) => { // eslint-disable-next-line react/display-name export const ToggleEmojiButton = forwardRef void }>( (props, ref) => { + const selectedConvoKey = useSelectedConversationKey(); + const isOutgoingRequest = useIsOutgoingRequest(selectedConvoKey); + return ( - + borderRadius="300px" iconPadding="6px" onClick={props.onClick} + disabled={isOutgoingRequest} dataTestId="emoji-button" /> @@ -70,8 +86,11 @@ export const ToggleEmojiButton = forwardRef ); export const SendMessageButton = (props: { onClick: () => void }) => { + const selectedConvoKey = useSelectedConversationKey(); + const isOutgoingRequest = useIsOutgoingRequest(selectedConvoKey); + return ( - + void }) => { borderRadius="300px" iconPadding="6px" onClick={props.onClick} + disabled={isOutgoingRequest} dataTestId="send-message-button" /> diff --git a/ts/components/conversation/composition/CompositionTextArea.tsx b/ts/components/conversation/composition/CompositionTextArea.tsx new file mode 100644 index 000000000..e1a64c70d --- /dev/null +++ b/ts/components/conversation/composition/CompositionTextArea.tsx @@ -0,0 +1,153 @@ +import { RefObject, useState } from 'react'; +import { Mention, MentionsInput } from 'react-mentions'; +import { useIsOutgoingRequest } from '../../../hooks/useParamSelector'; +import { getConversationController } from '../../../session/conversations'; +import { + useSelectedConversationKey, + useSelectedIsBlocked, + useSelectedIsKickedFromGroup, + useSelectedIsLeft, +} from '../../../state/selectors/selectedConversation'; +import { HTMLDirection, useHTMLDirection } from '../../../util/i18n'; +import { updateDraftForConversation } from '../SessionConversationDrafts'; +import { renderEmojiQuickResultRow, searchEmojiForQuery } from './EmojiQuickResult'; +import { renderUserMentionRow, styleForCompositionBoxSuggestions } from './UserMentions'; + +const sendMessageStyle = (dir?: HTMLDirection) => { + return { + control: { + wordBreak: 'break-all', + }, + input: { + overflow: 'auto', + maxHeight: '50vh', + wordBreak: 'break-word', + padding: '0px', + margin: '0px', + }, + highlighter: { + boxSizing: 'border-box', + overflow: 'hidden', + maxHeight: '50vh', + }, + flexGrow: 1, + minHeight: '24px', + width: '100%', + ...styleForCompositionBoxSuggestions(dir), + }; +}; + +type Props = { + draft: string; + setDraft: (draft: string) => void; + container: HTMLDivElement | null; + textAreaRef: RefObject; + fetchUsersForGroup: (query: string, callback: (data: any) => void) => void; + typingEnabled: boolean; + onKeyDown: (event: any) => void; +}; + +export const CompositionTextArea = (props: Props) => { + const { draft, setDraft, container, textAreaRef, fetchUsersForGroup, typingEnabled, onKeyDown } = + props; + + const [lastBumpTypingMessageLength, setLastBumpTypingMessageLength] = useState(0); + + const selectedConversationKey = useSelectedConversationKey(); + const htmlDirection = useHTMLDirection(); + const isOutgoingRequest = useIsOutgoingRequest(selectedConversationKey); + const isKickedFromGroup = useSelectedIsKickedFromGroup(); + const left = useSelectedIsLeft(); + const isBlocked = useSelectedIsBlocked(); + + if (!selectedConversationKey) { + return null; + } + + const makeMessagePlaceHolderText = () => { + if (isKickedFromGroup) { + return window.i18n('youGotKickedFromGroup'); + } + if (left) { + return window.i18n('youLeftTheGroup'); + } + if (isBlocked) { + return window.i18n('unblockToSend'); + } + return window.i18n('sendMessage'); + }; + + const messagePlaceHolder = makeMessagePlaceHolderText(); + const neverMatchingRegex = /($a)/; + + const style = sendMessageStyle(htmlDirection); + + const handleOnChange = (event: any) => { + if (!selectedConversationKey) { + throw new Error('selectedConversationKey is needed'); + } + + const newDraft = event.target.value ?? ''; + setDraft(newDraft); + updateDraftForConversation({ conversationKey: selectedConversationKey, draft: newDraft }); + }; + + const handleKeyUp = async () => { + if (!selectedConversationKey) { + throw new Error('selectedConversationKey is needed'); + } + /** Called whenever the user changes the message composition field. But only fires if there's content in the message field after the change. + Also, check for a message length change before firing it up, to avoid catching ESC, tab, or whatever which is not typing + */ + if (draft && draft.length && draft.length !== lastBumpTypingMessageLength) { + const conversationModel = getConversationController().get(selectedConversationKey); + if (!conversationModel) { + return; + } + conversationModel.throttledBumpTyping(); + setLastBumpTypingMessageLength(draft.length); + } + }; + + return ( + + + htmlDirection === 'rtl' ? `${display}@` : `@${display}` + } + data={fetchUsersForGroup} + renderSuggestion={renderUserMentionRow} + /> + + + ); +}; diff --git a/ts/components/icon/SessionIconButton.tsx b/ts/components/icon/SessionIconButton.tsx index 65a082096..70d91b971 100644 --- a/ts/components/icon/SessionIconButton.tsx +++ b/ts/components/icon/SessionIconButton.tsx @@ -15,6 +15,7 @@ interface SProps extends SessionIconProps { title?: string; tabIndex?: number; children?: ReactNode; + disabled?: boolean; } const StyledSessionIconButton = styled.button<{ color?: string; isSelected?: boolean }>` @@ -32,8 +33,10 @@ const StyledSessionIconButton = styled.button<{ color?: string; isSelected?: boo };`} } + ${props => props.disabled && 'cursor: not-allowed;'} + &:hover svg path { - ${props => !props.color && 'fill: var(--button-icon-stroke-hover-color);'} + ${props => !props.disabled && !props.color && 'fill: var(--button-icon-stroke-hover-color);'} } `; @@ -61,15 +64,16 @@ const SessionIconButtonInner = forwardRef((props, ref style, tabIndex, children, + disabled, } = props; const clickHandler = (e: MouseEvent) => { - if (props.onClick) { + if (!disabled && props.onClick) { e.stopPropagation(); props.onClick(e); } }; const keyPressHandler = (e: KeyboardEvent) => { - if (e.currentTarget.tabIndex > -1 && e.key === 'Enter' && props.onClick) { + if (e.currentTarget.tabIndex > -1 && e.key === 'Enter' && !disabled && props.onClick) { e.stopPropagation(); props.onClick(); } @@ -92,6 +96,7 @@ const SessionIconButtonInner = forwardRef((props, ref }} tabIndex={tabIndex} onKeyDown={keyPressHandler} + disabled={disabled} data-testid={dataTestId} >