diff --git a/src/components/common/messaging/Message.tsx b/src/components/common/messaging/Message.tsx index 4de938ea..03cd3d16 100644 --- a/src/components/common/messaging/Message.tsx +++ b/src/components/common/messaging/Message.tsx @@ -24,15 +24,19 @@ export default function Message({ attachContext, message, contrast, content: rep const content = message.content as string; return ( - { head ? : - } + } - { head && } + { head && + + + } { replacement ?? } { message.attachments?.map((attachment, index) => 0 || content.length > 0 } />) } diff --git a/src/components/common/messaging/MessageBase.tsx b/src/components/common/messaging/MessageBase.tsx index 209e6408..7284dba4 100644 --- a/src/components/common/messaging/MessageBase.tsx +++ b/src/components/common/messaging/MessageBase.tsx @@ -1,6 +1,8 @@ import dayjs from "dayjs"; -import styled, { css } from "styled-components"; +import Tooltip from "../Tooltip"; import { decodeTime } from "ulid"; +import { Text } from "preact-i18n"; +import styled, { css } from "styled-components"; import { MessageObject } from "../../../context/revoltjs/util"; export interface BaseMessageProps { @@ -50,6 +52,12 @@ export default styled.div` ${ props => props.status && css` color: var(--error); ` } + + .author { + gap: 8px; + display: flex; + align-items: center; + } .copy { width: 0; @@ -98,14 +106,47 @@ export const MessageContent = styled.div` justify-content: center; `; -export function MessageDetail({ message }: { message: MessageObject }) { +export const DetailBase = styled.div` + gap: 4px; + font-size: 10px; + display: inline-flex; + color: var(--tertiary-foreground); +`; + +export function MessageDetail({ message, position }: { message: MessageObject, position: 'left' | 'top' }) { + if (position === 'left') { + if (message.edited) { + return ( + + + [] + + + + + + ) + } else { + return ( + <> + + + ) + } + } + return ( - <> + - + { message.edited && + + } + ) } diff --git a/src/components/common/messaging/MessageBox.tsx b/src/components/common/messaging/MessageBox.tsx index 32b4e347..f0e6a8a1 100644 --- a/src/components/common/messaging/MessageBox.tsx +++ b/src/components/common/messaging/MessageBox.tsx @@ -1,24 +1,59 @@ import { ulid } from "ulid"; import { Channel } from "revolt.js"; -import TextArea from "../../ui/TextArea"; -import { useContext } from "preact/hooks"; +import styled from "styled-components"; import { defer } from "../../../lib/defer"; import IconButton from "../../ui/IconButton"; import { Send } from '@styled-icons/feather'; +import Axios, { CancelTokenSource } from "axios"; +import { useTranslation } from "../../../lib/i18n"; +import { useCallback, useContext, useState } from "preact/hooks"; import { connectState } from "../../../redux/connector"; import { WithDispatcher } from "../../../redux/reducers"; import { takeError } from "../../../context/revoltjs/util"; +import TextAreaAutoSize from "../../../lib/TextAreaAutoSize"; import { AppContext } from "../../../context/revoltjs/RevoltClient"; import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; +import { useIntermediate } from "../../../context/intermediate/Intermediate"; +import { FileUploader, grabFiles, uploadFile } from "../../../context/revoltjs/FileUploads"; import { SingletonMessageRenderer, SMOOTH_SCROLL_ON_RECEIVE } from "../../../lib/renderer/Singleton"; +import FilePreview from './bars/FilePreview'; +import { debounce } from "../../../lib/debounce"; + type Props = WithDispatcher & { channel: Channel; draft?: string; }; +export type UploadState = + | { type: "none" } + | { type: "attached"; files: File[] } + | { type: "uploading"; files: File[]; percent: number; cancel: CancelTokenSource } + | { type: "sending"; files: File[] } + | { type: "failed"; files: File[]; error: string }; + +const Base = styled.div` + display: flex; + padding: 0 12px; + background: var(--message-box); + + textarea { + font-size: .875rem; + background: transparent; + } +`; + +const Action = styled.div` + display: grid; + place-items: center; +`; + function MessageBox({ channel, draft, dispatcher }: Props) { + const [ uploadState, setUploadState ] = useState({ type: 'none' }); + const [typing, setTyping] = useState(false); + const { openScreen } = useIntermediate(); const client = useContext(AppContext); + const translate = useTranslation(); function setMessage(content?: string) { if (content) { @@ -36,12 +71,16 @@ function MessageBox({ channel, draft, dispatcher }: Props) { } async function send() { - const nonce = ulid(); - + if (uploadState.type === 'uploading' || uploadState.type === 'sending') return; + const content = draft?.trim() ?? ''; + if (uploadState.type === 'attached') return sendFile(content); if (content.length === 0) return; - + + stopTyping(); setMessage(); + + const nonce = ulid(); dispatcher({ type: "QUEUE_ADD", nonce, @@ -55,7 +94,6 @@ function MessageBox({ channel, draft, dispatcher }: Props) { }); defer(() => SingletonMessageRenderer.jumpToBottom(channel._id, SMOOTH_SCROLL_ON_RECEIVE)); - // Sounds.playOutbound(); try { await client.channels.sendMessage(channel._id, { @@ -71,21 +109,164 @@ function MessageBox({ channel, draft, dispatcher }: Props) { } } + async function sendFile(content: string) { + if (uploadState.type !== 'attached') return; + let attachments = []; + + const cancel = Axios.CancelToken.source(); + const files = uploadState.files; + stopTyping(); + setUploadState({ type: "uploading", files, percent: 0, cancel }); + + try { + for (let i=0;i0)continue; // ! FIXME: temp, allow multiple uploads on server + const file = files[i]; + attachments.push( + await uploadFile(client.configuration!.features.autumn.url, 'attachments', file, { + onUploadProgress: e => + setUploadState({ + type: "uploading", + files, + percent: Math.round(((i * 100) + (100 * e.loaded) / e.total) / (files.length)), + cancel + }), + cancelToken: cancel.token + }) + ); + } + } catch (err) { + if (err?.message === "cancel") { + setUploadState({ + type: "attached", + files + }); + } else { + setUploadState({ + type: "failed", + files, + error: takeError(err) + }); + } + + return; + } + + setUploadState({ + type: "sending", + files + }); + + const nonce = ulid(); + try { + await client.channels.sendMessage(channel._id, { + content, + nonce, + attachment: attachments[0] // ! FIXME: temp, allow multiple uploads on server + }); + } catch (err) { + setUploadState({ + type: "failed", + files, + error: takeError(err) + }); + + return; + } + + setMessage(); + setUploadState({ type: "none" }); + } + + function startTyping() { + if (typeof typing === 'number' && + new Date() < typing) return; + + const ws = client.websocket; + if (ws.connected) { + setTyping(+ new Date() + 4000); + ws.send({ + type: "BeginTyping", + channel: channel._id + }); + } + } + + function stopTyping(force?: boolean) { + if (force || typing) { + const ws = client.websocket; + if (ws.connected) { + setTyping(false); + ws.send({ + type: "EndTyping", + channel: channel._id + }); + } + } + } + + const debouncedStopTyping = useCallback(debounce(stopTyping, 1000), [ channel._id ]); + return ( -
-