mirror of
https://github.com/revoltchat/revite.git
synced 2024-11-10 01:03:36 -05:00
Add message reply UI.
This commit is contained in:
parent
0ce77951cb
commit
50bd6addb4
9 changed files with 234 additions and 37 deletions
2
external/lang
vendored
2
external/lang
vendored
|
@ -1 +1 @@
|
|||
Subproject commit f3d13c09b6fa2f28f027ce32643caffadbb63cf1
|
||||
Subproject commit 5e57b0f203f1c03c2942222b967288257c218a4e
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
65
src/components/common/messaging/attachments/MessageReply.tsx
Normal file
65
src/components/common/messaging/attachments/MessageReply.tsx
Normal 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>
|
||||
)
|
||||
}
|
88
src/components/common/messaging/bars/ReplyBar.tsx
Normal file
88
src/components/common/messaging/bars/ReplyBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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} />
|
||||
</>;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -19,3 +19,4 @@ export function internalEmit(ns: string, event: string, ...args: any[]) {
|
|||
// - Intermediate/navigate
|
||||
// - MessageBox/append
|
||||
// - TextArea/focus
|
||||
// - ReplyBar/add
|
||||
|
|
|
@ -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";
|
||||
|
|
Loading…
Reference in a new issue