feat: disable composition for outgoing message requests

moved composition textarea to functional component
pull/3083/head
William Grant 1 year ago
parent d6ad37ec38
commit d74edc5b24

@ -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<Props, State> {
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<Props, State> {
// 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<Props, State> {
}}
data-testid="message-input"
>
{this.renderTextArea()}
<CompositionTextArea
draft={this.state.draft}
setDraft={newDraft => {
this.setState({ draft: newDraft });
}}
container={this.container}
textAreaRef={this.textarea}
fetchUsersForGroup={this.fetchUsersForGroup}
typingEnabled={this.props.typingEnabled}
onKeyDown={this.onKeyDown}
/>
</StyledSendMessageInput>
{typingEnabled && (
<ToggleEmojiButton ref={this.emojiPanelButton} onClick={this.toggleEmojiPanel} />
@ -471,78 +450,6 @@ class CompositionBoxInner extends Component<Props, State> {
}
/* 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 (
<MentionsInput
value={draft}
onChange={this.onChange}
// eslint-disable-next-line @typescript-eslint/no-misused-promises
onKeyDown={this.onKeyDown}
// eslint-disable-next-line @typescript-eslint/no-misused-promises
onKeyUp={this.onKeyUp}
placeholder={messagePlaceHolder}
spellCheck={true}
dir={htmlDirection}
inputRef={this.textarea}
disabled={!typingEnabled}
rows={1}
data-testid="message-input-text-area"
style={style}
suggestionsPortalHost={this.container as any}
forceSuggestionsAboveCursor={true} // force mentions to be rendered on top of the cursor, this is working with a fork of react-mentions for now
>
<Mention
appendSpaceOnAdd={true}
// this will be cleaned on cleanMentions()
markup="@ᅭ__id__ᅲ__display__ᅭ" // ᅭ = \uFFD2 is one of the forbidden char for a display name (check displayNameRegex)
trigger="@"
// this is only for the composition box visible content. The real stuff on the backend box is the @markup
displayTransform={(_id, display) =>
htmlDirection === 'rtl' ? `${display}@` : `@${display}`
}
data={this.fetchUsersForGroup}
renderSuggestion={renderUserMentionRow}
/>
<Mention
trigger=":"
markup="__id__"
appendSpaceOnAdd={true}
regex={neverMatchingRegex}
data={searchEmojiForQuery}
renderSuggestion={renderEmojiQuickResultRow}
/>
</MentionsInput>
);
}
private fetchUsersForOpenGroup(
query: string,
callback: (data: Array<SuggestionDataItem>) => void
@ -885,25 +792,6 @@ class CompositionBoxInner extends Component<Props, State> {
});
}
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<Props, State> {
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');

@ -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 (
<StyledChatButtonContainer>
<StyledChatButtonContainer disabled={isOutgoingRequest}>
<SessionIconButton
iconType="plusThin"
backgroundColor={'var(--chat-buttons-background-color)'}
@ -26,14 +33,18 @@ export const AddStagedAttachmentButton = (props: { onClick: () => void }) => {
iconPadding="8px"
onClick={props.onClick}
dataTestId="attachments-button"
disabled={isOutgoingRequest}
/>
</StyledChatButtonContainer>
);
};
export const StartRecordingButton = (props: { onClick: () => void }) => {
const selectedConvoKey = useSelectedConversationKey();
const isOutgoingRequest = useIsOutgoingRequest(selectedConvoKey);
return (
<StyledChatButtonContainer>
<StyledChatButtonContainer disabled={isOutgoingRequest}>
<SessionIconButton
iconType="microphone"
iconSize={'huge2'}
@ -42,6 +53,7 @@ export const StartRecordingButton = (props: { onClick: () => void }) => {
borderRadius="300px"
iconPadding="6px"
onClick={props.onClick}
disabled={isOutgoingRequest}
dataTestId="microphone-button"
/>
</StyledChatButtonContainer>
@ -51,8 +63,11 @@ export const StartRecordingButton = (props: { onClick: () => void }) => {
// eslint-disable-next-line react/display-name
export const ToggleEmojiButton = forwardRef<HTMLButtonElement, { onClick: () => void }>(
(props, ref) => {
const selectedConvoKey = useSelectedConversationKey();
const isOutgoingRequest = useIsOutgoingRequest(selectedConvoKey);
return (
<StyledChatButtonContainer>
<StyledChatButtonContainer disabled={isOutgoingRequest}>
<SessionIconButton
iconType="emoji"
ref={ref}
@ -62,6 +77,7 @@ export const ToggleEmojiButton = forwardRef<HTMLButtonElement, { onClick: () =>
borderRadius="300px"
iconPadding="6px"
onClick={props.onClick}
disabled={isOutgoingRequest}
dataTestId="emoji-button"
/>
</StyledChatButtonContainer>
@ -70,8 +86,11 @@ export const ToggleEmojiButton = forwardRef<HTMLButtonElement, { onClick: () =>
);
export const SendMessageButton = (props: { onClick: () => void }) => {
const selectedConvoKey = useSelectedConversationKey();
const isOutgoingRequest = useIsOutgoingRequest(selectedConvoKey);
return (
<StyledChatButtonContainer>
<StyledChatButtonContainer disabled={isOutgoingRequest}>
<SessionIconButton
iconType="send"
backgroundColor={'var(--chat-buttons-background-color)'}
@ -81,6 +100,7 @@ export const SendMessageButton = (props: { onClick: () => void }) => {
borderRadius="300px"
iconPadding="6px"
onClick={props.onClick}
disabled={isOutgoingRequest}
dataTestId="send-message-button"
/>
</StyledChatButtonContainer>

@ -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<HTMLTextAreaElement>;
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 (
<MentionsInput
value={draft}
onChange={handleOnChange}
// eslint-disable-next-line @typescript-eslint/no-misused-promises
onKeyDown={onKeyDown}
// eslint-disable-next-line @typescript-eslint/no-misused-promises
onKeyUp={handleKeyUp}
placeholder={messagePlaceHolder}
spellCheck={true}
dir={htmlDirection}
inputRef={textAreaRef}
disabled={!typingEnabled || isOutgoingRequest}
rows={1}
data-testid="message-input-text-area"
style={style}
suggestionsPortalHost={container as any}
forceSuggestionsAboveCursor={true} // force mentions to be rendered on top of the cursor, this is working with a fork of react-mentions for now
>
<Mention
appendSpaceOnAdd={true}
// this will be cleaned on cleanMentions()
markup="@ᅭ__id__ᅲ__display__ᅭ" // ᅭ = \uFFD2 is one of the forbidden char for a display name (check displayNameRegex)
trigger="@"
// this is only for the composition box visible content. The real stuff on the backend box is the @markup
displayTransform={(_id, display) =>
htmlDirection === 'rtl' ? `${display}@` : `@${display}`
}
data={fetchUsersForGroup}
renderSuggestion={renderUserMentionRow}
/>
<Mention
trigger=":"
markup="__id__"
appendSpaceOnAdd={true}
regex={neverMatchingRegex}
data={searchEmojiForQuery}
renderSuggestion={renderEmojiQuickResultRow}
/>
</MentionsInput>
);
};

@ -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<HTMLButtonElement, SProps>((props, ref
style,
tabIndex,
children,
disabled,
} = props;
const clickHandler = (e: MouseEvent<HTMLButtonElement>) => {
if (props.onClick) {
if (!disabled && props.onClick) {
e.stopPropagation();
props.onClick(e);
}
};
const keyPressHandler = (e: KeyboardEvent<HTMLButtonElement>) => {
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<HTMLButtonElement, SProps>((props, ref
}}
tabIndex={tabIndex}
onKeyDown={keyPressHandler}
disabled={disabled}
data-testid={dataTestId}
>
<SessionIcon

Loading…
Cancel
Save