1
0
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:
Puyodead1 2023-09-09 15:33:22 -04:00
parent 0b547226c3
commit 4b0cd78f7a
No known key found for this signature in database
GPG Key ID: A4FA4FEC0DD353FC
14 changed files with 308 additions and 389 deletions

View File

@ -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",

View File

@ -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:

View File

@ -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}
>

View File

@ -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}
/>
);
}

View 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;

View 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);

View File

@ -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);

View File

@ -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(), []);

View File

@ -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;
};
};

View File

@ -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;

View File

@ -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);

View File

@ -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)();
}
};
}

View File

@ -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);
}
}

View 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;