diff --git a/app/soapbox/actions/media.js b/app/soapbox/actions/media.js index 460c2f0790..ce1550ba47 100644 Binary files a/app/soapbox/actions/media.js and b/app/soapbox/actions/media.js differ diff --git a/app/soapbox/components/hover_ref_wrapper.tsx b/app/soapbox/components/hover_ref_wrapper.tsx index bf4e253f21..2090543cca 100644 --- a/app/soapbox/components/hover_ref_wrapper.tsx +++ b/app/soapbox/components/hover_ref_wrapper.tsx @@ -15,7 +15,7 @@ const showProfileHoverCard = debounce((dispatch, ref, accountId) => { interface IHoverRefWrapper { accountId: string, - inline: boolean, + inline?: boolean, className?: string, } diff --git a/app/soapbox/components/upload-progress.tsx b/app/soapbox/components/upload-progress.tsx new file mode 100644 index 0000000000..d910747cb3 --- /dev/null +++ b/app/soapbox/components/upload-progress.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import { spring } from 'react-motion'; + +import { HStack, Icon, Stack, Text } from 'soapbox/components/ui'; +import Motion from 'soapbox/features/ui/util/optional_motion'; + +interface IUploadProgress { + /** Number between 0 and 1 to represent the percentage complete. */ + progress: number, +} + +/** Displays a progress bar for uploading files. */ +const UploadProgress: React.FC = ({ progress }) => { + return ( + + + + + + + + +
+ + {({ width }) => + (
) + } + +
+ + + ); +}; + +export default UploadProgress; diff --git a/app/soapbox/features/chats/chat-room.tsx b/app/soapbox/features/chats/chat-room.tsx new file mode 100644 index 0000000000..7762fc216e --- /dev/null +++ b/app/soapbox/features/chats/chat-room.tsx @@ -0,0 +1,68 @@ +import { Map as ImmutableMap } from 'immutable'; +import React, { useEffect, useRef } from 'react'; + +import { fetchChat, markChatRead } from 'soapbox/actions/chats'; +import { Column } from 'soapbox/components/ui'; +import { useAppSelector, useAppDispatch } from 'soapbox/hooks'; +import { makeGetChat } from 'soapbox/selectors'; +import { getAcct } from 'soapbox/utils/accounts'; +import { displayFqn as getDisplayFqn } from 'soapbox/utils/state'; + +import ChatBox from './components/chat-box'; + +const getChat = makeGetChat(); + +interface IChatRoom { + params: { + chatId: string, + } +} + +/** Fullscreen chat UI. */ +const ChatRoom: React.FC = ({ params }) => { + const dispatch = useAppDispatch(); + const displayFqn = useAppSelector(getDisplayFqn); + const inputElem = useRef(null); + + const chat = useAppSelector(state => { + const chat = state.chats.items.get(params.chatId, ImmutableMap()).toJS() as any; + return getChat(state, chat); + }); + + const focusInput = () => { + inputElem.current?.focus(); + }; + + const handleInputRef = (el: HTMLTextAreaElement) => { + inputElem.current = el; + focusInput(); + }; + + const markRead = () => { + if (!chat) return; + dispatch(markChatRead(chat.id)); + }; + + useEffect(() => { + dispatch(fetchChat(params.chatId)); + markRead(); + }, [params.chatId]); + + // If this component is loaded at all, we can instantly mark new messages as read. + useEffect(() => { + markRead(); + }, [chat?.unread]); + + if (!chat) return null; + + return ( + + + + ); +}; + +export default ChatRoom; diff --git a/app/soapbox/features/chats/chat_room.js b/app/soapbox/features/chats/chat_room.js deleted file mode 100644 index 4d9140650e..0000000000 Binary files a/app/soapbox/features/chats/chat_room.js and /dev/null differ diff --git a/app/soapbox/features/chats/components/audio_toggle.tsx b/app/soapbox/features/chats/components/audio-toggle.tsx similarity index 100% rename from app/soapbox/features/chats/components/audio_toggle.tsx rename to app/soapbox/features/chats/components/audio-toggle.tsx diff --git a/app/soapbox/features/chats/components/chat-box.tsx b/app/soapbox/features/chats/components/chat-box.tsx new file mode 100644 index 0000000000..668c323ca8 --- /dev/null +++ b/app/soapbox/features/chats/components/chat-box.tsx @@ -0,0 +1,192 @@ +import { OrderedSet as ImmutableOrderedSet } from 'immutable'; +import React, { useRef, useState } from 'react'; +import { useIntl, defineMessages } from 'react-intl'; + +import { + sendChatMessage, + markChatRead, +} from 'soapbox/actions/chats'; +import { uploadMedia } from 'soapbox/actions/media'; +import IconButton from 'soapbox/components/icon_button'; +import UploadProgress from 'soapbox/components/upload-progress'; +import UploadButton from 'soapbox/features/compose/components/upload_button'; +import { useAppSelector, useAppDispatch } from 'soapbox/hooks'; +import { truncateFilename } from 'soapbox/utils/media'; + +import ChatMessageList from './chat-message-list'; + +const messages = defineMessages({ + placeholder: { id: 'chat_box.input.placeholder', defaultMessage: 'Send a message…' }, + send: { id: 'chat_box.actions.send', defaultMessage: 'Send' }, +}); + +const fileKeyGen = (): number => Math.floor((Math.random() * 0x10000)); + +interface IChatBox { + chatId: string, + onSetInputRef: (el: HTMLTextAreaElement) => void, +} + +/** + * Chat UI with just the messages and textarea. + * Reused between floating desktop chats and fullscreen/mobile chats. + */ +const ChatBox: React.FC = ({ chatId, onSetInputRef }) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + const chatMessageIds = useAppSelector(state => state.chat_message_lists.get(chatId, ImmutableOrderedSet())); + + const [content, setContent] = useState(''); + const [attachment, setAttachment] = useState(undefined); + const [isUploading, setIsUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + const [resetFileKey, setResetFileKey] = useState(fileKeyGen()); + + const inputElem = useRef(null); + + const clearState = () => { + setContent(''); + setAttachment(undefined); + setIsUploading(false); + setUploadProgress(0); + setResetFileKey(fileKeyGen()); + }; + + const getParams = () => { + return { + content, + media_id: attachment && attachment.id, + }; + }; + + const canSubmit = () => { + const conds = [ + content.length > 0, + attachment, + ]; + + return conds.some(c => c); + }; + + const sendMessage = () => { + if (canSubmit() && !isUploading) { + const params = getParams(); + + dispatch(sendChatMessage(chatId, params)); + clearState(); + } + }; + + const insertLine = () => { + setContent(content + '\n'); + }; + + const handleKeyDown: React.KeyboardEventHandler = (e) => { + markRead(); + if (e.key === 'Enter' && e.shiftKey) { + insertLine(); + e.preventDefault(); + } else if (e.key === 'Enter') { + sendMessage(); + e.preventDefault(); + } + }; + + const handleContentChange: React.ChangeEventHandler = (e) => { + setContent(e.target.value); + }; + + const markRead = () => { + dispatch(markChatRead(chatId)); + }; + + const handleHover = () => { + markRead(); + }; + + const setInputRef = (el: HTMLTextAreaElement) => { + inputElem.current = el; + onSetInputRef(el); + }; + + const handleRemoveFile = () => { + setAttachment(undefined); + setResetFileKey(fileKeyGen()); + }; + + const onUploadProgress = (e: ProgressEvent) => { + const { loaded, total } = e; + setUploadProgress(loaded / total); + }; + + const handleFiles = (files: FileList) => { + setIsUploading(true); + + const data = new FormData(); + data.append('file', files[0]); + + dispatch(uploadMedia(data, onUploadProgress)).then((response: any) => { + setAttachment(response.data); + setIsUploading(false); + }).catch(() => { + setIsUploading(false); + }); + }; + + const renderAttachment = () => { + if (!attachment) return null; + + return ( +
+
+ {truncateFilename(attachment.preview_url, 20)} +
+
+ +
+
+ ); + }; + + const renderActionButton = () => { + return canSubmit() ? ( + + ) : ( + + ); + }; + + if (!chatMessageIds) return null; + + return ( +
+ + {renderAttachment()} + {isUploading && ( + + )} +
+
+ {renderActionButton()} +
+