Add message reply UI.

This commit is contained in:
Paul 2021-06-23 18:26:41 +01:00
parent 0ce77951cb
commit 50bd6addb4
9 changed files with 234 additions and 37 deletions

2
external/lang vendored

@ -1 +1 @@
Subproject commit f3d13c09b6fa2f28f027ce32643caffadbb63cf1
Subproject commit 5e57b0f203f1c03c2942222b967288257c218a4e

View file

@ -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>
</>
)
}

View file

@ -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

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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} />
</>;
}

View file

@ -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

View file

@ -19,3 +19,4 @@ export function internalEmit(ns: string, event: string, ...args: any[]) {
// - Intermediate/navigate
// - MessageBox/append
// - TextArea/focus
// - ReplyBar/add

View file

@ -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";