From 50bd6addb439081130728b02b75c057b2ed6e368 Mon Sep 17 00:00:00 2001 From: Paul Date: Wed, 23 Jun 2021 18:26:41 +0100 Subject: [PATCH] Add message reply UI. --- external/lang | 2 +- src/components/common/messaging/Message.tsx | 56 ++++++------ .../common/messaging/MessageBox.tsx | 20 +++-- .../messaging/attachments/MessageReply.tsx | 65 ++++++++++++++ .../common/messaging/bars/ReplyBar.tsx | 88 +++++++++++++++++++ src/components/common/user/UserShort.tsx | 4 +- src/lib/ContextMenus.tsx | 21 ++++- src/lib/eventEmitter.ts | 1 + src/redux/reducers/queue.ts | 14 ++- 9 files changed, 234 insertions(+), 37 deletions(-) create mode 100644 src/components/common/messaging/attachments/MessageReply.tsx create mode 100644 src/components/common/messaging/bars/ReplyBar.tsx diff --git a/external/lang b/external/lang index f3d13c09..5e57b0f2 160000 --- a/external/lang +++ b/external/lang @@ -1 +1 @@ -Subproject commit f3d13c09b6fa2f28f027ce32643caffadbb63cf1 +Subproject commit 5e57b0f203f1c03c2942222b967288257c218a4e diff --git a/src/components/common/messaging/Message.tsx b/src/components/common/messaging/Message.tsx index 92d6c362..781c4e95 100644 --- a/src/components/common/messaging/Message.tsx +++ b/src/components/common/messaging/Message.tsx @@ -13,6 +13,7 @@ import Overline from "../../ui/Overline"; import { useContext } from "preact/hooks"; import { AppContext } from "../../../context/revoltjs/RevoltClient"; import { memo } from "preact/compat"; +import { MessageReply } from "./attachments/MessageReply"; interface Props { attachContext?: boolean @@ -30,33 +31,36 @@ function Message({ attachContext, message, contrast, content: replacement, head: const client = useContext(AppContext); const content = message.content as string; - const head = (message.replies && message.replies.length > 0) || preferHead; + const head = preferHead || (message.replies && message.replies.length > 0); return ( - - - { head ? - : - } - - - { head && - - - } - { replacement ?? } - { queued?.error && } - { message.attachments?.map((attachment, index) => - 0 || content.length > 0 } />) } - { message.embeds?.map((embed, index) => - ) } - - + <> + { message.replies?.map((message_id, index) => ) } + + + { head ? + : + } + + + { head && + + + } + { replacement ?? } + { queued?.error && } + { message.attachments?.map((attachment, index) => + 0 || content.length > 0 } />) } + { message.embeds?.map((embed, index) => + ) } + + + ) } diff --git a/src/components/common/messaging/MessageBox.tsx b/src/components/common/messaging/MessageBox.tsx index f678a277..a3c98c1e 100644 --- a/src/components/common/messaging/MessageBox.tsx +++ b/src/components/common/messaging/MessageBox.tsx @@ -4,8 +4,10 @@ import styled from "styled-components"; import { defer } from "../../../lib/defer"; import IconButton from "../../ui/IconButton"; import { Send } from '@styled-icons/feather'; +import { debounce } from "../../../lib/debounce"; import Axios, { CancelTokenSource } from "axios"; import { useTranslation } from "../../../lib/i18n"; +import { Reply } from "../../../redux/reducers/queue"; import { connectState } from "../../../redux/connector"; import { WithDispatcher } from "../../../redux/reducers"; import { takeError } from "../../../context/revoltjs/util"; @@ -18,8 +20,8 @@ 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 ReplyBar from "./bars/ReplyBar"; import FilePreview from './bars/FilePreview'; -import { debounce } from "../../../lib/debounce"; import AutoComplete, { useAutoComplete } from "../AutoComplete"; type Props = WithDispatcher & { @@ -55,7 +57,8 @@ export const CAN_UPLOAD_AT_ONCE = 5; function MessageBox({ channel, draft, dispatcher }: Props) { const [ uploadState, setUploadState ] = useState({ type: 'none' }); - const [typing, setTyping] = useState(false); + const [ typing, setTyping ] = useState(false); + const [ replies, setReplies ] = useState([]); const { openScreen } = useIntermediate(); const client = useContext(AppContext); const translate = useTranslation(); @@ -104,6 +107,7 @@ function MessageBox({ channel, draft, dispatcher }: Props) { stopTyping(); setMessage(); + setReplies([]); const nonce = ulid(); dispatcher({ @@ -114,7 +118,9 @@ function MessageBox({ channel, draft, dispatcher }: Props) { _id: nonce, channel: channel._id, author: client.user!._id, - content + + content, + replies } }); @@ -123,7 +129,8 @@ function MessageBox({ channel, draft, dispatcher }: Props) { try { await client.channels.sendMessage(channel._id, { content, - nonce + nonce, + replies }); } catch (error) { dispatcher({ @@ -186,7 +193,8 @@ function MessageBox({ channel, draft, dispatcher }: Props) { await client.channels.sendMessage(channel._id, { content, nonce, - attachments // ! FIXME: temp, allow multiple uploads on server + replies, + attachments }); } catch (err) { setUploadState({ @@ -199,6 +207,7 @@ function MessageBox({ channel, draft, dispatcher }: Props) { } setMessage(); + setReplies([]); if (files.length > CAN_UPLOAD_AT_ONCE) { setUploadState({ @@ -257,6 +266,7 @@ function MessageBox({ channel, draft, dispatcher }: Props) { setUploadState({ type: 'attached', files: uploadState.files.filter((_, i) => index !== i) }); } }} /> + ` + gap: 4px; + display: flex; + font-size: 0.8em; + margin-left: 30px; + user-select: none; + margin-bottom: 4px; + align-items: center; + color: var(--secondary-foreground); + + svg { + color: var(--tertiary-foreground); + } + + ${ props => props.fail && css` + color: var(--tertiary-foreground); + ` } + + ${ props => props.head && css` + margin-top: 12px; + ` } + + ${ props => props.preview && css` + margin-left: 0; + ` } +`; + +export function MessageReply({ index, channel, id }: Props) { + const view = useRenderState(channel); + if (view?.type !== 'RENDER') return null; + + const message = view.messages.find(x => x._id === id); + if (!message) { + return ( + + + + + ) + } + + const user = useUser(message.author); + + return ( + + + + + + ) +} diff --git a/src/components/common/messaging/bars/ReplyBar.tsx b/src/components/common/messaging/bars/ReplyBar.tsx new file mode 100644 index 00000000..33a2da4f --- /dev/null +++ b/src/components/common/messaging/bars/ReplyBar.tsx @@ -0,0 +1,88 @@ +import styled from "styled-components"; +import UserShort from "../../user/UserShort"; +import Markdown from "../../../markdown/Markdown"; +import { AtSign, CornerUpRight, XCircle } from "@styled-icons/feather"; +import { StateUpdater, useEffect } from "preact/hooks"; +import { ReplyBase } from "../attachments/MessageReply"; +import { Reply } from "../../../../redux/reducers/queue"; +import { useUsers } from "../../../../context/revoltjs/hooks"; +import { internalSubscribe } from "../../../../lib/eventEmitter"; +import { useRenderState } from "../../../../lib/renderer/Singleton"; +import IconButton from "../../../ui/IconButton"; + +interface Props { + channel: string, + replies: Reply[], + setReplies: StateUpdater +} + +const Base = styled.div` + display: flex; + padding: 0 22px; + user-select: none; + align-items: center; + background: var(--message-box); + + div { + flex-grow: 1; + } + + .actions { + gap: 12px; + display: flex; + } + + .toggle { + gap: 4px; + display: flex; + font-size: 0.7em; + align-items: center; + } +`; + +// ! FIXME: Move to global config +const MAX_REPLIES = 5; +export default function ReplyBar({ channel, replies, setReplies }: Props) { + useEffect(() => { + return internalSubscribe("ReplyBar", "add", id => replies.length < MAX_REPLIES && !replies.find(x => x.id === id) && setReplies([ ...replies, { id, mention: false } ])); + }, [ replies ]); + + const view = useRenderState(channel); + if (view?.type !== 'RENDER') return null; + + const ids = replies.map(x => x.id); + const messages = view.messages.filter(x => ids.includes(x._id)); + const users = useUsers(messages.map(x => x.author)); + + return ( +
+ { replies.map((reply, index) => { + let message = messages.find(x => reply.id === x._id); + if (!message) return; + + let user = users.find(x => message!.author === x?._id); + if (!user) return; + + return ( + + + + + + + + setReplies(replies.map((_, i) => i === index ? { ..._, mention: !_.mention } : _))}> + + { reply.mention ? 'ON' : 'OFF' } + + + setReplies(replies.filter((_, i) => i !== index))}> + + + + + ) + }) } +
+ ) +} diff --git a/src/components/common/user/UserShort.tsx b/src/components/common/user/UserShort.tsx index de16edf4..0dda8d3c 100644 --- a/src/components/common/user/UserShort.tsx +++ b/src/components/common/user/UserShort.tsx @@ -6,9 +6,9 @@ export function Username({ user }: { user?: User }) { return { user?.username ?? }; } -export default function UserShort({ user }: { user?: User }) { +export default function UserShort({ user, size }: { user?: User, size?: number }) { return <> - + ; } diff --git a/src/lib/ContextMenus.tsx b/src/lib/ContextMenus.tsx index d5c84676..4867ee15 100644 --- a/src/lib/ContextMenus.tsx +++ b/src/lib/ContextMenus.tsx @@ -39,6 +39,7 @@ type Action = | { action: "retry_message"; message: QueuedMessage } | { action: "cancel_message"; message: QueuedMessage } | { action: "mention"; user: string } + | { action: "reply_message"; id: string } | { action: "quote_message"; content: string } | { action: "edit_message"; id: string } | { action: "delete_message"; target: Channels.Message } @@ -120,8 +121,9 @@ function ContextMenus(props: WithDispatcher) { .sendMessage( data.message.channel, { + nonce: data.message.id, content: data.message.data.content as string, - nonce + replies: data.message.data.replies } ) .catch(fail); @@ -156,6 +158,17 @@ function ContextMenus(props: WithDispatcher) { case "copy_text": writeClipboard(data.content); break; + + case "reply_message": + { + internalEmit( + "ReplyBar", + "add", + data.id + ); + } + break; + case "quote_message": { internalEmit( @@ -471,10 +484,16 @@ function ContextMenus(props: WithDispatcher) { typeof message.content === "string" && message.content.length > 0 ) { + generateAction({ + action: "reply_message", + id: message._id + }); + generateAction({ action: "quote_message", content: message.content }); + generateAction({ action: "copy_text", content: message.content diff --git a/src/lib/eventEmitter.ts b/src/lib/eventEmitter.ts index 5e76841c..669ad37a 100644 --- a/src/lib/eventEmitter.ts +++ b/src/lib/eventEmitter.ts @@ -19,3 +19,4 @@ export function internalEmit(ns: string, event: string, ...args: any[]) { // - Intermediate/navigate // - MessageBox/append // - TextArea/focus +// - ReplyBar/add diff --git a/src/redux/reducers/queue.ts b/src/redux/reducers/queue.ts index abf78f9b..3bcbec58 100644 --- a/src/redux/reducers/queue.ts +++ b/src/redux/reducers/queue.ts @@ -5,10 +5,20 @@ export enum QueueStatus { ERRORED = "errored", } +export interface Reply { + id: string, + mention: boolean +} + +export type QueuedMessageData = Omit & { + content: string; + replies: Reply[]; +} + export interface QueuedMessage { id: string; channel: string; - data: MessageObject; + data: QueuedMessageData; status: QueueStatus; error?: string; } @@ -19,7 +29,7 @@ export type QueueAction = type: "QUEUE_ADD"; nonce: string; channel: string; - message: MessageObject; + message: QueuedMessageData; } | { type: "QUEUE_FAIL";