feat: add reaction button to overlay

This commit is contained in:
Paul Makles 2022-08-08 15:15:20 +01:00
parent e1d3ad1675
commit 58f294b790
9 changed files with 334 additions and 129 deletions

View file

@ -43,6 +43,8 @@
} }
}, },
"dependencies": { "dependencies": {
"@floating-ui/react-dom": "^0.7.2",
"@floating-ui/react-dom-interactions": "^0.7.0",
"fs-extra": "^10.0.0", "fs-extra": "^10.0.0",
"klaw": "^3.0.0", "klaw": "^3.0.0",
"sirv-cli": "^1.0.14", "sirv-cli": "^1.0.14",

View file

@ -5,7 +5,7 @@ import { useTriggerEvents } from "preact-context-menu";
import { memo } from "preact/compat"; import { memo } from "preact/compat";
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import { Category } from "@revoltchat/ui"; import { Category, Button } from "@revoltchat/ui";
import { internalEmit } from "../../../lib/eventEmitter"; import { internalEmit } from "../../../lib/eventEmitter";
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
@ -88,6 +88,7 @@ const Message = observer(
// ! FIXME(?): animate on hover // ! FIXME(?): animate on hover
const [mouseHovering, setAnimate] = useState(false); const [mouseHovering, setAnimate] = useState(false);
const [reactionsOpen, setReactionsOpen] = useState(false);
useEffect(() => setAnimate(false), [replacement]); useEffect(() => setAnimate(false), [replacement]);
return ( return (
@ -182,10 +183,12 @@ const Message = observer(
<Embed key={index} embed={embed} /> <Embed key={index} embed={embed} />
))} ))}
<Reactions message={message} /> <Reactions message={message} />
{mouseHovering && {(mouseHovering || reactionsOpen) &&
!replacement && !replacement &&
!isTouchscreenDevice && ( !isTouchscreenDevice && (
<MessageOverlayBar <MessageOverlayBar
reactionsOpen={reactionsOpen}
setReactionsOpen={setReactionsOpen}
message={message} message={message}
queued={queued} queued={queued}
/> />

View file

@ -26,7 +26,10 @@ import { state, useApplicationState } from "../../../mobx/State";
import { Reply } from "../../../mobx/stores/MessageQueue"; import { Reply } from "../../../mobx/stores/MessageQueue";
import { emojiDictionary } from "../../../assets/emojis"; import { emojiDictionary } from "../../../assets/emojis";
import { useClient } from "../../../controllers/client/ClientController"; import {
clientController,
useClient,
} from "../../../controllers/client/ClientController";
import { takeError } from "../../../controllers/client/jsx/error"; import { takeError } from "../../../controllers/client/jsx/error";
import { import {
FileUploader, FileUploader,
@ -143,8 +146,14 @@ const RE_SED = new RegExp("^s/([^])*/([^])*$");
// Tests for code block delimiters (``` at start of line) // Tests for code block delimiters (``` at start of line)
const RE_CODE_DELIMITER = new RegExp("^```", "gm"); const RE_CODE_DELIMITER = new RegExp("^```", "gm");
const HackAlertThisFileWillBeReplaced = observer( export const HackAlertThisFileWillBeReplaced = observer(
({ channel, onClose }: Props & { onClose: () => void }) => { ({
onSelect,
onClose,
}: {
onSelect: (emoji: string) => void;
onClose: () => void;
}) => {
const renderEmoji = useMemo( const renderEmoji = useMemo(
() => () =>
memo(({ emoji }: { emoji: string }) => ( memo(({ emoji }: { emoji: string }) => (
@ -162,8 +171,12 @@ const HackAlertThisFileWillBeReplaced = observer(
for (const server of state.ordering.orderedServers) { for (const server of state.ordering.orderedServers) {
// ! FIXME: add a separate map on each server for emoji // ! FIXME: add a separate map on each server for emoji
const list = [...channel.client.emojis.values()] const list = [...clientController.getReadyClient()!.emojis.values()]
.filter((emoji) => emoji.parent.id === server._id) .filter(
(emoji) =>
emoji.parent.type !== "Detached" &&
emoji.parent.id === server._id,
)
.map(({ _id, name }) => ({ id: _id, name })); .map(({ _id, name }) => ({ id: _id, name }));
if (list.length > 0) { if (list.length > 0) {
@ -187,13 +200,7 @@ const HackAlertThisFileWillBeReplaced = observer(
emojis={emojis} emojis={emojis}
categories={categories} categories={categories}
renderEmoji={renderEmoji} renderEmoji={renderEmoji}
onSelect={(emoji) => { onSelect={onSelect}
const v = state.draft.get(channel._id);
state.draft.set(
channel._id,
`${v ? `${v} ` : ""}:${emoji}:`,
);
}}
onClose={onClose} onClose={onClose}
/> />
); );
@ -568,7 +575,13 @@ export default observer(({ channel }: Props) => {
<FloatingLayer> <FloatingLayer>
{picker && ( {picker && (
<HackAlertThisFileWillBeReplaced <HackAlertThisFileWillBeReplaced
channel={channel} onSelect={(emoji) => {
const v = state.draft.get(channel._id);
state.draft.set(
channel._id,
`${v ? `${v} ` : ""}:${emoji}:`,
);
}}
onClose={closePicker} onClose={closePicker}
/> />
)} )}

View file

@ -1,11 +1,20 @@
import {
autoPlacement,
offset,
shift,
useFloating,
} from "@floating-ui/react-dom-interactions";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Message } from "revolt.js"; import { Message } from "revolt.js";
import styled, { css } from "styled-components"; import styled, { css } from "styled-components";
import { useCallback } from "preact/hooks"; import { createPortal } from "preact/compat";
import { useCallback, useRef } from "preact/hooks";
import { emojiDictionary } from "../../../../assets/emojis";
import { useClient } from "../../../../controllers/client/ClientController"; import { useClient } from "../../../../controllers/client/ClientController";
import { RenderEmoji } from "../../../markdown/plugins/emoji"; import { RenderEmoji } from "../../../markdown/plugins/emoji";
import { HackAlertThisFileWillBeReplaced } from "../MessageBox";
interface Props { interface Props {
message: Message; message: Message;
@ -131,3 +140,78 @@ export const Reactions = observer(({ message }: Props) => {
</List> </List>
); );
}); });
const Base = styled.div`
> div {
position: unset;
}
`;
/**
* ! FIXME: rewrite
*/
export const ReactionWrapper: React.FC<{
message: Message;
open: boolean;
setOpen: (v: boolean) => void;
}> = ({ open, setOpen, message, children }) => {
const { x, y, reference, floating, strategy } = useFloating({
open,
middleware: [
offset(4),
shift({ mainAxis: true, crossAxis: true, padding: 4 }),
autoPlacement(),
],
});
const skip = useRef();
const toggle = () => {
if (skip.current) {
skip.current = null;
return;
}
setOpen(!open);
if (!open) {
skip.current = true;
}
};
return (
<>
<div
ref={reference}
onClick={toggle}
style={{ width: "fit-content" }}>
{children}
</div>
{createPortal(
<div id="reaction">
{open && (
<Base
ref={floating}
style={{
position: strategy,
top: y ?? 0,
left: x ?? 0,
}}>
<HackAlertThisFileWillBeReplaced
onSelect={(emoji) =>
message.react(
emojiDictionary[
emoji as keyof typeof emojiDictionary
] ?? emoji,
)
}
onClose={toggle}
/>
</Base>
)}
</div>,
document.body,
)}
</>
);
};

View file

@ -5,6 +5,7 @@ import {
Share, Share,
InfoSquare, InfoSquare,
Notification, Notification,
HappyBeaming,
} from "@styled-icons/boxicons-solid"; } from "@styled-icons/boxicons-solid";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Message as MessageObject } from "revolt.js"; import { Message as MessageObject } from "revolt.js";
@ -17,12 +18,16 @@ import { internalEmit } from "../../../../lib/eventEmitter";
import { shiftKeyPressed } from "../../../../lib/modifiers"; import { shiftKeyPressed } from "../../../../lib/modifiers";
import { getRenderer } from "../../../../lib/renderer/Singleton"; import { getRenderer } from "../../../../lib/renderer/Singleton";
import { state } from "../../../../mobx/State";
import { QueuedMessage } from "../../../../mobx/stores/MessageQueue"; import { QueuedMessage } from "../../../../mobx/stores/MessageQueue";
import { modalController } from "../../../../controllers/modals/ModalController"; import { modalController } from "../../../../controllers/modals/ModalController";
import Tooltip from "../../../common/Tooltip"; import Tooltip from "../../../common/Tooltip";
import { ReactionWrapper } from "../attachments/Reactions";
interface Props { interface Props {
reactionsOpen: boolean;
setReactionsOpen: (v: boolean) => void;
message: MessageObject; message: MessageObject;
queued?: QueuedMessage; queued?: QueuedMessage;
} }
@ -81,125 +86,152 @@ const Divider = styled.div`
background: var(--tertiary-background); background: var(--tertiary-background);
`; `;
export const MessageOverlayBar = observer(({ message, queued }: Props) => { export const MessageOverlayBar = observer(
const client = message.client; ({ reactionsOpen, setReactionsOpen, message, queued }: Props) => {
const isAuthor = message.author_id === client.user!._id; const client = message.client;
const isAuthor = message.author_id === client.user!._id;
const [copied, setCopied] = useState<"link" | "id">(null!); const [copied, setCopied] = useState<"link" | "id">(null!);
const [extraActions, setExtra] = useState(shiftKeyPressed); const [extraActions, setExtra] = useState(shiftKeyPressed);
useEffect(() => { useEffect(() => {
const handler = (ev: KeyboardEvent) => setExtra(ev.shiftKey); const handler = (ev: KeyboardEvent) => setExtra(ev.shiftKey);
document.addEventListener("keyup", handler); document.addEventListener("keyup", handler);
document.addEventListener("keydown", handler); document.addEventListener("keydown", handler);
return () => { return () => {
document.removeEventListener("keyup", handler); document.removeEventListener("keyup", handler);
document.removeEventListener("keydown", handler); document.removeEventListener("keydown", handler);
}; };
}); });
return ( return (
<OverlayBar> <OverlayBar>
<Tooltip content="Reply"> {message.channel?.havePermission("SendMessage") && (
<Entry onClick={() => internalEmit("ReplyBar", "add", message)}> <Tooltip content="Reply">
<Share size={18} /> <Entry
</Entry> onClick={() =>
</Tooltip> internalEmit("ReplyBar", "add", message)
}>
<Share size={18} />
</Entry>
</Tooltip>
)}
{isAuthor && ( {message.channel?.havePermission("React") &&
<Tooltip content="Edit"> state.experiments.isEnabled("picker") && (
<ReactionWrapper
open={reactionsOpen}
setOpen={setReactionsOpen}
message={message}>
<Tooltip content="React">
<Entry>
<HappyBeaming size={18} />
</Entry>
</Tooltip>
</ReactionWrapper>
)}
{isAuthor && (
<Tooltip content="Edit">
<Entry
onClick={() =>
internalEmit(
"MessageRenderer",
"edit_message",
message._id,
)
}>
<Pencil size={18} />
</Entry>
</Tooltip>
)}
{isAuthor ||
(message.channel &&
message.channel.havePermission("ManageMessages")) ? (
<Tooltip content="Delete">
<Entry
onClick={(e) =>
e.shiftKey
? message.delete()
: modalController.push({
type: "delete_message",
target: message,
})
}>
<Trash size={18} color={"var(--error)"} />
</Entry>
</Tooltip>
) : undefined}
<Tooltip content="More">
<Entry <Entry
onClick={() => onClick={() =>
internalEmit( openContextMenu("Menu", {
"MessageRenderer", message,
"edit_message", contextualChannel: message.channel_id,
message._id, queued,
) })
}> }>
<Pencil size={18} /> <DotsVerticalRounded size={18} />
</Entry> </Entry>
</Tooltip> </Tooltip>
)} {extraActions && (
{isAuthor || <>
(message.channel && <Divider />
message.channel.havePermission("ManageMessages")) ? ( <Tooltip content="Mark as Unread">
<Tooltip content="Delete"> <Entry
<Entry onClick={() => {
onClick={(e) => // ! FIXME: deduplicate this code with ctx menu
e.shiftKey const messages = getRenderer(
? message.delete() message.channel!,
: modalController.push({ ).messages;
type: "delete_message", const index = messages.findIndex(
target: message, (x) => x._id === message._id,
}) );
}>
<Trash size={18} color={"var(--error)"} />
</Entry>
</Tooltip>
) : undefined}
<Tooltip content="More">
<Entry
onClick={() =>
openContextMenu("Menu", {
message,
contextualChannel: message.channel_id,
queued,
})
}>
<DotsVerticalRounded size={18} />
</Entry>
</Tooltip>
{extraActions && (
<>
<Divider />
<Tooltip content="Mark as Unread">
<Entry
onClick={() => {
// ! FIXME: deduplicate this code with ctx menu
const messages = getRenderer(
message.channel!,
).messages;
const index = messages.findIndex(
(x) => x._id === message._id,
);
let unread_id = message._id; let unread_id = message._id;
if (index > 0) { if (index > 0) {
unread_id = messages[index - 1]._id; unread_id = messages[index - 1]._id;
} }
internalEmit("NewMessages", "mark", unread_id); internalEmit(
message.channel?.ack(unread_id, true); "NewMessages",
}}> "mark",
<Notification size={18} /> unread_id,
</Entry> );
</Tooltip> message.channel?.ack(unread_id, true);
<Tooltip }}>
content={copied === "link" ? "Copied!" : "Copy Link"} <Notification size={18} />
hideOnClick={false}> </Entry>
<Entry </Tooltip>
onClick={() => { <Tooltip
setCopied("link"); content={
modalController.writeText(message.url); copied === "link" ? "Copied!" : "Copy Link"
}}> }
<LinkAlt size={18} /> hideOnClick={false}>
</Entry> <Entry
</Tooltip> onClick={() => {
<Tooltip setCopied("link");
content={copied === "id" ? "Copied!" : "Copy ID"} modalController.writeText(message.url);
hideOnClick={false}> }}>
<Entry <LinkAlt size={18} />
onClick={() => { </Entry>
setCopied("id"); </Tooltip>
modalController.writeText(message._id); <Tooltip
}}> content={copied === "id" ? "Copied!" : "Copy ID"}
<InfoSquare size={18} /> hideOnClick={false}>
</Entry> <Entry
</Tooltip> onClick={() => {
</> setCopied("id");
)} modalController.writeText(message._id);
</OverlayBar> }}>
); <InfoSquare size={18} />
}); </Entry>
</Tooltip>
</>
)}
</OverlayBar>
);
},
);

View file

@ -155,7 +155,9 @@ export const ChannelButton = observer((props: ChannelProps) => {
data-alert={alerting} data-alert={alerting}
data-muted={muted} data-muted={muted}
aria-label={channel.name} aria-label={channel.name}
className={classNames(styles.item, { [styles.compact]: compact })} className={classNames(styles.item, {
[styles.compact]: compact,
})}
{...useTriggerEvents("Menu", { {...useTriggerEvents("Menu", {
channel: channel._id, channel: channel._id,
unread: !!alert, unread: !!alert,
@ -175,7 +177,9 @@ export const ChannelButton = observer((props: ChannelProps) => {
<Text <Text
id="quantities.members" id="quantities.members"
plural={channel.recipients!.length} plural={channel.recipients!.length}
fields={{ count: channel.recipients!.length }} fields={{
count: channel.recipients!.length,
}}
/> />
)} )}
</div> </div>

View file

@ -45,7 +45,7 @@ export const EXPERIMENTS: {
picker: { picker: {
title: "Custom Emoji", title: "Custom Emoji",
description: description:
"This will enable a work-in-progress emoji picker and custom emoji settings.", "This will enable a work-in-progress emoji picker, custom emoji settings and reaction picker.",
}, },
}; };

View file

@ -43,6 +43,7 @@ export default function Developer() {
fields={{ provider: <b>GAMING!</b> }} fields={{ provider: <b>GAMING!</b> }}
/> />
</div> </div>
<div style={{ padding: "16px" }}> <div style={{ padding: "16px" }}>
<a onClick={() => setCrash(true)}>click to crash app</a> <a onClick={() => setCrash(true)}>click to crash app</a>
{crash && (window as any).sus.sus()} {crash && (window as any).sus.sus()}

View file

@ -1825,6 +1825,49 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@floating-ui/core@npm:^0.7.3":
version: 0.7.3
resolution: "@floating-ui/core@npm:0.7.3"
checksum: f48f9fb0d19dcbe7a68c38e8de7fabb11f0c0e6e0ef215ae60b5004900bacb1386e7b89cb377d91a90ff7d147ea1f06c2905136ecf34dea162d9696d8f448d5f
languageName: node
linkType: hard
"@floating-ui/dom@npm:^0.5.3":
version: 0.5.4
resolution: "@floating-ui/dom@npm:0.5.4"
dependencies:
"@floating-ui/core": ^0.7.3
checksum: 9f9d8a51a828c6be5f187204aa6d293c6c9ef70d51dcc5891a4d85683745fceebf79ff8826d0f75ae41b45c3b138367d339756f27f41be87a8770742ebc0de42
languageName: node
linkType: hard
"@floating-ui/react-dom-interactions@npm:^0.7.0":
version: 0.7.0
resolution: "@floating-ui/react-dom-interactions@npm:0.7.0"
dependencies:
"@floating-ui/react-dom": ^0.7.2
aria-hidden: ^1.1.3
use-isomorphic-layout-effect: ^1.1.1
peerDependencies:
react: ">=16.8.0"
react-dom: ">=16.8.0"
checksum: 7cf0875a65c55c06a0d0cc91e07d725cb0be8cccf61d6c08ab91f24c80c9dce370d6a47e6b7c4d49c3d9456136d840a57ca548b0bc2316f3ff869ac8e1797424
languageName: node
linkType: hard
"@floating-ui/react-dom@npm:^0.7.2":
version: 0.7.2
resolution: "@floating-ui/react-dom@npm:0.7.2"
dependencies:
"@floating-ui/dom": ^0.5.3
use-isomorphic-layout-effect: ^1.1.1
peerDependencies:
react: ">=16.8.0"
react-dom: ">=16.8.0"
checksum: bc3f2b5557f87f6f4bbccfe3e8d097abafad61a41083d3b79f3499f27590e273bcb3dc7136c2444841ee7a8c0d2a70cc1385458c16103fa8b70eade80c24af52
languageName: node
linkType: hard
"@fontsource/atkinson-hyperlegible@npm:^4.4.5": "@fontsource/atkinson-hyperlegible@npm:^4.4.5":
version: 4.5.1 version: 4.5.1
resolution: "@fontsource/atkinson-hyperlegible@npm:4.5.1" resolution: "@fontsource/atkinson-hyperlegible@npm:4.5.1"
@ -3156,6 +3199,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"aria-hidden@npm:^1.1.3":
version: 1.1.3
resolution: "aria-hidden@npm:1.1.3"
dependencies:
tslib: ^1.0.0
checksum: 2d40a328246baac7ae0b243ebe0cbef53c836c5f78c9212e9c1ff93f3aee185bd9aa51773e161e0025722d691c9d5f125070f6175a7074c4a57778ddc30d9e74
languageName: node
linkType: hard
"array-includes@npm:^3.1.2, array-includes@npm:^3.1.3": "array-includes@npm:^3.1.2, array-includes@npm:^3.1.3":
version: 3.1.3 version: 3.1.3
resolution: "array-includes@npm:3.1.3" resolution: "array-includes@npm:3.1.3"
@ -3603,6 +3655,8 @@ __metadata:
resolution: "client@workspace:." resolution: "client@workspace:."
dependencies: dependencies:
"@babel/plugin-proposal-decorators": ^7.17.9 "@babel/plugin-proposal-decorators": ^7.17.9
"@floating-ui/react-dom": ^0.7.2
"@floating-ui/react-dom-interactions": ^0.7.0
"@fontsource/atkinson-hyperlegible": ^4.4.5 "@fontsource/atkinson-hyperlegible": ^4.4.5
"@fontsource/bitter": ^4.5.7 "@fontsource/bitter": ^4.5.7
"@fontsource/comic-neue": ^4.4.5 "@fontsource/comic-neue": ^4.4.5
@ -8792,7 +8846,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"tslib@npm:^1.8.1": "tslib@npm:^1.0.0, tslib@npm:^1.8.1":
version: 1.14.1 version: 1.14.1
resolution: "tslib@npm:1.14.1" resolution: "tslib@npm:1.14.1"
checksum: dbe628ef87f66691d5d2959b3e41b9ca0045c3ee3c7c7b906cc1e328b39f199bb1ad9e671c39025bd56122ac57dfbf7385a94843b1cc07c60a4db74795829acd checksum: dbe628ef87f66691d5d2959b3e41b9ca0045c3ee3c7c7b906cc1e328b39f199bb1ad9e671c39025bd56122ac57dfbf7385a94843b1cc07c60a4db74795829acd
@ -9131,6 +9185,18 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"use-isomorphic-layout-effect@npm:^1.1.1":
version: 1.1.2
resolution: "use-isomorphic-layout-effect@npm:1.1.2"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
peerDependenciesMeta:
"@types/react":
optional: true
checksum: a6532f7fc9ae222c3725ff0308aaf1f1ddbd3c00d685ef9eee6714fd0684de5cb9741b432fbf51e61a784e2955424864f7ea9f99734a02f237b17ad3e18ea5cb
languageName: node
linkType: hard
"use-memo-one@npm:^1.1.1": "use-memo-one@npm:^1.1.1":
version: 1.1.2 version: 1.1.2
resolution: "use-memo-one@npm:1.1.2" resolution: "use-memo-one@npm:1.1.2"