diff --git a/external/lang b/external/lang index f3d13c09b6fa2f28f027ce32643caffadbb63cf1..5e57b0f203f1c03c2942222b967288257c218a4e 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 92d6c3622b7c2880ba87dd89a535652c3b108deb..781c4e9508ec614a4f531280a72d62e3e363eb24 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 ( - <MessageBase id={message._id} - head={head} - contrast={contrast} - sending={typeof queued !== 'undefined'} - mention={message.mentions?.includes(client.user!._id)} - failed={typeof queued?.error !== 'undefined'} - onContextMenu={attachContext ? attachContextMenu('Menu', { message, contextualChannel: message.channel, queued }) : undefined}> - <MessageInfo> - { head ? - <UserIcon target={user} size={36} /> : - <MessageDetail message={message} position="left" /> } - </MessageInfo> - <MessageContent> - { head && <span className="author"> - <Username user={user} /> - <MessageDetail message={message} position="top" /> - </span> } - { replacement ?? <Markdown content={content} /> } - { queued?.error && <Overline type="error" error={queued.error} /> } - { message.attachments?.map((attachment, index) => - <Attachment key={index} attachment={attachment} hasContent={ index > 0 || content.length > 0 } />) } - { message.embeds?.map((embed, index) => - <Embed key={index} embed={embed} />) } - </MessageContent> - </MessageBase> + <> + { message.replies?.map((message_id, index) => <MessageReply index={index} id={message_id} channel={message.channel} />) } + <MessageBase id={message._id} + head={head && !message.replies} + contrast={contrast} + sending={typeof queued !== 'undefined'} + mention={message.mentions?.includes(client.user!._id)} + failed={typeof queued?.error !== 'undefined'} + onContextMenu={attachContext ? attachContextMenu('Menu', { message, contextualChannel: message.channel, queued }) : undefined}> + <MessageInfo> + { head ? + <UserIcon target={user} size={36} /> : + <MessageDetail message={message} position="left" /> } + </MessageInfo> + <MessageContent> + { head && <span className="author"> + <Username user={user} /> + <MessageDetail message={message} position="top" /> + </span> } + { replacement ?? <Markdown content={content} /> } + { queued?.error && <Overline type="error" error={queued.error} /> } + { message.attachments?.map((attachment, index) => + <Attachment key={index} attachment={attachment} hasContent={ index > 0 || content.length > 0 } />) } + { message.embeds?.map((embed, index) => + <Embed key={index} embed={embed} />) } + </MessageContent> + </MessageBase> + </> ) } diff --git a/src/components/common/messaging/MessageBox.tsx b/src/components/common/messaging/MessageBox.tsx index f678a2770808103c5fd49a80503c74decb670c78..a3c98c1e67c5153ccde0d5b3834fdacb24f9c599 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<UploadState>({ type: 'none' }); - const [typing, setTyping] = useState<boolean | number>(false); + const [ typing, setTyping ] = useState<boolean | number>(false); + const [ replies, setReplies ] = useState<Reply[]>([]); 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) }); } }} /> + <ReplyBar channel={channel._id} replies={replies} setReplies={setReplies} /> <Base> <Action> <FileUploader diff --git a/src/components/common/messaging/attachments/MessageReply.tsx b/src/components/common/messaging/attachments/MessageReply.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0d7c0691c9653f6da80097087e168d1dd760fb8b --- /dev/null +++ b/src/components/common/messaging/attachments/MessageReply.tsx @@ -0,0 +1,65 @@ +import { Text } from "preact-i18n"; +import UserShort from "../../user/UserShort"; +import styled, { css } from "styled-components"; +import Markdown from "../../../markdown/Markdown"; +import { CornerUpRight } from "@styled-icons/feather"; +import { useUser } from "../../../../context/revoltjs/hooks"; +import { useRenderState } from "../../../../lib/renderer/Singleton"; + +interface Props { + channel: string + index: number + id: string +} + +export const ReplyBase = styled.div<{ head?: boolean, fail?: boolean, preview?: boolean }>` + 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 ( + <ReplyBase head={index === 0} fail> + <CornerUpRight size={16} /> + <span><Text id="app.main.channel.misc.failed_load" /></span> + </ReplyBase> + ) + } + + const user = useUser(message.author); + + return ( + <ReplyBase head={index === 0}> + <CornerUpRight size={16} /> + <UserShort user={user} size={16} /> + <Markdown disallowBigEmoji content={(message.content as string).split('\n').shift()} /> + </ReplyBase> + ) +} diff --git a/src/components/common/messaging/bars/ReplyBar.tsx b/src/components/common/messaging/bars/ReplyBar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..33a2da4f1f9b70cdefa5c47a98fa2d609cc9fa33 --- /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<Reply[]> +} + +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 ( + <div> + { 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 ( + <Base key={reply.id}> + <ReplyBase preview> + <CornerUpRight size={22} /> + <UserShort user={user} size={16} /> + <Markdown disallowBigEmoji content={(message.content as string).split('\n').shift()} /> + </ReplyBase> + <span class="actions"> + <IconButton onClick={() => setReplies(replies.map((_, i) => i === index ? { ..._, mention: !_.mention } : _))}> + <span class="toggle"> + <AtSign size={16} /> { reply.mention ? 'ON' : 'OFF' } + </span> + </IconButton> + <IconButton onClick={() => setReplies(replies.filter((_, i) => i !== index))}> + <XCircle size={16} /> + </IconButton> + </span> + </Base> + ) + }) } + </div> + ) +} diff --git a/src/components/common/user/UserShort.tsx b/src/components/common/user/UserShort.tsx index de16edf4dace6f8d2558713b96a359d2a330864b..0dda8d3c6ac7ea22df5e63318b082ae604563fe2 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 <b>{ user?.username ?? <Text id="app.main.channel.unknown_user" /> }</b>; } -export default function UserShort({ user }: { user?: User }) { +export default function UserShort({ user, size }: { user?: User, size?: number }) { return <> - <UserIcon size={24} target={user} /> + <UserIcon size={size ?? 24} target={user} /> <Username user={user} /> </>; } diff --git a/src/lib/ContextMenus.tsx b/src/lib/ContextMenus.tsx index d5c84676ef20aeff7c348cca5072ef859ec2dc2b..4867ee15f8e563f8ac11ffd52793016f523e6a78 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 5e76841c4ca2470a8417c214dadec98b1003558a..669ad37afef1cfc0405b9efbd654da43eb1ec808 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 abf78f9b5338e1def33464ec847383ae76ef76b1..3bcbec5812c6728931b5191fa3a259770bc6f227 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<MessageObject, 'content' | 'replies'> & { + 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";