cinny/src/app/organisms/channel/ChannelViewInput.jsx

387 lines
13 KiB
React
Raw Normal View History

2021-08-04 12:52:59 +03:00
/* eslint-disable react/prop-types */
import React, { useState, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import './ChannelViewInput.scss';
import TextareaAutosize from 'react-autosize-textarea';
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
2021-08-08 19:26:34 +03:00
import settings from '../../../client/state/settings';
2021-08-14 07:49:29 +03:00
import { openEmojiBoard } from '../../../client/action/navigation';
2021-08-04 12:52:59 +03:00
import { bytesToSize } from '../../../util/common';
2021-08-11 10:59:01 +03:00
import { getUsername } from '../../../util/matrixUtil';
import colorMXID from '../../../util/colorMXID';
2021-08-04 12:52:59 +03:00
import Text from '../../atoms/text/Text';
import RawIcon from '../../atoms/system-icons/RawIcon';
import IconButton from '../../atoms/button/IconButton';
import ScrollView from '../../atoms/scroll/ScrollView';
2021-08-11 10:59:01 +03:00
import { MessageReply } from '../../molecules/message/Message';
2021-08-04 12:52:59 +03:00
import CirclePlusIC from '../../../../public/res/ic/outlined/circle-plus.svg';
import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
import SendIC from '../../../../public/res/ic/outlined/send.svg';
import ShieldIC from '../../../../public/res/ic/outlined/shield.svg';
import VLCIC from '../../../../public/res/ic/outlined/vlc.svg';
import VolumeFullIC from '../../../../public/res/ic/outlined/volume-full.svg';
2021-08-08 19:26:34 +03:00
import MarkdownIC from '../../../../public/res/ic/outlined/markdown.svg';
2021-08-04 12:52:59 +03:00
import FileIC from '../../../../public/res/ic/outlined/file.svg';
2021-08-11 10:59:01 +03:00
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
2021-08-04 12:52:59 +03:00
2021-08-08 19:26:34 +03:00
const CMD_REGEX = /(\/|>[#*@]|:)(\S*)$/;
2021-08-04 12:52:59 +03:00
let isTyping = false;
2021-08-08 19:26:34 +03:00
let isCmdActivated = false;
let cmdCursorPos = null;
2021-08-04 12:52:59 +03:00
function ChannelViewInput({
roomId, roomTimeline, timelineScroll, viewEvent,
}) {
const [attachment, setAttachment] = useState(null);
2021-08-08 19:26:34 +03:00
const [isMarkdown, setIsMarkdown] = useState(settings.isMarkdown);
2021-08-11 10:59:01 +03:00
const [replyTo, setReplyTo] = useState(null);
2021-08-04 12:52:59 +03:00
const textAreaRef = useRef(null);
const inputBaseRef = useRef(null);
const uploadInputRef = useRef(null);
const uploadProgressRef = useRef(null);
2021-08-08 19:26:34 +03:00
const rightOptionsRef = useRef(null);
2021-08-04 12:52:59 +03:00
const TYPING_TIMEOUT = 5000;
const mx = initMatrix.matrixClient;
const { roomsInput } = initMatrix;
2021-08-08 19:26:34 +03:00
useEffect(() => {
settings.on(cons.events.settings.MARKDOWN_TOGGLED, setIsMarkdown);
return () => {
settings.removeListener(cons.events.settings.MARKDOWN_TOGGLED, setIsMarkdown);
};
}, []);
2021-08-04 12:52:59 +03:00
const sendIsTyping = (isT) => {
mx.sendTyping(roomId, isT, isT ? TYPING_TIMEOUT : undefined);
isTyping = isT;
if (isT === true) {
setTimeout(() => {
if (isTyping) sendIsTyping(false);
}, TYPING_TIMEOUT);
}
};
function uploadingProgress(myRoomId, { loaded, total }) {
if (myRoomId !== roomId) return;
const progressPer = Math.round((loaded * 100) / total);
uploadProgressRef.current.textContent = `Uploading: ${bytesToSize(loaded)}/${bytesToSize(total)} (${progressPer}%)`;
inputBaseRef.current.style.backgroundImage = `linear-gradient(90deg, var(--bg-surface-hover) ${progressPer}%, var(--bg-surface-low) ${progressPer}%)`;
}
function clearAttachment(myRoomId) {
if (roomId !== myRoomId) return;
setAttachment(null);
inputBaseRef.current.style.backgroundImage = 'unset';
uploadInputRef.current.value = null;
}
2021-08-08 19:26:34 +03:00
function rightOptionsA11Y(A11Y) {
const rightOptions = rightOptionsRef.current.children;
for (let index = 0; index < rightOptions.length; index += 1) {
rightOptions[index].disabled = !A11Y;
}
}
function activateCmd(prefix) {
isCmdActivated = true;
inputBaseRef.current.style.boxShadow = '0 0 0 1px var(--bg-positive)';
rightOptionsA11Y(false);
viewEvent.emit('cmd_activate', prefix);
}
function deactivateCmd() {
if (inputBaseRef.current !== null) {
inputBaseRef.current.style.boxShadow = 'var(--bs-surface-border)';
rightOptionsA11Y(true);
}
isCmdActivated = false;
cmdCursorPos = null;
}
function errorCmd() {
inputBaseRef.current.style.boxShadow = '0 0 0 1px var(--bg-danger)';
}
function setCursorPosition(pos) {
setTimeout(() => {
textAreaRef.current.focus();
textAreaRef.current.setSelectionRange(pos, pos);
}, 0);
}
function replaceCmdWith(msg, cursor, replacement) {
if (msg === null) return null;
const targetInput = msg.slice(0, cursor);
const cmdParts = targetInput.match(CMD_REGEX);
const leadingInput = msg.slice(0, cmdParts.index);
if (replacement.length > 0) setCursorPosition(leadingInput.length + replacement.length);
return leadingInput + replacement + msg.slice(cursor);
}
function firedCmd(cmdData) {
const msg = textAreaRef.current.value;
textAreaRef.current.value = replaceCmdWith(
msg, cmdCursorPos, typeof cmdData?.replace !== 'undefined' ? cmdData.replace : '',
);
deactivateCmd();
}
2021-08-11 10:59:01 +03:00
function setUpReply(userId, eventId, content) {
setReplyTo({ userId, eventId, content });
roomsInput.setReplyTo(roomId, { userId, eventId, content });
}
2021-08-04 12:52:59 +03:00
useEffect(() => {
roomsInput.on(cons.events.roomsInput.UPLOAD_PROGRESS_CHANGES, uploadingProgress);
roomsInput.on(cons.events.roomsInput.ATTACHMENT_CANCELED, clearAttachment);
roomsInput.on(cons.events.roomsInput.FILE_UPLOADED, clearAttachment);
2021-08-08 19:26:34 +03:00
viewEvent.on('cmd_error', errorCmd);
viewEvent.on('cmd_fired', firedCmd);
2021-08-11 10:59:01 +03:00
viewEvent.on('reply_to', setUpReply);
2021-08-04 12:52:59 +03:00
if (textAreaRef?.current !== null) {
isTyping = false;
textAreaRef.current.focus();
textAreaRef.current.value = roomsInput.getMessage(roomId);
setAttachment(roomsInput.getAttachment(roomId));
2021-08-11 10:59:01 +03:00
setReplyTo(roomsInput.getReplyTo(roomId));
2021-08-04 12:52:59 +03:00
}
return () => {
roomsInput.removeListener(cons.events.roomsInput.UPLOAD_PROGRESS_CHANGES, uploadingProgress);
roomsInput.removeListener(cons.events.roomsInput.ATTACHMENT_CANCELED, clearAttachment);
roomsInput.removeListener(cons.events.roomsInput.FILE_UPLOADED, clearAttachment);
2021-08-08 19:26:34 +03:00
viewEvent.removeListener('cmd_error', errorCmd);
viewEvent.removeListener('cmd_fired', firedCmd);
2021-08-11 10:59:01 +03:00
viewEvent.removeListener('reply_to', setUpReply);
2021-08-08 19:26:34 +03:00
if (isCmdActivated) deactivateCmd();
2021-08-04 12:52:59 +03:00
if (textAreaRef?.current === null) return;
const msg = textAreaRef.current.value;
inputBaseRef.current.style.backgroundImage = 'unset';
if (msg.trim() === '') {
roomsInput.setMessage(roomId, '');
return;
}
roomsInput.setMessage(roomId, msg);
};
}, [roomId]);
async function sendMessage() {
2021-08-08 19:26:34 +03:00
if (isCmdActivated) {
viewEvent.emit('cmd_exe');
return;
}
2021-08-04 12:52:59 +03:00
const msgBody = textAreaRef.current.value;
if (roomsInput.isSending(roomId)) return;
if (msgBody.trim() === '' && attachment === null) return;
sendIsTyping(false);
roomsInput.setMessage(roomId, msgBody);
if (attachment !== null) {
roomsInput.setAttachment(roomId, attachment);
}
textAreaRef.current.disabled = true;
textAreaRef.current.style.cursor = 'not-allowed';
await roomsInput.sendInput(roomId);
textAreaRef.current.disabled = false;
textAreaRef.current.style.cursor = 'unset';
textAreaRef.current.focus();
textAreaRef.current.value = roomsInput.getMessage(roomId);
timelineScroll.reachBottom();
viewEvent.emit('message_sent');
textAreaRef.current.style.height = 'unset';
2021-08-11 10:59:01 +03:00
if (replyTo !== null) setReplyTo(null);
2021-08-04 12:52:59 +03:00
}
function processTyping(msg) {
const isEmptyMsg = msg === '';
if (isEmptyMsg && isTyping) {
sendIsTyping(false);
return;
}
if (!isEmptyMsg && !isTyping) {
sendIsTyping(true);
}
}
2021-08-08 19:26:34 +03:00
function getCursorPosition() {
return textAreaRef.current.selectionStart;
}
function recognizeCmd(rawInput) {
const cursor = getCursorPosition();
const targetInput = rawInput.slice(0, cursor);
const cmdParts = targetInput.match(CMD_REGEX);
if (cmdParts === null) {
if (isCmdActivated) {
deactivateCmd();
viewEvent.emit('cmd_deactivate');
}
return;
}
const cmdPrefix = cmdParts[1];
const cmdSlug = cmdParts[2];
2021-08-10 11:42:00 +03:00
if (cmdPrefix === ':') {
// skip emoji autofill command if link is suspected.
const checkForLink = targetInput.slice(0, cmdParts.index);
if (checkForLink.match(/(http|https|mailto|matrix|ircs|irc)$/)) {
deactivateCmd();
viewEvent.emit('cmd_deactivate');
return;
}
}
2021-08-08 19:26:34 +03:00
cmdCursorPos = cursor;
if (cmdSlug === '') {
activateCmd(cmdPrefix);
return;
}
if (!isCmdActivated) activateCmd(cmdPrefix);
inputBaseRef.current.style.boxShadow = '0 0 0 1px var(--bg-caution)';
viewEvent.emit('cmd_process', cmdPrefix, cmdSlug);
}
2021-08-04 12:52:59 +03:00
function handleMsgTyping(e) {
const msg = e.target.value;
2021-08-08 19:26:34 +03:00
recognizeCmd(e.target.value);
if (!isCmdActivated) processTyping(msg);
2021-08-04 12:52:59 +03:00
}
function handleKeyDown(e) {
if (e.keyCode === 13 && e.shiftKey === false) {
e.preventDefault();
sendMessage();
}
}
function addEmoji(emoji) {
textAreaRef.current.value += emoji.unicode;
}
function handleUploadClick() {
if (attachment === null) uploadInputRef.current.click();
else {
roomsInput.cancelAttachment(roomId);
}
}
function uploadFileChange(e) {
const file = e.target.files.item(0);
setAttachment(file);
if (file !== null) roomsInput.setAttachment(roomId, file);
}
function renderInputs() {
return (
<>
<div className={`channel-input__option-container${attachment === null ? '' : ' channel-attachment__option'}`}>
<input onChange={uploadFileChange} style={{ display: 'none' }} ref={uploadInputRef} type="file" />
<IconButton onClick={handleUploadClick} tooltip={attachment === null ? 'Upload' : 'Cancel'} src={CirclePlusIC} />
</div>
<div ref={inputBaseRef} className="channel-input__input-container">
{roomTimeline.isEncryptedRoom() && <RawIcon size="extra-small" src={ShieldIC} />}
<ScrollView autoHide>
<Text className="channel-input__textarea-wrapper">
<TextareaAutosize
ref={textAreaRef}
onChange={handleMsgTyping}
onResize={() => timelineScroll.autoReachBottom()}
onKeyDown={handleKeyDown}
placeholder="Send a message..."
/>
</Text>
</ScrollView>
2021-08-08 19:26:34 +03:00
{isMarkdown && <RawIcon size="extra-small" src={MarkdownIC} />}
2021-08-04 12:52:59 +03:00
</div>
2021-08-08 19:26:34 +03:00
<div ref={rightOptionsRef} className="channel-input__option-container">
2021-08-14 07:49:29 +03:00
<IconButton
onClick={(e) => {
openEmojiBoard({
2021-08-15 11:29:09 +03:00
x: '10%',
y: 300,
isReverse: true,
2021-08-14 07:49:29 +03:00
detail: e.detail,
}, addEmoji);
}}
tooltip="Emoji"
src={EmojiIC}
2021-08-04 12:52:59 +03:00
/>
<IconButton onClick={sendMessage} tooltip="Send" src={SendIC} />
</div>
</>
);
}
function attachFile() {
const fileType = attachment.type.slice(0, attachment.type.indexOf('/'));
return (
<div className="channel-attachment">
<div className={`channel-attachment__preview${fileType !== 'image' ? ' channel-attachment__icon' : ''}`}>
{fileType === 'image' && <img alt={attachment.name} src={URL.createObjectURL(attachment)} />}
{fileType === 'video' && <RawIcon src={VLCIC} />}
{fileType === 'audio' && <RawIcon src={VolumeFullIC} />}
{fileType !== 'image' && fileType !== 'video' && fileType !== 'audio' && <RawIcon src={FileIC} />}
</div>
<div className="channel-attachment__info">
<Text variant="b1">{attachment.name}</Text>
<Text variant="b3"><span ref={uploadProgressRef}>{`size: ${bytesToSize(attachment.size)}`}</span></Text>
</div>
</div>
);
}
2021-08-11 10:59:01 +03:00
function attachReply() {
return (
<div className="channel-reply">
<IconButton
onClick={() => {
roomsInput.cancelReplyTo(roomId);
setReplyTo(null);
}}
src={CrossIC}
tooltip="Cancel reply"
size="extra-small"
/>
<MessageReply
userId={replyTo.userId}
name={getUsername(replyTo.userId)}
color={colorMXID(replyTo.userId)}
content={replyTo.content}
/>
</div>
);
}
2021-08-04 12:52:59 +03:00
return (
<>
2021-08-11 10:59:01 +03:00
{ replyTo !== null && attachReply()}
2021-08-04 12:52:59 +03:00
{ attachment !== null && attachFile() }
<form className="channel-input" onSubmit={(e) => { e.preventDefault(); }}>
{
roomTimeline.room.isSpaceRoom()
? <Text className="channel-input__space" variant="b1">Spaces are yet to be implemented</Text>
: renderInputs()
}
</form>
</>
);
}
ChannelViewInput.propTypes = {
roomId: PropTypes.string.isRequired,
roomTimeline: PropTypes.shape({}).isRequired,
timelineScroll: PropTypes.shape({
reachBottom: PropTypes.func,
autoReachBottom: PropTypes.func,
tryRestoringScroll: PropTypes.func,
enableSmoothScroll: PropTypes.func,
disableSmoothScroll: PropTypes.func,
}).isRequired,
viewEvent: PropTypes.shape({}).isRequired,
};
export default ChannelViewInput;