session-desktop/ts/components/session/conversation/SessionCompositionBox.tsx

972 lines
27 KiB
TypeScript

import React from 'react';
import _, { debounce } from 'lodash';
import { Attachment, AttachmentType } from '../../../types/Attachment';
import * as MIME from '../../../types/MIME';
import { SessionIconButton, SessionIconSize, SessionIconType } from '../icon';
import { SessionEmojiPanel } from './SessionEmojiPanel';
import { SessionRecording } from './SessionRecording';
import { SignalService } from '../../../protobuf';
import { Constants } from '../../../session';
import { toArray } from 'react-emoji-render';
import { Flex } from '../Flex';
import { AttachmentList } from '../../conversation/AttachmentList';
import { ToastUtils } from '../../../session/utils';
import { AttachmentUtil } from '../../../util';
import {
getPreview,
LINK_PREVIEW_TIMEOUT,
SessionStagedLinkPreview,
} from './SessionStagedLinkPreview';
import { AbortController } from 'abort-controller';
import { SessionQuotedMessageComposition } from './SessionQuotedMessageComposition';
import { Mention, MentionsInput } from 'react-mentions';
import { MemberItem } from '../../conversation/MemberList';
import { CaptionEditor } from '../../CaptionEditor';
export interface ReplyingToMessageProps {
convoId: string;
id: string;
author: string;
timestamp: number;
text?: string;
attachments?: Array<any>;
}
export interface StagedLinkPreviewData {
isLoaded: boolean;
title: string | null;
url: string | null;
domain: string | null;
description: string | null;
image?: AttachmentType;
}
export interface StagedAttachmentType extends AttachmentType {
file: File;
}
interface Props {
sendMessage: any;
onMessageSending: any;
onMessageSuccess: any;
onMessageFailure: any;
onLoadVoiceNoteView: any;
onExitVoiceNoteView: any;
isBlocked: boolean;
isPrivate: boolean;
isKickedFromGroup: boolean;
leftGroup: boolean;
conversationKey: string;
isPublic: boolean;
quotedMessageProps?: ReplyingToMessageProps;
removeQuotedMessage: () => void;
textarea: React.RefObject<HTMLDivElement>;
stagedAttachments: Array<StagedAttachmentType>;
clearAttachments: () => any;
removeAttachment: (toRemove: AttachmentType) => void;
onChoseAttachments: (newAttachments: Array<File>) => void;
}
interface State {
message: string;
showRecordingView: boolean;
mediaSetting: boolean | null;
showEmojiPanel: boolean;
voiceRecording?: Blob;
ignoredLink?: string; // set the the ignored url when users closed the link preview
stagedLinkPreview?: StagedLinkPreviewData;
showCaptionEditor?: AttachmentType;
}
const sendMessageStyle = {
control: {
wordBreak: 'break-all',
},
input: {
overflow: 'auto',
maxHeight: 70,
wordBreak: 'break-all',
padding: '0px',
margin: '0px',
},
highlighter: {
boxSizing: 'border-box',
overflow: 'hidden',
maxHeight: 70,
},
flexGrow: 1,
minHeight: '24px',
width: '100%',
};
const getDefaultState = () => {
return {
message: '',
voiceRecording: undefined,
showRecordingView: false,
mediaSetting: null,
showEmojiPanel: false,
ignoredLink: undefined,
stagedLinkPreview: undefined,
};
};
export class SessionCompositionBox extends React.Component<Props, State> {
private readonly textarea: React.RefObject<any>;
private readonly fileInput: React.RefObject<HTMLInputElement>;
private emojiPanel: any;
private linkPreviewAbortController?: AbortController;
private container: any;
private readonly mentionsRegex = /@\(05[0-9a-f]{64}\)/g;
constructor(props: any) {
super(props);
this.state = getDefaultState();
this.textarea = props.textarea;
this.fileInput = React.createRef();
// Emojis
this.emojiPanel = null;
this.toggleEmojiPanel = debounce(this.toggleEmojiPanel.bind(this), 100);
this.hideEmojiPanel = this.hideEmojiPanel.bind(this);
this.onEmojiClick = this.onEmojiClick.bind(this);
this.handleClick = this.handleClick.bind(this);
this.renderRecordingView = this.renderRecordingView.bind(this);
this.renderCompositionView = this.renderCompositionView.bind(this);
this.renderTextArea = this.renderTextArea.bind(this);
this.renderQuotedMessage = this.renderQuotedMessage.bind(this);
this.renderStagedLinkPreview = this.renderStagedLinkPreview.bind(this);
this.renderAttachmentsStaged = this.renderAttachmentsStaged.bind(this);
// Recording view functions
this.sendVoiceMessage = this.sendVoiceMessage.bind(this);
this.onLoadVoiceNoteView = this.onLoadVoiceNoteView.bind(this);
this.onExitVoiceNoteView = this.onExitVoiceNoteView.bind(this);
// Attachments
this.onChoseAttachment = this.onChoseAttachment.bind(this);
this.onChooseAttachment = this.onChooseAttachment.bind(this);
this.onClickAttachment = this.onClickAttachment.bind(this);
this.renderCaptionEditor = this.renderCaptionEditor.bind(this);
// On Sending
this.onSendMessage = this.onSendMessage.bind(this);
// Events
this.onKeyDown = this.onKeyDown.bind(this);
this.onChange = this.onChange.bind(this);
this.focusCompositionBox = this.focusCompositionBox.bind(this);
this.fetchUsersForGroup = this.fetchUsersForGroup.bind(this);
}
public async componentWillMount() {
const mediaSetting = await window.getSettingValue('media-permissions');
this.setState({ mediaSetting });
}
public componentDidMount() {
setTimeout(this.focusCompositionBox, 100);
}
public componentWillUnmount() {
this.linkPreviewAbortController?.abort();
this.linkPreviewAbortController = undefined;
}
public componentDidUpdate(prevProps: Props, _prevState: State) {
// reset the state on new conversation key
if (prevProps.conversationKey !== this.props.conversationKey) {
this.setState(getDefaultState(), this.focusCompositionBox);
} 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();
}
}
public render() {
const { showRecordingView } = this.state;
return (
<Flex flexDirection="column">
{this.renderQuotedMessage()}
{this.renderStagedLinkPreview()}
{this.renderAttachmentsStaged()}
<div className="composition-container">
{showRecordingView
? this.renderRecordingView()
: this.renderCompositionView()}
</div>
</Flex>
);
}
private handleClick(e: any) {
if (this.emojiPanel && this.emojiPanel.contains(e.target)) {
return;
}
this.toggleEmojiPanel();
}
private showEmojiPanel() {
document.addEventListener('mousedown', this.handleClick, false);
this.setState({
showEmojiPanel: true,
});
}
private hideEmojiPanel() {
document.removeEventListener('mousedown', this.handleClick, false);
this.setState({
showEmojiPanel: false,
});
}
private toggleEmojiPanel() {
if (this.state.showEmojiPanel) {
this.hideEmojiPanel();
} else {
this.showEmojiPanel();
}
}
private renderRecordingView() {
return (
<SessionRecording
sendVoiceMessage={this.sendVoiceMessage}
onLoadVoiceNoteView={this.onLoadVoiceNoteView}
onExitVoiceNoteView={this.onExitVoiceNoteView}
/>
);
}
private isTypingEnabled(): boolean {
const { isBlocked, isKickedFromGroup, leftGroup, isPrivate } = this.props;
return !(isBlocked || isKickedFromGroup || leftGroup);
}
private renderCompositionView() {
const { showEmojiPanel } = this.state;
const typingEnabled = this.isTypingEnabled();
return (
<>
{typingEnabled && (
<SessionIconButton
iconType={SessionIconType.CirclePlus}
iconSize={SessionIconSize.Large}
onClick={this.onChooseAttachment}
/>
)}
<input
className="hidden"
placeholder="Attachment"
multiple={true}
ref={this.fileInput}
type="file"
onChange={this.onChoseAttachment}
/>
{typingEnabled && (
<SessionIconButton
iconType={SessionIconType.Microphone}
iconSize={SessionIconSize.Huge}
onClick={this.onLoadVoiceNoteView}
/>
)}
<div
className="send-message-input"
role="main"
onClick={this.focusCompositionBox} // used to focus on the textarea when clicking in its container
ref={el => {
this.container = el;
}}
>
{this.renderTextArea()}
</div>
{typingEnabled && (
<SessionIconButton
iconType={SessionIconType.Emoji}
iconSize={SessionIconSize.Large}
onClick={this.toggleEmojiPanel}
/>
)}
<div className="send-message-button">
<SessionIconButton
iconType={SessionIconType.Send}
iconSize={SessionIconSize.Large}
iconRotation={90}
onClick={this.onSendMessage}
/>
</div>
{typingEnabled && (
<div
ref={ref => (this.emojiPanel = ref)}
onKeyDown={this.onKeyDown}
role="button"
>
{showEmojiPanel && (
<SessionEmojiPanel
onEmojiClicked={this.onEmojiClick}
show={showEmojiPanel}
/>
)}
</div>
)}
</>
);
}
private renderTextArea() {
const { i18n } = window;
const { message } = this.state;
const { isKickedFromGroup, leftGroup, isPrivate, isBlocked } = this.props;
const messagePlaceHolder = isKickedFromGroup
? i18n('youGotKickedFromGroup')
: leftGroup
? i18n('youLeftTheGroup')
: isBlocked && isPrivate
? i18n('unblockToSend')
: isBlocked && !isPrivate
? i18n('unblockGroupToSend')
: i18n('sendMessage');
const typingEnabled = this.isTypingEnabled();
return (
<MentionsInput
value={message}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
placeholder={messagePlaceHolder}
spellCheck={false}
inputRef={this.textarea}
disabled={!typingEnabled}
maxLength={Constants.CONVERSATION.MAX_MESSAGE_BODY_LENGTH}
rows={1}
style={sendMessageStyle}
suggestionsPortalHost={this.container}
allowSuggestionsAboveCursor={true} // if only one suggestion, it might be rendered below
>
<Mention
appendSpaceOnAdd={true}
markup="@(__id__)" // @__id__ does not work, see cleanMentions()
trigger="@"
displayTransform={id => `@(${id})`}
data={this.fetchUsersForGroup}
renderSuggestion={(
suggestion,
_search,
_highlightedDisplay,
_index,
focused
) => (
<MemberItem
i18n={window.i18n}
selected={focused}
// tslint:disable-next-line: no-empty
onClicked={() => {}}
existingMember={false}
member={{
id: `${suggestion.id}`,
authorPhoneNumber: `${suggestion.id}`,
selected: false,
authorProfileName: `${suggestion.display}`,
authorName: `${suggestion.display}`,
existingMember: false,
checkmarked: false,
authorAvatarPath: '',
}}
checkmarked={false}
/>
)}
/>
</MentionsInput>
);
}
private fetchUsersForGroup(query: any, callback: any) {
if (!query) {
return;
}
if (this.props.isPublic) {
this.fetchUsersForOpenGroup(query, callback);
return;
}
if (!this.props.isPrivate) {
this.fetchUsersForClosedGroup(query, callback);
return;
}
}
private fetchUsersForOpenGroup(query: any, callback: any) {
if (!query) {
return;
}
void window.lokiPublicChatAPI
.getListOfMembers()
.then(members =>
members
.filter(d => !!d)
.filter(d => d.authorProfileName !== 'Anonymous')
.filter(d =>
d.authorProfileName?.toLowerCase()?.includes(query.toLowerCase())
)
)
// Transform the users to what react-mentions expects
.then(members => {
const toRet = members.map(user => ({
display: user.authorProfileName,
id: user.authorPhoneNumber,
}));
return toRet;
})
.then(callback);
}
private fetchUsersForClosedGroup(query: any, callback: any) {
if (!query) {
return;
}
const conversationModel = window.ConversationController.get(
this.props.conversationKey
);
if (!conversationModel) {
return;
}
const allPubKeys = conversationModel.get('members');
const allMembers = allPubKeys.map(pubKey => {
const conv = window.ConversationController.get(pubKey);
let profileName = 'Anonymous';
if (conv) {
profileName = conv.getProfileName();
}
return {
id: pubKey,
authorPhoneNumber: pubKey,
authorProfileName: profileName,
};
});
const members = allMembers
.filter(d => !!d)
.filter(d => d.authorProfileName !== 'Anonymous')
.filter(d =>
d.authorProfileName?.toLowerCase()?.includes(query.toLowerCase())
);
// Transform the users to what react-mentions expects
const mentionsData = members.map(user => ({
display: user.authorProfileName,
id: user.authorPhoneNumber,
}));
callback(mentionsData);
}
private renderStagedLinkPreview(): JSX.Element {
// Don't generate link previews if user has turned them off
if (!(window.getSettingValue('link-preview-setting') || false)) {
return <></>;
}
const { stagedAttachments, quotedMessageProps } = this.props;
const { ignoredLink } = this.state;
// Don't render link previews if quoted message or attachments are already added
if (stagedAttachments.length !== 0 || quotedMessageProps?.id) {
return <></>;
}
// we try to match the first link found in the current message
const links = window.Signal.LinkPreviews.findLinks(
this.state.message,
undefined
);
if (!links || links.length === 0 || ignoredLink === links[0]) {
return <></>;
}
const firstLink = links[0];
// if the first link changed, reset the ignored link so that the preview is generated
if (ignoredLink && ignoredLink !== firstLink) {
this.setState({ ignoredLink: undefined });
}
if (firstLink !== this.state.stagedLinkPreview?.url) {
// trigger fetching of link preview data and image
void this.fetchLinkPreview(firstLink);
}
// if the fetch did not start yet, just don't show anything
if (!this.state.stagedLinkPreview) {
return <></>;
}
const {
isLoaded,
title,
description,
domain,
image,
} = this.state.stagedLinkPreview;
return (
<SessionStagedLinkPreview
isLoaded={isLoaded}
title={title}
description={description}
domain={domain}
image={image}
url={firstLink}
onClose={url => {
this.setState({ ignoredLink: url });
}}
/>
);
return <></>;
}
private async fetchLinkPreview(firstLink: string) {
// mark the link preview as loading, no data are set yet
this.setState({
stagedLinkPreview: {
isLoaded: false,
url: firstLink,
domain: null,
description: null,
image: undefined,
title: null,
},
});
const abortController = new AbortController();
this.linkPreviewAbortController?.abort();
this.linkPreviewAbortController = abortController;
setTimeout(() => {
abortController.abort();
}, LINK_PREVIEW_TIMEOUT);
getPreview(firstLink, abortController.signal)
.then(ret => {
let image: AttachmentType | undefined;
if (ret) {
if (ret.image?.width) {
if (ret.image) {
const blob = new Blob([ret.image.data], {
type: ret.image.contentType,
});
const imageAttachment = {
...ret.image,
url: URL.createObjectURL(blob),
fileName: 'preview',
};
image = imageAttachment;
}
}
}
this.setState({
stagedLinkPreview: {
isLoaded: true,
title: ret?.title || null,
description: ret?.description || '',
url: ret?.url || null,
domain:
(ret?.url && window.Signal.LinkPreviews.getDomain(ret.url)) || '',
image,
},
});
})
.catch(err => {
console.warn('fetch link preview: ', err);
abortController.abort();
this.setState({
stagedLinkPreview: {
isLoaded: true,
title: null,
domain: null,
description: null,
url: firstLink,
image: undefined,
},
});
});
}
private renderQuotedMessage() {
const { quotedMessageProps, removeQuotedMessage } = this.props;
if (quotedMessageProps && quotedMessageProps.id) {
return (
<SessionQuotedMessageComposition
quotedMessageProps={quotedMessageProps}
removeQuotedMessage={removeQuotedMessage}
/>
);
}
return <></>;
}
private onClickAttachment(attachment: AttachmentType) {
// const onSave = (caption: string) => {
// // eslint-disable-next-line no-param-reassign
// attachment.caption = caption;
// // this.captionEditorView.remove();
// // Signal.Backbone.Views.Lightbox.hide();
// this.render();
// };
this.setState({ showCaptionEditor: attachment });
// this.captionEditorView = new Whisper.ReactWrapperView({
// className: 'attachment-list-wrapper',
// Component: window.Signal.Components.CaptionEditor,
// props: getProps(),
// onClose: () => Signal.Backbone.Views.Lightbox.hide(),
// });
// Signal.Backbone.Views.Lightbox.show(this.captionEditorView.el);
}
private renderCaptionEditor(attachment?: AttachmentType) {
if (attachment) {
const onSave = (caption: string) => {
// eslint-disable-next-line no-param-reassign
attachment.caption = caption;
ToastUtils.pushToastInfo('saved', window.i18n('saved'));
// close the lightbox on save
this.setState({
showCaptionEditor: undefined,
});
};
const url = attachment.videoUrl || attachment.url;
return (
<CaptionEditor
attachment={attachment}
url={url}
onSave={onSave}
caption={attachment.caption}
onClose={() => {
this.setState({
showCaptionEditor: undefined,
});
}}
/>
);
}
return <></>;
}
private renderAttachmentsStaged() {
const { stagedAttachments } = this.props;
const { showCaptionEditor } = this.state;
if (stagedAttachments && stagedAttachments.length) {
return (
<>
<AttachmentList
attachments={stagedAttachments}
onClickAttachment={this.onClickAttachment}
onAddAttachment={this.onChooseAttachment}
onCloseAttachment={this.props.removeAttachment}
onClose={this.props.clearAttachments}
/>
{this.renderCaptionEditor(showCaptionEditor)}
</>
);
}
return <></>;
}
private onChooseAttachment() {
this.fileInput.current?.click();
}
private async onChoseAttachment() {
// Build attachments list
let attachmentsFileList = null;
// this is terrible, but we have to reset the input value manually.
// otherwise, the user won't be able to select two times the same file for example.
if (this.fileInput.current?.files) {
attachmentsFileList = Array.from(this.fileInput.current.files);
this.fileInput.current.files = null;
this.fileInput.current.value = '';
}
if (!attachmentsFileList || attachmentsFileList.length === 0) {
return;
}
this.props.onChoseAttachments(attachmentsFileList);
}
private async onKeyDown(event: any) {
if (event.key === 'Enter' && !event.shiftKey) {
// If shift, newline. Else send message.
event.preventDefault();
await this.onSendMessage();
} else if (event.key === 'Escape' && this.state.showEmojiPanel) {
this.hideEmojiPanel();
}
}
private parseEmojis(value: string) {
const emojisArray = toArray(value);
// toArray outputs React elements for emojis and strings for other
return emojisArray.reduce((previous: string, current: any) => {
if (typeof current === 'string') {
return previous + current;
}
return previous + (current.props.children as string);
}, '');
}
// tslint:disable-next-line: cyclomatic-complexity
private async onSendMessage() {
// this is dirty but we have to replace all @(xxx) by @xxx manually here
const cleanMentions = (text: string): string => {
const matches = text.match(this.mentionsRegex);
let replacedMentions = text;
(matches || []).forEach(match => {
const replacedMention = match.substring(2, match.length - 1);
replacedMentions = replacedMentions.replace(
match,
`@${replacedMention}`
);
});
return replacedMentions;
};
const messagePlaintext = cleanMentions(
this.parseEmojis(this.state.message)
);
const { isBlocked, isPrivate, leftGroup, isKickedFromGroup } = this.props;
// deny sending of message if our app version is expired
if (window.extension.expiredStatus() === true) {
ToastUtils.pushToastError(
'expiredWarning',
window.i18n('expiredWarning')
);
return;
}
if (isBlocked && isPrivate) {
ToastUtils.pushUnblockToSend();
return;
}
if (isBlocked && !isPrivate) {
ToastUtils.pushUnblockToSendGroup();
return;
}
// Verify message length
const msgLen = messagePlaintext?.length || 0;
if (msgLen > window.CONSTANTS.MAX_MESSAGE_BODY_LENGTH) {
ToastUtils.pushMessageBodyTooLong();
return;
}
if (msgLen === 0 && this.props.stagedAttachments?.length === 0) {
ToastUtils.pushMessageBodyMissing();
return;
}
if (!window.clientClockSynced) {
let clockSynced = false;
if (window.setClockParams) {
// Check to see if user has updated their clock to current time
clockSynced = await window.setClockParams();
} else {
window.log.info('setClockParams not loaded yet');
}
if (clockSynced) {
ToastUtils.pushClockOutOfSync();
return;
}
}
if (!isPrivate && leftGroup) {
ToastUtils.pushYouLeftTheGroup();
return;
}
if (!isPrivate && isKickedFromGroup) {
ToastUtils.pushYouLeftTheGroup();
return;
}
const { quotedMessageProps } = this.props;
const { stagedLinkPreview } = this.state;
// Send message
this.props.onMessageSending();
const extractedQuotedMessageProps = _.pick(
quotedMessageProps,
'id',
'author',
'text',
'attachments'
);
const linkPreviews =
(stagedLinkPreview && [
_.pick(stagedLinkPreview, 'url', 'image', 'title'),
]) ||
[];
try {
const attachments = await this.getFiles();
await this.props.sendMessage(
messagePlaintext,
attachments,
extractedQuotedMessageProps,
linkPreviews,
null,
{}
);
// Message sending sucess
this.props.onMessageSuccess();
// Empty composition box
this.setState({
message: '',
showEmojiPanel: false,
});
// Empty stagedAttachments
this.props.clearAttachments();
if (stagedLinkPreview && stagedLinkPreview.url) {
this.setState({
stagedLinkPreview: undefined,
ignoredLink: undefined,
});
}
} catch (e) {
// Message sending failed
window.log.error(e);
this.props.onMessageFailure();
}
}
// this function is called right before sending a message, to gather really the files behind attachments.
private async getFiles() {
const { stagedAttachments } = this.props;
// scale them down
const files = await Promise.all(
stagedAttachments.map(attachment =>
AttachmentUtil.getFile(attachment, {
maxSize: Constants.CONVERSATION.MAX_ATTACHMENT_FILESIZE_BYTES,
})
)
);
this.props.clearAttachments();
return files;
}
private async sendVoiceMessage(audioBlob: Blob) {
if (!this.state.showRecordingView) {
return;
}
const fileBuffer = await new Response(audioBlob).arrayBuffer();
const audioAttachment: Attachment = {
data: fileBuffer,
flags: SignalService.AttachmentPointer.Flags.VOICE_MESSAGE,
contentType: MIME.AUDIO_MP3,
};
const messageSuccess = this.props.sendMessage(
'',
[audioAttachment],
undefined,
undefined,
null,
{}
);
if (messageSuccess) {
// success!
}
this.onExitVoiceNoteView();
}
private onLoadVoiceNoteView() {
// Do stuff for component, then run callback to SessionConversation
const { mediaSetting } = this.state;
if (mediaSetting) {
this.setState({
showRecordingView: true,
showEmojiPanel: false,
});
this.props.onLoadVoiceNoteView();
return;
}
ToastUtils.pushAudioPermissionNeeded();
}
private onExitVoiceNoteView() {
// Do stuff for component, then run callback to SessionConversation
this.setState({ showRecordingView: false });
this.props.onExitVoiceNoteView();
}
private onChange(event: any) {
const message = event.target.value ?? '';
this.setState({ message });
}
private onEmojiClick({ colons }: { colons: string }) {
const messageBox = this.textarea.current;
if (!messageBox) {
return;
}
const { message } = this.state;
const currentSelectionStart = Number(messageBox.selectionStart);
const currentSelectionEnd = Number(messageBox.selectionEnd);
const before = message.slice(0, currentSelectionStart);
const end = message.slice(currentSelectionEnd);
const newMessage = `${before}${colons}${end}`;
this.setState({ message: newMessage }, () => {
// update our selection because updating text programmatically
// will put the selection at the end of the textarea
const selectionStart = currentSelectionStart + Number(colons.length);
messageBox.selectionStart = selectionStart;
messageBox.selectionEnd = selectionStart;
// Sometimes, we have to repeat the set of the selection position with a timeout to be effective
setTimeout(() => {
messageBox.selectionStart = selectionStart;
messageBox.selectionEnd = selectionStart;
}, 20);
});
}
private focusCompositionBox() {
// Focus the textarea when user clicks anywhere in the composition box
this.textarea.current?.focus();
}
}