mirror of
https://github.com/revoltchat/revite.git
synced 2024-12-24 22:52:09 -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 { useContext } from "preact/hooks";
|
||||||
import { AppContext } from "../../../context/revoltjs/RevoltClient";
|
import { AppContext } from "../../../context/revoltjs/RevoltClient";
|
||||||
import { memo } from "preact/compat";
|
import { memo } from "preact/compat";
|
||||||
|
import { MessageReply } from "./attachments/MessageReply";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
attachContext?: boolean
|
attachContext?: boolean
|
||||||
|
@ -30,10 +31,12 @@ function Message({ attachContext, message, contrast, content: replacement, head:
|
||||||
const client = useContext(AppContext);
|
const client = useContext(AppContext);
|
||||||
|
|
||||||
const content = message.content as string;
|
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 (
|
return (
|
||||||
|
<>
|
||||||
|
{ message.replies?.map((message_id, index) => <MessageReply index={index} id={message_id} channel={message.channel} />) }
|
||||||
<MessageBase id={message._id}
|
<MessageBase id={message._id}
|
||||||
head={head}
|
head={head && !message.replies}
|
||||||
contrast={contrast}
|
contrast={contrast}
|
||||||
sending={typeof queued !== 'undefined'}
|
sending={typeof queued !== 'undefined'}
|
||||||
mention={message.mentions?.includes(client.user!._id)}
|
mention={message.mentions?.includes(client.user!._id)}
|
||||||
|
@ -57,6 +60,7 @@ function Message({ attachContext, message, contrast, content: replacement, head:
|
||||||
<Embed key={index} embed={embed} />) }
|
<Embed key={index} embed={embed} />) }
|
||||||
</MessageContent>
|
</MessageContent>
|
||||||
</MessageBase>
|
</MessageBase>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -4,8 +4,10 @@ import styled from "styled-components";
|
||||||
import { defer } from "../../../lib/defer";
|
import { defer } from "../../../lib/defer";
|
||||||
import IconButton from "../../ui/IconButton";
|
import IconButton from "../../ui/IconButton";
|
||||||
import { Send } from '@styled-icons/feather';
|
import { Send } from '@styled-icons/feather';
|
||||||
|
import { debounce } from "../../../lib/debounce";
|
||||||
import Axios, { CancelTokenSource } from "axios";
|
import Axios, { CancelTokenSource } from "axios";
|
||||||
import { useTranslation } from "../../../lib/i18n";
|
import { useTranslation } from "../../../lib/i18n";
|
||||||
|
import { Reply } from "../../../redux/reducers/queue";
|
||||||
import { connectState } from "../../../redux/connector";
|
import { connectState } from "../../../redux/connector";
|
||||||
import { WithDispatcher } from "../../../redux/reducers";
|
import { WithDispatcher } from "../../../redux/reducers";
|
||||||
import { takeError } from "../../../context/revoltjs/util";
|
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 { FileUploader, grabFiles, uploadFile } from "../../../context/revoltjs/FileUploads";
|
||||||
import { SingletonMessageRenderer, SMOOTH_SCROLL_ON_RECEIVE } from "../../../lib/renderer/Singleton";
|
import { SingletonMessageRenderer, SMOOTH_SCROLL_ON_RECEIVE } from "../../../lib/renderer/Singleton";
|
||||||
|
|
||||||
|
import ReplyBar from "./bars/ReplyBar";
|
||||||
import FilePreview from './bars/FilePreview';
|
import FilePreview from './bars/FilePreview';
|
||||||
import { debounce } from "../../../lib/debounce";
|
|
||||||
import AutoComplete, { useAutoComplete } from "../AutoComplete";
|
import AutoComplete, { useAutoComplete } from "../AutoComplete";
|
||||||
|
|
||||||
type Props = WithDispatcher & {
|
type Props = WithDispatcher & {
|
||||||
|
@ -56,6 +58,7 @@ export const CAN_UPLOAD_AT_ONCE = 5;
|
||||||
function MessageBox({ channel, draft, dispatcher }: Props) {
|
function MessageBox({ channel, draft, dispatcher }: Props) {
|
||||||
const [ uploadState, setUploadState ] = useState<UploadState>({ type: 'none' });
|
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 { openScreen } = useIntermediate();
|
||||||
const client = useContext(AppContext);
|
const client = useContext(AppContext);
|
||||||
const translate = useTranslation();
|
const translate = useTranslation();
|
||||||
|
@ -104,6 +107,7 @@ function MessageBox({ channel, draft, dispatcher }: Props) {
|
||||||
|
|
||||||
stopTyping();
|
stopTyping();
|
||||||
setMessage();
|
setMessage();
|
||||||
|
setReplies([]);
|
||||||
|
|
||||||
const nonce = ulid();
|
const nonce = ulid();
|
||||||
dispatcher({
|
dispatcher({
|
||||||
|
@ -114,7 +118,9 @@ function MessageBox({ channel, draft, dispatcher }: Props) {
|
||||||
_id: nonce,
|
_id: nonce,
|
||||||
channel: channel._id,
|
channel: channel._id,
|
||||||
author: client.user!._id,
|
author: client.user!._id,
|
||||||
content
|
|
||||||
|
content,
|
||||||
|
replies
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -123,7 +129,8 @@ function MessageBox({ channel, draft, dispatcher }: Props) {
|
||||||
try {
|
try {
|
||||||
await client.channels.sendMessage(channel._id, {
|
await client.channels.sendMessage(channel._id, {
|
||||||
content,
|
content,
|
||||||
nonce
|
nonce,
|
||||||
|
replies
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
dispatcher({
|
dispatcher({
|
||||||
|
@ -186,7 +193,8 @@ function MessageBox({ channel, draft, dispatcher }: Props) {
|
||||||
await client.channels.sendMessage(channel._id, {
|
await client.channels.sendMessage(channel._id, {
|
||||||
content,
|
content,
|
||||||
nonce,
|
nonce,
|
||||||
attachments // ! FIXME: temp, allow multiple uploads on server
|
replies,
|
||||||
|
attachments
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setUploadState({
|
setUploadState({
|
||||||
|
@ -199,6 +207,7 @@ function MessageBox({ channel, draft, dispatcher }: Props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
setMessage();
|
setMessage();
|
||||||
|
setReplies([]);
|
||||||
|
|
||||||
if (files.length > CAN_UPLOAD_AT_ONCE) {
|
if (files.length > CAN_UPLOAD_AT_ONCE) {
|
||||||
setUploadState({
|
setUploadState({
|
||||||
|
@ -257,6 +266,7 @@ function MessageBox({ channel, draft, dispatcher }: Props) {
|
||||||
setUploadState({ type: 'attached', files: uploadState.files.filter((_, i) => index !== i) });
|
setUploadState({ type: 'attached', files: uploadState.files.filter((_, i) => index !== i) });
|
||||||
}
|
}
|
||||||
}} />
|
}} />
|
||||||
|
<ReplyBar channel={channel._id} replies={replies} setReplies={setReplies} />
|
||||||
<Base>
|
<Base>
|
||||||
<Action>
|
<Action>
|
||||||
<FileUploader
|
<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>;
|
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 <>
|
return <>
|
||||||
<UserIcon size={24} target={user} />
|
<UserIcon size={size ?? 24} target={user} />
|
||||||
<Username user={user} />
|
<Username user={user} />
|
||||||
</>;
|
</>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -39,6 +39,7 @@ type Action =
|
||||||
| { action: "retry_message"; message: QueuedMessage }
|
| { action: "retry_message"; message: QueuedMessage }
|
||||||
| { action: "cancel_message"; message: QueuedMessage }
|
| { action: "cancel_message"; message: QueuedMessage }
|
||||||
| { action: "mention"; user: string }
|
| { action: "mention"; user: string }
|
||||||
|
| { action: "reply_message"; id: string }
|
||||||
| { action: "quote_message"; content: string }
|
| { action: "quote_message"; content: string }
|
||||||
| { action: "edit_message"; id: string }
|
| { action: "edit_message"; id: string }
|
||||||
| { action: "delete_message"; target: Channels.Message }
|
| { action: "delete_message"; target: Channels.Message }
|
||||||
|
@ -120,8 +121,9 @@ function ContextMenus(props: WithDispatcher) {
|
||||||
.sendMessage(
|
.sendMessage(
|
||||||
data.message.channel,
|
data.message.channel,
|
||||||
{
|
{
|
||||||
|
nonce: data.message.id,
|
||||||
content: data.message.data.content as string,
|
content: data.message.data.content as string,
|
||||||
nonce
|
replies: data.message.data.replies
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.catch(fail);
|
.catch(fail);
|
||||||
|
@ -156,6 +158,17 @@ function ContextMenus(props: WithDispatcher) {
|
||||||
case "copy_text":
|
case "copy_text":
|
||||||
writeClipboard(data.content);
|
writeClipboard(data.content);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "reply_message":
|
||||||
|
{
|
||||||
|
internalEmit(
|
||||||
|
"ReplyBar",
|
||||||
|
"add",
|
||||||
|
data.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
case "quote_message":
|
case "quote_message":
|
||||||
{
|
{
|
||||||
internalEmit(
|
internalEmit(
|
||||||
|
@ -471,10 +484,16 @@ function ContextMenus(props: WithDispatcher) {
|
||||||
typeof message.content === "string" &&
|
typeof message.content === "string" &&
|
||||||
message.content.length > 0
|
message.content.length > 0
|
||||||
) {
|
) {
|
||||||
|
generateAction({
|
||||||
|
action: "reply_message",
|
||||||
|
id: message._id
|
||||||
|
});
|
||||||
|
|
||||||
generateAction({
|
generateAction({
|
||||||
action: "quote_message",
|
action: "quote_message",
|
||||||
content: message.content
|
content: message.content
|
||||||
});
|
});
|
||||||
|
|
||||||
generateAction({
|
generateAction({
|
||||||
action: "copy_text",
|
action: "copy_text",
|
||||||
content: message.content
|
content: message.content
|
||||||
|
|
|
@ -19,3 +19,4 @@ export function internalEmit(ns: string, event: string, ...args: any[]) {
|
||||||
// - Intermediate/navigate
|
// - Intermediate/navigate
|
||||||
// - MessageBox/append
|
// - MessageBox/append
|
||||||
// - TextArea/focus
|
// - TextArea/focus
|
||||||
|
// - ReplyBar/add
|
||||||
|
|
|
@ -5,10 +5,20 @@ export enum QueueStatus {
|
||||||
ERRORED = "errored",
|
ERRORED = "errored",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Reply {
|
||||||
|
id: string,
|
||||||
|
mention: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type QueuedMessageData = Omit<MessageObject, 'content' | 'replies'> & {
|
||||||
|
content: string;
|
||||||
|
replies: Reply[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface QueuedMessage {
|
export interface QueuedMessage {
|
||||||
id: string;
|
id: string;
|
||||||
channel: string;
|
channel: string;
|
||||||
data: MessageObject;
|
data: QueuedMessageData;
|
||||||
status: QueueStatus;
|
status: QueueStatus;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
@ -19,7 +29,7 @@ export type QueueAction =
|
||||||
type: "QUEUE_ADD";
|
type: "QUEUE_ADD";
|
||||||
nonce: string;
|
nonce: string;
|
||||||
channel: string;
|
channel: string;
|
||||||
message: MessageObject;
|
message: QueuedMessageData;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: "QUEUE_FAIL";
|
type: "QUEUE_FAIL";
|
||||||
|
|
Loading…
Reference in a new issue