mirror of
https://github.com/spacebarchat/client.git
synced 2024-11-22 02:12:38 +01:00
Start of message input refactor
This commit is contained in:
parent
0b547226c3
commit
4b0cd78f7a
@ -36,6 +36,7 @@
|
|||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-advanced-cropper": "^0.18.0",
|
"react-advanced-cropper": "^0.18.0",
|
||||||
"react-colorful": "^5.6.1",
|
"react-colorful": "^5.6.1",
|
||||||
|
"react-device-detect": "^2.2.3",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-error-boundary": "^3.1.4",
|
"react-error-boundary": "^3.1.4",
|
||||||
"react-hook-form": "^7.43.9",
|
"react-hook-form": "^7.43.9",
|
||||||
|
@ -77,6 +77,9 @@ dependencies:
|
|||||||
react-colorful:
|
react-colorful:
|
||||||
specifier: ^5.6.1
|
specifier: ^5.6.1
|
||||||
version: 5.6.1(react-dom@18.2.0)(react@18.2.0)
|
version: 5.6.1(react-dom@18.2.0)(react@18.2.0)
|
||||||
|
react-device-detect:
|
||||||
|
specifier: ^2.2.3
|
||||||
|
version: 2.2.3(react-dom@18.2.0)(react@18.2.0)
|
||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^18.2.0
|
specifier: ^18.2.0
|
||||||
version: 18.2.0(react@18.2.0)
|
version: 18.2.0(react@18.2.0)
|
||||||
@ -9259,6 +9262,17 @@ packages:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- vue-template-compiler
|
- vue-template-compiler
|
||||||
|
|
||||||
|
/react-device-detect@2.2.3(react-dom@18.2.0)(react@18.2.0):
|
||||||
|
resolution: {integrity: sha512-buYY3qrCnQVlIFHrC5UcUoAj7iANs/+srdkwsnNjI7anr3Tt7UY6MqNxtMLlr0tMBied0O49UZVK8XKs3ZIiPw==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>= 0.14.0'
|
||||||
|
react-dom: '>= 0.14.0'
|
||||||
|
dependencies:
|
||||||
|
react: 18.2.0
|
||||||
|
react-dom: 18.2.0(react@18.2.0)
|
||||||
|
ua-parser-js: 1.0.36
|
||||||
|
dev: false
|
||||||
|
|
||||||
/react-dom@18.2.0(react@18.2.0):
|
/react-dom@18.2.0(react@18.2.0):
|
||||||
resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==}
|
resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -10799,6 +10813,10 @@ packages:
|
|||||||
engines: {node: '>=4.2.0'}
|
engines: {node: '>=4.2.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
/ua-parser-js@1.0.36:
|
||||||
|
resolution: {integrity: sha512-znuyCIXzl8ciS3+y3fHJI/2OhQIXbXw9MWC/o3qwyR+RGppjZHrM27CGFSKCJXi2Kctiz537iOu2KnXs1lMQhw==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/unbox-primitive@1.0.2:
|
/unbox-primitive@1.0.2:
|
||||||
resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==}
|
resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -2,6 +2,7 @@ import { AnimatePresence, motion } from "framer-motion";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { BannerContext } from "../contexts/BannerContext";
|
import { BannerContext } from "../contexts/BannerContext";
|
||||||
|
import useLogger from "../hooks/useLogger";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
import IconButton from "./IconButton";
|
import IconButton from "./IconButton";
|
||||||
|
|
||||||
@ -17,6 +18,7 @@ const CloseWrapper = styled(IconButton)`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
function Banner() {
|
function Banner() {
|
||||||
|
const logger = useLogger("Banner");
|
||||||
const bannerContext = React.useContext(BannerContext);
|
const bannerContext = React.useContext(BannerContext);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -45,7 +47,7 @@ function Banner() {
|
|||||||
animate="show"
|
animate="show"
|
||||||
exit="hide"
|
exit="hide"
|
||||||
onAnimationComplete={() => {
|
onAnimationComplete={() => {
|
||||||
console.log("animation complete");
|
logger.debug("animation complete");
|
||||||
}}
|
}}
|
||||||
style={bannerContext.content.style}
|
style={bannerContext.content.style}
|
||||||
>
|
>
|
||||||
|
@ -1,81 +1,24 @@
|
|||||||
import styled from "styled-components";
|
|
||||||
import useLogger from "../../hooks/useLogger";
|
|
||||||
import { useAppStore } from "../../stores/AppStore";
|
|
||||||
import Channel from "../../stores/objects/Channel";
|
import Channel from "../../stores/objects/Channel";
|
||||||
|
|
||||||
import { MessageType, RESTPostAPIChannelMessageJSONBody } from "@spacebarchat/spacebar-api-types/v9";
|
import { debounce } from "@mui/material";
|
||||||
|
import { ChannelType } from "@spacebarchat/spacebar-api-types/v9";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import React, { useMemo } from "react";
|
import React from "react";
|
||||||
import { Descendant, Node, createEditor } from "slate";
|
import useLogger from "../../hooks/useLogger";
|
||||||
import { withHistory } from "slate-history";
|
|
||||||
import { Editable, Slate, withReact } from "slate-react";
|
|
||||||
import Guild from "../../stores/objects/Guild";
|
import Guild from "../../stores/objects/Guild";
|
||||||
import { Permissions } from "../../utils/Permissions";
|
import MessageTextArea from "./MessageTextArea";
|
||||||
import Snowflake from "../../utils/Snowflake";
|
|
||||||
import { HorizontalDivider } from "../Divider";
|
|
||||||
import Icon from "../Icon";
|
|
||||||
import IconButton from "../IconButton";
|
|
||||||
import AttachmentUploadList from "./AttachmentUploadList";
|
|
||||||
import TypingStatus from "./TypingStatus";
|
|
||||||
|
|
||||||
const Container = styled.div`
|
enum UploadStateType {
|
||||||
margin-top: -8px;
|
NONE,
|
||||||
padding-left: 16px;
|
ATTACHED,
|
||||||
padding-right: 16px;
|
UPLOADING,
|
||||||
flex-shrink: 0;
|
SENDING,
|
||||||
z-index: 1;
|
}
|
||||||
`;
|
|
||||||
|
|
||||||
const InnerContainer = styled.div`
|
interface UploadState {
|
||||||
background-color: var(--background-primary);
|
type: UploadStateType;
|
||||||
margin-bottom: 24px;
|
files: File[];
|
||||||
width: 100%;
|
}
|
||||||
border-radius: 8px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const UploadActionWrapper = styled.div`
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 12px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const StyledEditable = styled(Editable)<{ $canSendMessages?: boolean; $canUpload?: boolean }>`
|
|
||||||
width: 100%;
|
|
||||||
outline: none;
|
|
||||||
word-break: break-word;
|
|
||||||
padding: 12px 16px 12px ${({ $canUpload }) => ($canUpload ? "0" : "16px")};
|
|
||||||
overflow-y: auto;
|
|
||||||
max-height: 50vh;
|
|
||||||
cursor: ${({ $canSendMessages }) => (!$canSendMessages ? "not-allowed" : "text")};
|
|
||||||
`;
|
|
||||||
|
|
||||||
const CustomIcon = styled(Icon)`
|
|
||||||
color: var(--text-secondary);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const AttachmentsList = styled.ul`
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 10px;
|
|
||||||
overflow-x: auto;
|
|
||||||
list-style: none;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const initialEditorValue: Descendant[] = [
|
|
||||||
{
|
|
||||||
type: "paragraph",
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
text: "",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
channel: Channel;
|
channel: Channel;
|
||||||
@ -85,205 +28,92 @@ interface Props {
|
|||||||
/**
|
/**
|
||||||
* Component for sending messages
|
* Component for sending messages
|
||||||
*/
|
*/
|
||||||
function MessageInput(props: Props) {
|
function MessageInput({ channel }: Props) {
|
||||||
const app = useAppStore();
|
|
||||||
const logger = useLogger("MessageInput");
|
const logger = useLogger("MessageInput");
|
||||||
const editor = useMemo(() => withHistory(withReact(createEditor())), []);
|
|
||||||
const [content, setContent] = React.useState("");
|
const [content, setContent] = React.useState("");
|
||||||
const [canSendMessages, setCanSendMessages] = React.useState(true);
|
const [uploadState, setUploadState] = React.useState<UploadState>({
|
||||||
const [canUpload, setCanUpload] = React.useState(true);
|
type: UploadStateType.NONE,
|
||||||
const uploadRef = React.useRef<HTMLInputElement>(null);
|
files: [],
|
||||||
const [attachments, setAttachments] = React.useState<File[]>([]);
|
});
|
||||||
|
const [typing, setTyping] = React.useState<number | null>(null);
|
||||||
|
|
||||||
editor.insertData = (data) => {
|
/**
|
||||||
const text = data.getData("text/plain");
|
* Starts typing for client user and triggers gateway event
|
||||||
const { files } = data;
|
*/
|
||||||
|
const startTyping = React.useCallback(() => {
|
||||||
|
if (typing && typing > Date.now()) return;
|
||||||
|
logger.debug("ShouldStartTyping");
|
||||||
|
// TODO: send typing request
|
||||||
|
setTyping(+Date.now() + 10000);
|
||||||
|
}, [typing, setTyping]);
|
||||||
|
|
||||||
if (files && files.length > 0) {
|
/**
|
||||||
const newAttachments = [...attachments, ...files];
|
* Stops typing for client user
|
||||||
setAttachments(newAttachments);
|
*/
|
||||||
} else {
|
const stopTyping = React.useCallback(() => {
|
||||||
editor.insertText(text);
|
if (typing) {
|
||||||
|
logger.debug("ShouldStopTyping");
|
||||||
|
setTyping(null);
|
||||||
}
|
}
|
||||||
};
|
}, [typing, setTyping]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
/**
|
||||||
const permission = Permissions.getPermission(app.account!.id, props.guild, props.channel);
|
* Debounced version of stopTyping
|
||||||
setCanSendMessages(permission.has("SEND_MESSAGES"));
|
*/
|
||||||
setCanUpload(permission.has("ATTACH_FILES"));
|
const debouncedStopTyping = React.useCallback(debounce(stopTyping, 10000), [stopTyping]);
|
||||||
}, [props.channel, props.guild]);
|
|
||||||
|
|
||||||
const serialize = React.useCallback((value: Descendant[]) => {
|
/**
|
||||||
return (
|
* @returns Whether or not a message can be sent given the current state
|
||||||
value
|
*/
|
||||||
// Return the string content of each paragraph in the value's children.
|
const canSendMessage = () =>
|
||||||
.map((n) => Node.string(n))
|
React.useCallback(() => {
|
||||||
// Join them all with line breaks denoting paragraphs.
|
if (!uploadState.files.length && (!content || !content.trim() || !content.replace(/\r?\n|\r/g, ""))) {
|
||||||
.join("\n")
|
return false;
|
||||||
);
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}, [uploadState, content]);
|
||||||
|
|
||||||
|
const send = React.useCallback(() => {
|
||||||
|
if (!canSendMessage()) return;
|
||||||
|
logger.debug("ShouldSendMessage");
|
||||||
|
}, [content, uploadState, channel, canSendMessage]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the change event of the textarea
|
||||||
|
*/
|
||||||
|
const onChange = React.useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
setContent(e.target.value);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onKeyDown = React.useCallback(
|
const onKeyDown = React.useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
// TODO:
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
if (!props.channel) {
|
e.preventDefault();
|
||||||
logger.warn("No channel selected, cannot send message");
|
send();
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
const shouldFail = app.experiments.isTreatmentEnabled("message_queue", 2);
|
|
||||||
const shouldSend = !app.experiments.isTreatmentEnabled("message_queue", 1);
|
|
||||||
|
|
||||||
const canSend = props.channel.canSendMessage(content, attachments);
|
|
||||||
if (!canSend && !shouldFail) return;
|
|
||||||
|
|
||||||
const nonce = Snowflake.generate();
|
|
||||||
const msg = app.queue.add({
|
|
||||||
id: nonce,
|
|
||||||
content,
|
|
||||||
channel: props.channel.id,
|
|
||||||
files: attachments,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
type: MessageType.Default,
|
|
||||||
author: app.account!.raw,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (shouldSend) {
|
|
||||||
let body: RESTPostAPIChannelMessageJSONBody | FormData;
|
|
||||||
if (attachments.length > 0) {
|
|
||||||
const data = new FormData();
|
|
||||||
data.append("payload_json", JSON.stringify({ content, nonce }));
|
|
||||||
attachments.forEach((file, index) => {
|
|
||||||
data.append(`files[${index}]`, file);
|
|
||||||
});
|
|
||||||
body = data;
|
|
||||||
} else {
|
|
||||||
body = { content, nonce };
|
|
||||||
}
|
|
||||||
props.channel.sendMessage(body, msg).catch((error) => {
|
|
||||||
if (error) app.queue.error(nonce, error as string);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setContent("");
|
|
||||||
setAttachments([]);
|
|
||||||
|
|
||||||
// reset slate editor
|
|
||||||
const point = { path: [0, 0], offset: 0 };
|
|
||||||
editor.selection = { anchor: point, focus: point };
|
|
||||||
editor.history = { redos: [], undos: [] };
|
|
||||||
editor.children = initialEditorValue;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[props.channel, content, attachments],
|
|
||||||
);
|
|
||||||
|
|
||||||
const onChange = React.useCallback((value: Descendant[]) => {
|
|
||||||
const isAstChange = editor.operations.some((op) => "set_selection" !== op.type);
|
|
||||||
if (isAstChange) {
|
|
||||||
setContent(serialize(value));
|
|
||||||
|
|
||||||
// send typing event
|
|
||||||
if (!props.channel.isTyping) {
|
|
||||||
logger.debug("Sending typing event");
|
|
||||||
props.channel.sendTyping();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleFileUpload = React.useCallback(() => {
|
|
||||||
if (!props.channel) {
|
|
||||||
logger.warn("[HandleFileUpload] Invalid Channel");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
uploadRef.current?.click();
|
|
||||||
}, [props.channel]);
|
|
||||||
|
|
||||||
const onChangeFile = React.useCallback(
|
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
if (!e.target.files) return;
|
|
||||||
const files = Array.from(e.target.files);
|
|
||||||
const newAttachments = [...attachments, ...files];
|
|
||||||
setAttachments(newAttachments);
|
|
||||||
},
|
|
||||||
[attachments],
|
|
||||||
);
|
|
||||||
|
|
||||||
const removeAttachment = React.useCallback(
|
|
||||||
(index: number) => {
|
|
||||||
const newAttachments = [...attachments];
|
|
||||||
newAttachments.splice(index, 1);
|
|
||||||
setAttachments(newAttachments);
|
|
||||||
},
|
|
||||||
[attachments],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<MessageTextArea
|
||||||
<InnerContainer>
|
id="messageinput"
|
||||||
<div
|
// maxLength={4000} // TODO: this should come from the server
|
||||||
style={{
|
value={content}
|
||||||
borderRadius: "8px",
|
placeholder={
|
||||||
}}
|
channel.hasPermission("SEND_MESSAGES")
|
||||||
>
|
? `Message ${
|
||||||
{attachments.length > 0 && (
|
channel.type === ChannelType.DM ? channel.recipients?.[0].username : "#" + channel.name
|
||||||
<>
|
}`
|
||||||
<AttachmentsList>
|
: "You do not have permission to send messages in this channel."
|
||||||
{attachments.map((file, index) => (
|
}
|
||||||
<AttachmentUploadList
|
disabled={
|
||||||
key={index}
|
!channel.hasPermission("SEND_MESSAGES") ||
|
||||||
file={file}
|
uploadState.type === UploadStateType.UPLOADING ||
|
||||||
remove={() => removeAttachment(index)}
|
uploadState.type === UploadStateType.SENDING
|
||||||
/>
|
}
|
||||||
))}
|
onChange={onChange}
|
||||||
</AttachmentsList>
|
onKeyDown={onKeyDown}
|
||||||
<HorizontalDivider nomargin />
|
/>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flex: 1,
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{canUpload && (
|
|
||||||
<UploadActionWrapper>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
ref={uploadRef}
|
|
||||||
style={{ display: "none" }}
|
|
||||||
onChange={onChangeFile}
|
|
||||||
multiple={true}
|
|
||||||
disabled={!canSendMessages || !canUpload}
|
|
||||||
/>
|
|
||||||
<IconButton onClick={handleFileUpload} disabled={!canSendMessages || !canUpload}>
|
|
||||||
<CustomIcon icon="mdiPlusCircle" size="24px" />
|
|
||||||
</IconButton>
|
|
||||||
</UploadActionWrapper>
|
|
||||||
)}
|
|
||||||
<Slate editor={editor} initialValue={initialEditorValue} onChange={onChange}>
|
|
||||||
<StyledEditable
|
|
||||||
$canSendMessages={canSendMessages}
|
|
||||||
$canUpload={canUpload}
|
|
||||||
onKeyDown={onKeyDown}
|
|
||||||
value={content}
|
|
||||||
placeholder={
|
|
||||||
canSendMessages
|
|
||||||
? `Message ${props.channel?.name}`
|
|
||||||
: "You do not have permission to send messages in this channel."
|
|
||||||
}
|
|
||||||
aria-label="Message input"
|
|
||||||
readOnly={!canSendMessages}
|
|
||||||
/>
|
|
||||||
</Slate>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TypingStatus channel={props.channel} />
|
|
||||||
</InnerContainer>
|
|
||||||
</Container>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
78
src/components/messaging/MessageTextArea.tsx
Normal file
78
src/components/messaging/MessageTextArea.tsx
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { TextareaAutosize, TextareaAutosizeProps } from "@mui/material";
|
||||||
|
import React from "react";
|
||||||
|
import styled from "styled-components";
|
||||||
|
import { isTouchscreenDevice } from "../../utils/isTouchscreenDevice";
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0 16px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TextArea = styled(TextareaAutosize)`
|
||||||
|
resize: none;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
padding: 10px 16px 10px 10px;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
background-color: var(--background-primary);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-break: break-word;
|
||||||
|
white-space: break-spaces;
|
||||||
|
font-size: 16px;
|
||||||
|
font-family: var(--font-family);
|
||||||
|
margin-top: -10px;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
color: var(--text-disabled);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
function MessageTextArea(props: TextareaAutosizeProps) {
|
||||||
|
const ref = React.useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isTouchscreenDevice) return;
|
||||||
|
ref.current && ref.current.focus();
|
||||||
|
}, [props.value]);
|
||||||
|
|
||||||
|
const inputSelected = () => ["TEXTAREA", "INPUT"].includes(document.activeElement?.nodeName ?? "");
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!ref.current) return;
|
||||||
|
|
||||||
|
if (isTouchscreenDevice) return;
|
||||||
|
if (!inputSelected()) {
|
||||||
|
ref.current.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyDown(e: KeyboardEvent) {
|
||||||
|
if ((e.ctrlKey && e.key !== "v") || e.altKey || e.metaKey) return;
|
||||||
|
if (e.key.length !== 1) return;
|
||||||
|
if (ref && !inputSelected()) {
|
||||||
|
ref.current!.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.addEventListener("keydown", keyDown);
|
||||||
|
return () => document.body.removeEventListener("keydown", keyDown);
|
||||||
|
}, [ref, props.value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<TextArea
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
maxRows={
|
||||||
|
// 50vh
|
||||||
|
Math.floor((window.innerHeight * 0.5) / 20)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MessageTextArea;
|
83
src/components/messaging/TypingIndicator.tsx
Normal file
83
src/components/messaging/TypingIndicator.tsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { PulseLoader } from "react-spinners";
|
||||||
|
import styled from "styled-components";
|
||||||
|
import { useAppStore } from "../../stores/AppStore";
|
||||||
|
import Channel from "../../stores/objects/Channel";
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
overflow: visible;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 1px;
|
||||||
|
left: 16px;
|
||||||
|
right: 16px;
|
||||||
|
height: 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
resize: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Wrapper = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TypingText = styled.span`
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-left: 4px;
|
||||||
|
font-weight: var(--font-weight-light);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Bold = styled.b`
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
font-size: 14px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
channel: Channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TypingIndicator({ channel }: Props) {
|
||||||
|
const app = useAppStore();
|
||||||
|
const users = channel.typingUsers.filter((x) => typeof x !== "undefined" && x.id !== app.account!.id);
|
||||||
|
|
||||||
|
if (users.length > 0) {
|
||||||
|
users.sort((a, b) => a.username.toUpperCase().localeCompare(b.username.toUpperCase()));
|
||||||
|
|
||||||
|
let text;
|
||||||
|
if (users.length >= 5) {
|
||||||
|
text = <TypingText>Several people are typing...</TypingText>;
|
||||||
|
} else if (users.length > 1) {
|
||||||
|
const userlist = users.map((x) => <Bold>{x.username}</Bold>);
|
||||||
|
const user = userlist.pop();
|
||||||
|
|
||||||
|
text = (
|
||||||
|
<TypingText>
|
||||||
|
{userlist.join(", ")} and <Bold>{user}</Bold> are typing...
|
||||||
|
</TypingText>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
text = (
|
||||||
|
<TypingText>
|
||||||
|
<Bold>{users[0].username} is typing...</Bold>
|
||||||
|
</TypingText>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Wrapper>
|
||||||
|
<PulseLoader size={6} color="var(--text)" />
|
||||||
|
<TypingText>{text}</TypingText>
|
||||||
|
</Wrapper>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(TypingIndicator);
|
@ -1,85 +0,0 @@
|
|||||||
import { observer } from "mobx-react-lite";
|
|
||||||
import { PulseLoader } from "react-spinners";
|
|
||||||
import styled from "styled-components";
|
|
||||||
import Channel from "../../stores/objects/Channel";
|
|
||||||
|
|
||||||
const Typing = styled.div`
|
|
||||||
overflow: visible;
|
|
||||||
position: absolute;
|
|
||||||
bottom: 1px;
|
|
||||||
left: 16px;
|
|
||||||
right: 16px;
|
|
||||||
height: 24px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: var(--font-weight-medium);
|
|
||||||
resize: none;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
color: var(--text);
|
|
||||||
`;
|
|
||||||
|
|
||||||
const TypingIndicator = styled.div`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
overflow: hidden;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const TypingText = styled.span`
|
|
||||||
white-space: nowrap;
|
|
||||||
margin-left: 4px;
|
|
||||||
font-weight: var(--font-weight-light);
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Bold = styled.b`
|
|
||||||
font-weight: var(--font-weight-medium);
|
|
||||||
font-size: 14px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
channel: Channel;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TypingStatus({ channel }: Props) {
|
|
||||||
const getFormattedString = () => {
|
|
||||||
const typingUsers = channel.typingUsers;
|
|
||||||
const userCount = typingUsers.length;
|
|
||||||
|
|
||||||
if (userCount === 0) {
|
|
||||||
return null;
|
|
||||||
} else if (userCount === 1) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Bold>{typingUsers[0].username}</Bold> is typing...
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
} else if (userCount === 2) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Bold>{typingUsers[0].username}</Bold> and <Bold>{typingUsers[1].username}</Bold> are typing...{" "}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
} else if (userCount === 3) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Bold>{typingUsers[0].username}</Bold>, <Bold>{typingUsers[1].username}</Bold> and{" "}
|
|
||||||
<Bold>{typingUsers[2].username}</Bold> are typing...{" "}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return <>Several people are typing...</>;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!channel.typingUsers.length) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Typing>
|
|
||||||
<TypingIndicator>
|
|
||||||
<PulseLoader size={6} color="var(--text)" />
|
|
||||||
<TypingText>{getFormattedString()}</TypingText>
|
|
||||||
</TypingIndicator>
|
|
||||||
</Typing>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default observer(TypingStatus);
|
|
@ -213,13 +213,11 @@ function CreateInviteModal(props: InviteModalProps) {
|
|||||||
const handleAgeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
const handleAgeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
setMaxAge(EXPIRE_OPTIONS.find((x) => x.value === Number(e.target.value)) ?? EXPIRE_OPTIONS[5]);
|
setMaxAge(EXPIRE_OPTIONS.find((x) => x.value === Number(e.target.value)) ?? EXPIRE_OPTIONS[5]);
|
||||||
setIsEdited(true);
|
setIsEdited(true);
|
||||||
console.log("max age changed");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMaxUsesChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
const handleMaxUsesChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
setMaxUses(MAX_USES_OPTIONS.find((x) => x.value === Number(e.target.value)) ?? MAX_USES_OPTIONS[0]);
|
setMaxUses(MAX_USES_OPTIONS.find((x) => x.value === Number(e.target.value)) ?? MAX_USES_OPTIONS[0]);
|
||||||
setIsEdited(true);
|
setIsEdited(true);
|
||||||
console.log("max uses changed");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
React.useEffect(() => createCode(), []);
|
React.useEffect(() => createCode(), []);
|
||||||
|
@ -11,6 +11,7 @@ const font: ThemeFont["font"] = {
|
|||||||
bold: 700,
|
bold: 700,
|
||||||
black: 900,
|
black: 900,
|
||||||
},
|
},
|
||||||
|
family: "Roboto, Arial, Helvetica, sans-serif",
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ThemeVariables =
|
export type ThemeVariables =
|
||||||
@ -68,6 +69,7 @@ export type ThemeFont = {
|
|||||||
bold?: number;
|
bold?: number;
|
||||||
black?: number;
|
black?: number;
|
||||||
};
|
};
|
||||||
|
family: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -12,7 +12,7 @@ span {
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: "Roboto", Arial, Helvetica, sans-serif;
|
font-family: var(--font-family);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
@ -4,6 +4,7 @@ import secureLocalStorage from "react-secure-storage";
|
|||||||
import Logger from "../utils/Logger";
|
import Logger from "../utils/Logger";
|
||||||
import REST from "../utils/REST";
|
import REST from "../utils/REST";
|
||||||
import AccountStore from "./AccountStore";
|
import AccountStore from "./AccountStore";
|
||||||
|
import ChannelStore from "./ChannelStore";
|
||||||
import ExperimentsStore from "./ExperimentsStore";
|
import ExperimentsStore from "./ExperimentsStore";
|
||||||
import GatewayConnectionStore from "./GatewayConnectionStore";
|
import GatewayConnectionStore from "./GatewayConnectionStore";
|
||||||
import GuildStore from "./GuildStore";
|
import GuildStore from "./GuildStore";
|
||||||
@ -33,6 +34,7 @@ export default class AppStore {
|
|||||||
@observable account: AccountStore | null = null;
|
@observable account: AccountStore | null = null;
|
||||||
@observable gateway = new GatewayConnectionStore(this);
|
@observable gateway = new GatewayConnectionStore(this);
|
||||||
@observable guilds = new GuildStore(this);
|
@observable guilds = new GuildStore(this);
|
||||||
|
@observable channels = new ChannelStore(this);
|
||||||
@observable users = new UserStore();
|
@observable users = new UserStore();
|
||||||
@observable privateChannels = new PrivateChannelStore(this);
|
@observable privateChannels = new PrivateChannelStore(this);
|
||||||
@observable rest = new REST(this);
|
@observable rest = new REST(this);
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { debounce } from "@mui/material";
|
||||||
import {
|
import {
|
||||||
APIGuildMember,
|
APIGuildMember,
|
||||||
APIMessage,
|
APIMessage,
|
||||||
@ -635,6 +636,12 @@ export default class GatewayConnectionStore {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
channel.handleTyping(data);
|
if (!channel.typingIds.has(data.user_id)) {
|
||||||
|
channel.typingIds.add(data.user_id);
|
||||||
|
debounce(() => {
|
||||||
|
this.logger.debug(`Removing ${data.user_id} from typing user list`);
|
||||||
|
channel.typingIds.delete(data.user_id);
|
||||||
|
}, 10000)();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -6,7 +6,6 @@ import type {
|
|||||||
APIReadState,
|
APIReadState,
|
||||||
APIUser,
|
APIUser,
|
||||||
APIWebhook,
|
APIWebhook,
|
||||||
GatewayTypingStartDispatchData,
|
|
||||||
GatewayVoiceState,
|
GatewayVoiceState,
|
||||||
RESTGetAPIChannelMessagesQuery,
|
RESTGetAPIChannelMessagesQuery,
|
||||||
RESTGetAPIChannelMessagesResult,
|
RESTGetAPIChannelMessagesResult,
|
||||||
@ -15,12 +14,15 @@ import type {
|
|||||||
Snowflake as SnowflakeType,
|
Snowflake as SnowflakeType,
|
||||||
} from "@spacebarchat/spacebar-api-types/v9";
|
} from "@spacebarchat/spacebar-api-types/v9";
|
||||||
import { ChannelType, Routes } from "@spacebarchat/spacebar-api-types/v9";
|
import { ChannelType, Routes } from "@spacebarchat/spacebar-api-types/v9";
|
||||||
import { ObservableMap, action, computed, makeObservable, observable, runInAction } from "mobx";
|
import { ObservableSet, action, computed, makeObservable, observable } from "mobx";
|
||||||
import Logger from "../../utils/Logger";
|
import Logger from "../../utils/Logger";
|
||||||
|
import type { PermissionResolvable } from "../../utils/Permissions";
|
||||||
|
import { Permissions } from "../../utils/Permissions";
|
||||||
import { APIError } from "../../utils/interfaces/api";
|
import { APIError } from "../../utils/interfaces/api";
|
||||||
import AppStore from "../AppStore";
|
import AppStore from "../AppStore";
|
||||||
import MessageStore from "../MessageStore";
|
import MessageStore from "../MessageStore";
|
||||||
import QueuedMessage from "./QueuedMessage";
|
import QueuedMessage from "./QueuedMessage";
|
||||||
|
import User from "./User";
|
||||||
|
|
||||||
export default class Channel {
|
export default class Channel {
|
||||||
private readonly logger: Logger = new Logger("Channel");
|
private readonly logger: Logger = new Logger("Channel");
|
||||||
@ -55,13 +57,12 @@ export default class Channel {
|
|||||||
@observable flags: number;
|
@observable flags: number;
|
||||||
@observable defaultThreadRateLimitPerUser: number;
|
@observable defaultThreadRateLimitPerUser: number;
|
||||||
@observable channelIcon?: keyof typeof Icons;
|
@observable channelIcon?: keyof typeof Icons;
|
||||||
@observable typingCache: ObservableMap<SnowflakeType, GatewayTypingStartDispatchData>;
|
@observable typingIds: ObservableSet<SnowflakeType>;
|
||||||
@observable isTyping = false;
|
|
||||||
private hasFetchedMessages = false;
|
private hasFetchedMessages = false;
|
||||||
|
|
||||||
constructor(app: AppStore, channel: APIChannel) {
|
constructor(app: AppStore, channel: APIChannel) {
|
||||||
this.app = app;
|
this.app = app;
|
||||||
this.typingCache = new ObservableMap();
|
this.typingIds = new ObservableSet();
|
||||||
|
|
||||||
this.id = channel.id;
|
this.id = channel.id;
|
||||||
this.createdAt = new Date(channel.created_at);
|
this.createdAt = new Date(channel.created_at);
|
||||||
@ -217,39 +218,6 @@ export default class Channel {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
|
||||||
async sendTyping() {
|
|
||||||
this.isTyping = true;
|
|
||||||
await this.app.rest.post<void, void>(Routes.channelTyping(this.id));
|
|
||||||
|
|
||||||
// expire after 10 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
runInAction(() => {
|
|
||||||
this.isTyping = false;
|
|
||||||
});
|
|
||||||
}, 10000); // TODO: make this configurable?
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
|
||||||
handleTyping(data: GatewayTypingStartDispatchData) {
|
|
||||||
this.typingCache.set(data.user_id, data);
|
|
||||||
|
|
||||||
// expire after 10 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
runInAction(() => {
|
|
||||||
this.typingCache.delete(data.user_id);
|
|
||||||
});
|
|
||||||
}, 10000); // TODO: make this configurable?
|
|
||||||
}
|
|
||||||
|
|
||||||
canSendMessage(content: string, attachments: File[]) {
|
|
||||||
if (!attachments.length && (!content || !content.trim() || !content.replace(/\r?\n|\r/g, ""))) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
@computed
|
@computed
|
||||||
get isTextChannel() {
|
get isTextChannel() {
|
||||||
return (
|
return (
|
||||||
@ -269,9 +237,18 @@ export default class Channel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@computed
|
@computed
|
||||||
get typingUsers(): APIUser[] {
|
get typingUsers(): User[] {
|
||||||
return Array.from(this.typingCache.values())
|
return Array.from(this.typingIds.values())
|
||||||
.map((x) => x.member!.user!)
|
.map((x) => this.app.users.get(x) as User)
|
||||||
.filter((x) => x && x.id !== this.app.account!.id);
|
.filter((x) => x && x.id !== this.app.account!.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasPermission(permission: PermissionResolvable) {
|
||||||
|
const permissions = Permissions.getPermission(
|
||||||
|
this.app.account!.id,
|
||||||
|
this.guildId ? this.app.guilds.get(this.guildId) : undefined,
|
||||||
|
this,
|
||||||
|
);
|
||||||
|
return permissions.has(permission);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
6
src/utils/isTouchscreenDevice.ts
Normal file
6
src/utils/isTouchscreenDevice.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
// https://github.com/revoltchat/revite/blob/master/src/lib/isTouchscreenDevice.ts
|
||||||
|
|
||||||
|
import { isDesktop, isMobile, isTablet } from "react-device-detect";
|
||||||
|
|
||||||
|
export const isTouchscreenDevice =
|
||||||
|
isDesktop || isTablet ? false : (typeof window !== "undefined" ? navigator.maxTouchPoints > 0 : false) || isMobile;
|
Loading…
Reference in New Issue
Block a user