mirror of
https://github.com/spacebarchat/client.git
synced 2024-11-21 18:02:32 +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-advanced-cropper": "^0.18.0",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-device-detect": "^2.2.3",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-error-boundary": "^3.1.4",
|
||||
"react-hook-form": "^7.43.9",
|
||||
|
@ -77,6 +77,9 @@ dependencies:
|
||||
react-colorful:
|
||||
specifier: ^5.6.1
|
||||
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:
|
||||
specifier: ^18.2.0
|
||||
version: 18.2.0(react@18.2.0)
|
||||
@ -9259,6 +9262,17 @@ packages:
|
||||
- supports-color
|
||||
- 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):
|
||||
resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==}
|
||||
peerDependencies:
|
||||
@ -10799,6 +10813,10 @@ packages:
|
||||
engines: {node: '>=4.2.0'}
|
||||
hasBin: true
|
||||
|
||||
/ua-parser-js@1.0.36:
|
||||
resolution: {integrity: sha512-znuyCIXzl8ciS3+y3fHJI/2OhQIXbXw9MWC/o3qwyR+RGppjZHrM27CGFSKCJXi2Kctiz537iOu2KnXs1lMQhw==}
|
||||
dev: false
|
||||
|
||||
/unbox-primitive@1.0.2:
|
||||
resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==}
|
||||
dependencies:
|
||||
|
@ -2,6 +2,7 @@ import { AnimatePresence, motion } from "framer-motion";
|
||||
import React from "react";
|
||||
import styled from "styled-components";
|
||||
import { BannerContext } from "../contexts/BannerContext";
|
||||
import useLogger from "../hooks/useLogger";
|
||||
import Icon from "./Icon";
|
||||
import IconButton from "./IconButton";
|
||||
|
||||
@ -17,6 +18,7 @@ const CloseWrapper = styled(IconButton)`
|
||||
`;
|
||||
|
||||
function Banner() {
|
||||
const logger = useLogger("Banner");
|
||||
const bannerContext = React.useContext(BannerContext);
|
||||
|
||||
return (
|
||||
@ -45,7 +47,7 @@ function Banner() {
|
||||
animate="show"
|
||||
exit="hide"
|
||||
onAnimationComplete={() => {
|
||||
console.log("animation complete");
|
||||
logger.debug("animation complete");
|
||||
}}
|
||||
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 { 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 React, { useMemo } from "react";
|
||||
import { Descendant, Node, createEditor } from "slate";
|
||||
import { withHistory } from "slate-history";
|
||||
import { Editable, Slate, withReact } from "slate-react";
|
||||
import React from "react";
|
||||
import useLogger from "../../hooks/useLogger";
|
||||
import Guild from "../../stores/objects/Guild";
|
||||
import { Permissions } from "../../utils/Permissions";
|
||||
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";
|
||||
import MessageTextArea from "./MessageTextArea";
|
||||
|
||||
const Container = styled.div`
|
||||
margin-top: -8px;
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
flex-shrink: 0;
|
||||
z-index: 1;
|
||||
`;
|
||||
enum UploadStateType {
|
||||
NONE,
|
||||
ATTACHED,
|
||||
UPLOADING,
|
||||
SENDING,
|
||||
}
|
||||
|
||||
const InnerContainer = styled.div`
|
||||
background-color: var(--background-primary);
|
||||
margin-bottom: 24px;
|
||||
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 UploadState {
|
||||
type: UploadStateType;
|
||||
files: File[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
channel: Channel;
|
||||
@ -85,205 +28,92 @@ interface Props {
|
||||
/**
|
||||
* Component for sending messages
|
||||
*/
|
||||
function MessageInput(props: Props) {
|
||||
const app = useAppStore();
|
||||
function MessageInput({ channel }: Props) {
|
||||
const logger = useLogger("MessageInput");
|
||||
const editor = useMemo(() => withHistory(withReact(createEditor())), []);
|
||||
const [content, setContent] = React.useState("");
|
||||
const [canSendMessages, setCanSendMessages] = React.useState(true);
|
||||
const [canUpload, setCanUpload] = React.useState(true);
|
||||
const uploadRef = React.useRef<HTMLInputElement>(null);
|
||||
const [attachments, setAttachments] = React.useState<File[]>([]);
|
||||
const [uploadState, setUploadState] = React.useState<UploadState>({
|
||||
type: UploadStateType.NONE,
|
||||
files: [],
|
||||
});
|
||||
const [typing, setTyping] = React.useState<number | null>(null);
|
||||
|
||||
editor.insertData = (data) => {
|
||||
const text = data.getData("text/plain");
|
||||
const { files } = data;
|
||||
/**
|
||||
* Starts typing for client user and triggers gateway event
|
||||
*/
|
||||
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];
|
||||
setAttachments(newAttachments);
|
||||
} else {
|
||||
editor.insertText(text);
|
||||
/**
|
||||
* Stops typing for client user
|
||||
*/
|
||||
const stopTyping = React.useCallback(() => {
|
||||
if (typing) {
|
||||
logger.debug("ShouldStopTyping");
|
||||
setTyping(null);
|
||||
}
|
||||
};
|
||||
}, [typing, setTyping]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const permission = Permissions.getPermission(app.account!.id, props.guild, props.channel);
|
||||
setCanSendMessages(permission.has("SEND_MESSAGES"));
|
||||
setCanUpload(permission.has("ATTACH_FILES"));
|
||||
}, [props.channel, props.guild]);
|
||||
/**
|
||||
* Debounced version of stopTyping
|
||||
*/
|
||||
const debouncedStopTyping = React.useCallback(debounce(stopTyping, 10000), [stopTyping]);
|
||||
|
||||
const serialize = React.useCallback((value: Descendant[]) => {
|
||||
return (
|
||||
value
|
||||
// Return the string content of each paragraph in the value's children.
|
||||
.map((n) => Node.string(n))
|
||||
// Join them all with line breaks denoting paragraphs.
|
||||
.join("\n")
|
||||
);
|
||||
/**
|
||||
* @returns Whether or not a message can be sent given the current state
|
||||
*/
|
||||
const canSendMessage = () =>
|
||||
React.useCallback(() => {
|
||||
if (!uploadState.files.length && (!content || !content.trim() || !content.replace(/\r?\n|\r/g, ""))) {
|
||||
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(
|
||||
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
if (!props.channel) {
|
||||
logger.warn("No channel selected, cannot send message");
|
||||
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 onKeyDown = React.useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// TODO:
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
}, []);
|
||||
|
||||
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 (
|
||||
<Container>
|
||||
<InnerContainer>
|
||||
<div
|
||||
style={{
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
>
|
||||
{attachments.length > 0 && (
|
||||
<>
|
||||
<AttachmentsList>
|
||||
{attachments.map((file, index) => (
|
||||
<AttachmentUploadList
|
||||
key={index}
|
||||
file={file}
|
||||
remove={() => removeAttachment(index)}
|
||||
/>
|
||||
))}
|
||||
</AttachmentsList>
|
||||
<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>
|
||||
<MessageTextArea
|
||||
id="messageinput"
|
||||
// maxLength={4000} // TODO: this should come from the server
|
||||
value={content}
|
||||
placeholder={
|
||||
channel.hasPermission("SEND_MESSAGES")
|
||||
? `Message ${
|
||||
channel.type === ChannelType.DM ? channel.recipients?.[0].username : "#" + channel.name
|
||||
}`
|
||||
: "You do not have permission to send messages in this channel."
|
||||
}
|
||||
disabled={
|
||||
!channel.hasPermission("SEND_MESSAGES") ||
|
||||
uploadState.type === UploadStateType.UPLOADING ||
|
||||
uploadState.type === UploadStateType.SENDING
|
||||
}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
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>) => {
|
||||
setMaxAge(EXPIRE_OPTIONS.find((x) => x.value === Number(e.target.value)) ?? EXPIRE_OPTIONS[5]);
|
||||
setIsEdited(true);
|
||||
console.log("max age changed");
|
||||
};
|
||||
|
||||
const handleMaxUsesChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
setMaxUses(MAX_USES_OPTIONS.find((x) => x.value === Number(e.target.value)) ?? MAX_USES_OPTIONS[0]);
|
||||
setIsEdited(true);
|
||||
console.log("max uses changed");
|
||||
};
|
||||
|
||||
React.useEffect(() => createCode(), []);
|
||||
|
@ -11,6 +11,7 @@ const font: ThemeFont["font"] = {
|
||||
bold: 700,
|
||||
black: 900,
|
||||
},
|
||||
family: "Roboto, Arial, Helvetica, sans-serif",
|
||||
};
|
||||
|
||||
export type ThemeVariables =
|
||||
@ -68,6 +69,7 @@ export type ThemeFont = {
|
||||
bold?: number;
|
||||
black?: number;
|
||||
};
|
||||
family: string;
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -12,7 +12,7 @@ span {
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Roboto", Arial, Helvetica, sans-serif;
|
||||
font-family: var(--font-family);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overflow: hidden;
|
||||
|
@ -4,6 +4,7 @@ import secureLocalStorage from "react-secure-storage";
|
||||
import Logger from "../utils/Logger";
|
||||
import REST from "../utils/REST";
|
||||
import AccountStore from "./AccountStore";
|
||||
import ChannelStore from "./ChannelStore";
|
||||
import ExperimentsStore from "./ExperimentsStore";
|
||||
import GatewayConnectionStore from "./GatewayConnectionStore";
|
||||
import GuildStore from "./GuildStore";
|
||||
@ -33,6 +34,7 @@ export default class AppStore {
|
||||
@observable account: AccountStore | null = null;
|
||||
@observable gateway = new GatewayConnectionStore(this);
|
||||
@observable guilds = new GuildStore(this);
|
||||
@observable channels = new ChannelStore(this);
|
||||
@observable users = new UserStore();
|
||||
@observable privateChannels = new PrivateChannelStore(this);
|
||||
@observable rest = new REST(this);
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { debounce } from "@mui/material";
|
||||
import {
|
||||
APIGuildMember,
|
||||
APIMessage,
|
||||
@ -635,6 +636,12 @@ export default class GatewayConnectionStore {
|
||||
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,
|
||||
APIUser,
|
||||
APIWebhook,
|
||||
GatewayTypingStartDispatchData,
|
||||
GatewayVoiceState,
|
||||
RESTGetAPIChannelMessagesQuery,
|
||||
RESTGetAPIChannelMessagesResult,
|
||||
@ -15,12 +14,15 @@ import type {
|
||||
Snowflake as SnowflakeType,
|
||||
} 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 type { PermissionResolvable } from "../../utils/Permissions";
|
||||
import { Permissions } from "../../utils/Permissions";
|
||||
import { APIError } from "../../utils/interfaces/api";
|
||||
import AppStore from "../AppStore";
|
||||
import MessageStore from "../MessageStore";
|
||||
import QueuedMessage from "./QueuedMessage";
|
||||
import User from "./User";
|
||||
|
||||
export default class Channel {
|
||||
private readonly logger: Logger = new Logger("Channel");
|
||||
@ -55,13 +57,12 @@ export default class Channel {
|
||||
@observable flags: number;
|
||||
@observable defaultThreadRateLimitPerUser: number;
|
||||
@observable channelIcon?: keyof typeof Icons;
|
||||
@observable typingCache: ObservableMap<SnowflakeType, GatewayTypingStartDispatchData>;
|
||||
@observable isTyping = false;
|
||||
@observable typingIds: ObservableSet<SnowflakeType>;
|
||||
private hasFetchedMessages = false;
|
||||
|
||||
constructor(app: AppStore, channel: APIChannel) {
|
||||
this.app = app;
|
||||
this.typingCache = new ObservableMap();
|
||||
this.typingIds = new ObservableSet();
|
||||
|
||||
this.id = channel.id;
|
||||
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
|
||||
get isTextChannel() {
|
||||
return (
|
||||
@ -269,9 +237,18 @@ export default class Channel {
|
||||
}
|
||||
|
||||
@computed
|
||||
get typingUsers(): APIUser[] {
|
||||
return Array.from(this.typingCache.values())
|
||||
.map((x) => x.member!.user!)
|
||||
get typingUsers(): User[] {
|
||||
return Array.from(this.typingIds.values())
|
||||
.map((x) => this.app.users.get(x) as User)
|
||||
.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