feat: Comments (#4911)
* Comment model * Framework, model, policy, presenter, api endpoint etc * Iteration, first pass of UI * fixes, refactors * Comment commands * comment socket support * typing indicators * comment component, styling * wip * right sidebar resize * fix: CMD+Enter submit * Add usePersistedState fix: Main page scrolling on comment highlight * drafts * Typing indicator * refactor * policies * Click thread to highlight Improve comment timestamps * padding * Comment menu v1 * Change comments to use editor * Basic comment editing * fix: Hide commenting button when disabled at team level * Enable opening sidebar without mark * Move selected comment to location state * Add comment delete confirmation * Add comment count to document meta * fix: Comment sidebar togglable Add copy link to comment * stash * Restore History changes * Refactor right sidebar to allow for comment animation * Update to new router best practices * stash * Various improvements * stash * Handle click outside * Fix incorrect placeholder in input fix: Input box appearing on other sessions erroneously * stash * fix: Don't leave orphaned child comments * styling * stash * Enable comment toggling again * Edit styling, merge conflicts * fix: Cannot navigate from insights to comments * Remove draft comment mark on click outside * Fix: Empty comment sidebar, tsc * Remove public toggle * fix: All comments are recessed fix: Comments should not be printed * fix: Associated mark should be removed on comment delete * Revert unused changes * Empty state, basic RTL support * Create dont toggle comment mark * Make it feel more snappy * Highlight active comment in text * fix animation * RTL support * Add reply CTA * Translations
This commit is contained in:
@@ -2,6 +2,7 @@ import { AnimatePresence } from "framer-motion";
|
||||
import { observer, useLocalStore } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Switch, Route, useLocation, matchPath } from "react-router-dom";
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import ErrorSuspended from "~/scenes/ErrorSuspended";
|
||||
import DocumentContext from "~/components/DocumentContext";
|
||||
import type { DocumentContextValue } from "~/components/DocumentContext";
|
||||
@@ -16,14 +17,17 @@ import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
import {
|
||||
searchPath,
|
||||
matchDocumentSlug as slug,
|
||||
newDocumentPath,
|
||||
settingsPath,
|
||||
matchDocumentHistory,
|
||||
matchDocumentSlug as slug,
|
||||
matchDocumentInsights,
|
||||
} from "~/utils/routeHelpers";
|
||||
import Fade from "./Fade";
|
||||
|
||||
const DocumentComments = React.lazy(
|
||||
() => import("~/scenes/Document/components/Comments")
|
||||
);
|
||||
const DocumentHistory = React.lazy(
|
||||
() => import("~/scenes/Document/components/History")
|
||||
);
|
||||
@@ -84,15 +88,21 @@ const AuthenticatedLayout: React.FC = ({ children }) => {
|
||||
const showInsights = !!matchPath(location.pathname, {
|
||||
path: matchDocumentInsights,
|
||||
});
|
||||
const showComments =
|
||||
!showInsights &&
|
||||
!showHistory &&
|
||||
!ui.commentsCollapsed &&
|
||||
team?.getPreference(TeamPreference.Commenting);
|
||||
|
||||
const sidebarRight = (
|
||||
<AnimatePresence key={ui.activeDocumentId}>
|
||||
{(showHistory || showInsights) && (
|
||||
<AnimatePresence>
|
||||
{(showHistory || showInsights || showComments) && (
|
||||
<Route path={`/doc/${slug}`}>
|
||||
<SidebarRight>
|
||||
<React.Suspense fallback={null}>
|
||||
{showHistory && <DocumentHistory />}
|
||||
{showInsights && <DocumentInsights />}
|
||||
{showComments && <DocumentComments />}
|
||||
</React.Suspense>
|
||||
</SidebarRight>
|
||||
</Route>
|
||||
|
||||
@@ -19,15 +19,16 @@ type Props = {
|
||||
showBorder?: boolean;
|
||||
onClick?: React.MouseEventHandler<HTMLImageElement>;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
function Avatar(props: Props) {
|
||||
const { icon, showBorder, model, ...rest } = props;
|
||||
const { icon, showBorder, model, style, ...rest } = props;
|
||||
const src = props.src || model?.avatarUrl;
|
||||
const [error, handleError] = useBoolean(false);
|
||||
|
||||
return (
|
||||
<Relative>
|
||||
<Relative style={style}>
|
||||
{src && !error ? (
|
||||
<CircleImg
|
||||
onError={handleError}
|
||||
@@ -53,6 +54,7 @@ Avatar.defaultProps = {
|
||||
|
||||
const Relative = styled.div`
|
||||
position: relative;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
|
||||
15
app/components/ButtonSmall.ts
Normal file
15
app/components/ButtonSmall.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import styled from "styled-components";
|
||||
import Button, { Inner } from "./Button";
|
||||
|
||||
const ButtonSmall = styled(Button)`
|
||||
font-size: 13px;
|
||||
height: 26px;
|
||||
|
||||
${Inner} {
|
||||
padding: 0 6px;
|
||||
line-height: 26px;
|
||||
min-height: 26px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default ButtonSmall;
|
||||
53
app/components/CommentDeleteDialog.tsx
Normal file
53
app/components/CommentDeleteDialog.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import Comment from "~/models/Comment";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
|
||||
type Props = {
|
||||
comment: Comment;
|
||||
onSubmit?: () => void;
|
||||
};
|
||||
|
||||
function CommentDeleteDialog({ comment, onSubmit }: Props) {
|
||||
const { comments } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const { t } = useTranslation();
|
||||
const hasChildComments = comments.inThread(comment.id).length > 1;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await comment.delete();
|
||||
onSubmit?.();
|
||||
} catch (err) {
|
||||
showToast(err.message, { type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
submitText={t("I’m sure – Delete")}
|
||||
savingText={`${t("Deleting")}…`}
|
||||
danger
|
||||
>
|
||||
<Text type="secondary">
|
||||
{hasChildComments ? (
|
||||
<Trans>
|
||||
Are you sure you want to permanently delete this entire comment
|
||||
thread?
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Are you sure you want to permanently delete this comment?
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(CommentDeleteDialog);
|
||||
@@ -39,8 +39,8 @@ const Button = styled(NudeButton)`
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
right: 32px;
|
||||
margin: 24px;
|
||||
transform: translateX(-32px);
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: block;
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { LocationDescriptor } from "history";
|
||||
import { observer, useObserver } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useRouteMatch } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import Document from "~/models/Document";
|
||||
import DocumentMeta from "~/components/DocumentMeta";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { documentUrl, documentInsightsUrl } from "~/utils/routeHelpers";
|
||||
import Fade from "./Fade";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
isDraft: boolean;
|
||||
to?: LocationDescriptor;
|
||||
rtl?: boolean;
|
||||
};
|
||||
|
||||
function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
|
||||
const { views } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const match = useRouteMatch();
|
||||
const documentViews = useObserver(() => views.inDocument(document.id));
|
||||
const totalViewers = documentViews.length;
|
||||
const onlyYou = totalViewers === 1 && documentViews[0].user.id;
|
||||
const viewsLoadedOnMount = React.useRef(totalViewers > 0);
|
||||
|
||||
const insightsUrl = documentInsightsUrl(document);
|
||||
const Wrapper = viewsLoadedOnMount.current ? React.Fragment : Fade;
|
||||
|
||||
return (
|
||||
<Meta document={document} to={to} replace {...rest}>
|
||||
{totalViewers && !isDraft ? (
|
||||
<Wrapper>
|
||||
•
|
||||
<Link
|
||||
to={match.url === insightsUrl ? documentUrl(document) : insightsUrl}
|
||||
>
|
||||
{t("Viewed by")}{" "}
|
||||
{onlyYou
|
||||
? t("only you")
|
||||
: `${totalViewers} ${
|
||||
totalViewers === 1 ? t("person") : t("people")
|
||||
}`}
|
||||
</Link>
|
||||
</Wrapper>
|
||||
) : null}
|
||||
</Meta>
|
||||
);
|
||||
}
|
||||
|
||||
const Meta = styled(DocumentMeta)<{ rtl?: boolean }>`
|
||||
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
||||
margin: -12px 0 2em 0;
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(DocumentMetaWithViews);
|
||||
@@ -1,14 +1,15 @@
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { deburr, sortBy } from "lodash";
|
||||
import { deburr, difference, sortBy } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
|
||||
import { TextSelection } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { Optional } from "utility-types";
|
||||
import insertFiles from "@shared/editor/commands/insertFiles";
|
||||
import { Heading } from "@shared/editor/lib/getHeadings";
|
||||
import { AttachmentPreset } from "@shared/types";
|
||||
import { Heading } from "@shared/utils/ProsemirrorHelper";
|
||||
import { getDataTransferFiles } from "@shared/utils/files";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { isInternalUrl } from "@shared/utils/urls";
|
||||
@@ -20,11 +21,11 @@ import HoverPreview from "~/components/HoverPreview";
|
||||
import type { Props as EditorProps, Editor as SharedEditor } from "~/editor";
|
||||
import useDictionary from "~/hooks/useDictionary";
|
||||
import useEmbeds from "~/hooks/useEmbeds";
|
||||
import useFocusedComment from "~/hooks/useFocusedComment";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import { NotFoundError } from "~/utils/errors";
|
||||
import { uploadFile } from "~/utils/files";
|
||||
import history from "~/utils/history";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
import { isHash } from "~/utils/urls";
|
||||
@@ -51,11 +52,20 @@ export type Props = Optional<
|
||||
};
|
||||
|
||||
function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
const { id, shareId, onChange, onHeadingsChange } = props;
|
||||
const { documents, auth } = useStores();
|
||||
const {
|
||||
id,
|
||||
shareId,
|
||||
onChange,
|
||||
onHeadingsChange,
|
||||
onCreateCommentMark,
|
||||
onDeleteCommentMark,
|
||||
} = props;
|
||||
const { auth, comments, documents } = useStores();
|
||||
const focusedComment = useFocusedComment();
|
||||
const { showToast } = useToasts();
|
||||
const dictionary = useDictionary();
|
||||
const embeds = useEmbeds(!shareId);
|
||||
const history = useHistory();
|
||||
const localRef = React.useRef<SharedEditor>();
|
||||
const preferences = auth.user?.preferences;
|
||||
const previousHeadings = React.useRef<Heading[] | null>(null);
|
||||
@@ -63,6 +73,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
activeLinkElement,
|
||||
setActiveLink,
|
||||
] = React.useState<HTMLAnchorElement | null>(null);
|
||||
const previousCommentIds = React.useRef<string[]>();
|
||||
|
||||
const handleLinkActive = React.useCallback((element: HTMLAnchorElement) => {
|
||||
setActiveLink(element);
|
||||
@@ -125,7 +136,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
[documents]
|
||||
);
|
||||
|
||||
const onUploadFile = React.useCallback(
|
||||
const handleUploadFile = React.useCallback(
|
||||
async (file: File) => {
|
||||
const result = await uploadFile(file, {
|
||||
documentId: id,
|
||||
@@ -136,7 +147,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
[id]
|
||||
);
|
||||
|
||||
const onClickLink = React.useCallback(
|
||||
const handleClickLink = React.useCallback(
|
||||
(href: string, event: MouseEvent) => {
|
||||
// on page hash
|
||||
if (isHash(href)) {
|
||||
@@ -175,7 +186,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
window.open(href, "_blank");
|
||||
}
|
||||
},
|
||||
[shareId]
|
||||
[history, shareId]
|
||||
);
|
||||
|
||||
const focusAtEnd = React.useCallback(() => {
|
||||
@@ -223,7 +234,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
);
|
||||
|
||||
insertFiles(view, event, pos, files, {
|
||||
uploadFile: onUploadFile,
|
||||
uploadFile: handleUploadFile,
|
||||
onFileUploadStart: props.onFileUploadStart,
|
||||
onFileUploadStop: props.onFileUploadStop,
|
||||
onShowToast: showToast,
|
||||
@@ -236,7 +247,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
props.onFileUploadStart,
|
||||
props.onFileUploadStop,
|
||||
dictionary,
|
||||
onUploadFile,
|
||||
handleUploadFile,
|
||||
showToast,
|
||||
]
|
||||
);
|
||||
@@ -265,21 +276,54 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
}
|
||||
}, [localRef, onHeadingsChange]);
|
||||
|
||||
const updateComments = React.useCallback(() => {
|
||||
if (onCreateCommentMark && onDeleteCommentMark) {
|
||||
const commentMarks = localRef.current?.getComments();
|
||||
const commentIds = comments.orderedData.map((c) => c.id);
|
||||
const commentMarkIds = commentMarks?.map((c) => c.id);
|
||||
const newCommentIds = difference(
|
||||
commentMarkIds,
|
||||
previousCommentIds.current ?? [],
|
||||
commentIds
|
||||
);
|
||||
|
||||
newCommentIds.forEach((commentId) => {
|
||||
const mark = commentMarks?.find((c) => c.id === commentId);
|
||||
if (mark) {
|
||||
onCreateCommentMark(mark.id, mark.userId);
|
||||
}
|
||||
});
|
||||
|
||||
const removedCommentIds = difference(
|
||||
previousCommentIds.current ?? [],
|
||||
commentMarkIds ?? []
|
||||
);
|
||||
|
||||
removedCommentIds.forEach((commentId) => {
|
||||
onDeleteCommentMark(commentId);
|
||||
});
|
||||
|
||||
previousCommentIds.current = commentMarkIds;
|
||||
}
|
||||
}, [onCreateCommentMark, onDeleteCommentMark, comments.orderedData]);
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(event) => {
|
||||
onChange?.(event);
|
||||
updateHeadings();
|
||||
updateComments();
|
||||
},
|
||||
[onChange, updateHeadings]
|
||||
[onChange, updateComments, updateHeadings]
|
||||
);
|
||||
|
||||
const handleRefChanged = React.useCallback(
|
||||
(node: SharedEditor | null) => {
|
||||
if (node) {
|
||||
updateHeadings();
|
||||
updateComments();
|
||||
}
|
||||
},
|
||||
[updateHeadings]
|
||||
[updateComments, updateHeadings]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -287,18 +331,19 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
<>
|
||||
<LazyLoadedEditor
|
||||
ref={mergeRefs([ref, localRef, handleRefChanged])}
|
||||
uploadFile={onUploadFile}
|
||||
uploadFile={handleUploadFile}
|
||||
onShowToast={showToast}
|
||||
embeds={embeds}
|
||||
userPreferences={preferences}
|
||||
dictionary={dictionary}
|
||||
{...props}
|
||||
onHoverLink={handleLinkActive}
|
||||
onClickLink={onClickLink}
|
||||
onClickLink={handleClickLink}
|
||||
onSearchLink={handleSearchLink}
|
||||
onChange={handleChange}
|
||||
placeholder={props.placeholder || ""}
|
||||
defaultValue={props.defaultValue || ""}
|
||||
focusedCommentId={focusedComment?.id}
|
||||
/>
|
||||
{props.bottomPadding && !props.readOnly && (
|
||||
<ClickablePadding
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import DocumentMetaWithViews from "~/components/DocumentMetaWithViews";
|
||||
import DocumentMeta from "~/components/DocumentMeta";
|
||||
import Editor from "~/components/Editor";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
@@ -30,10 +30,7 @@ function HoverPreviewDocument({ url, children }: Props) {
|
||||
{children(
|
||||
<Content to={document.url}>
|
||||
<Heading>{document.titleWithDefault}</Heading>
|
||||
<DocumentMetaWithViews
|
||||
isDraft={document.isDraft}
|
||||
document={document}
|
||||
/>
|
||||
<DocumentMeta document={document} />
|
||||
|
||||
<React.Suspense fallback={<div />}>
|
||||
<Editor
|
||||
|
||||
@@ -121,6 +121,8 @@ export type Props = React.InputHTMLAttributes<
|
||||
margin?: string | number;
|
||||
error?: string;
|
||||
icon?: React.ReactNode;
|
||||
/* Callback is triggered with the CMD+Enter keyboard combo */
|
||||
onRequestSubmit?: (ev: React.KeyboardEvent<HTMLInputElement>) => unknown;
|
||||
onFocus?: (ev: React.SyntheticEvent) => unknown;
|
||||
onBlur?: (ev: React.SyntheticEvent) => unknown;
|
||||
};
|
||||
@@ -147,6 +149,20 @@ function Input(
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (
|
||||
ev: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => {
|
||||
if (ev.key === "Enter" && ev.metaKey) {
|
||||
if (this.props.onRequestSubmit) {
|
||||
this.props.onRequestSubmit(ev);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.props.onKeyDown) {
|
||||
this.props.onKeyDown(ev);
|
||||
}
|
||||
};
|
||||
|
||||
const {
|
||||
type = "text",
|
||||
icon,
|
||||
@@ -180,6 +196,7 @@ function Input(
|
||||
ref={ref as React.RefObject<HTMLTextAreaElement>}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
hasIcon={!!icon}
|
||||
{...rest}
|
||||
/>
|
||||
@@ -188,6 +205,7 @@ function Input(
|
||||
ref={ref as React.RefObject<HTMLInputElement>}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
hasIcon={!!icon}
|
||||
type={type}
|
||||
{...rest}
|
||||
|
||||
42
app/components/ResizingHeightContainer.tsx
Normal file
42
app/components/ResizingHeightContainer.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { m, TargetAndTransition } from "framer-motion";
|
||||
import * as React from "react";
|
||||
import useComponentSize from "~/hooks/useComponentSize";
|
||||
|
||||
type Props = {
|
||||
/** The children to render */
|
||||
children: React.ReactNode;
|
||||
/** Whether to hide overflow. */
|
||||
hideOverflow?: boolean;
|
||||
/** A way to calculate height */
|
||||
componentSizeCalculation?: "clientRectHeight" | "scrollHeight";
|
||||
/** Optional animation config. */
|
||||
config?: TargetAndTransition;
|
||||
/** Optional styles. */
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
/**
|
||||
* Automatically animates the height of a container based on it's contents.
|
||||
*/
|
||||
export function ResizingHeightContainer(props: Props) {
|
||||
const { hideOverflow, children, config, style } = props;
|
||||
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const { height } = useComponentSize(ref);
|
||||
|
||||
return (
|
||||
<m.div
|
||||
animate={{
|
||||
...config,
|
||||
height: Math.round(height),
|
||||
}}
|
||||
style={{
|
||||
...style,
|
||||
overflow: hideOverflow ? "hidden" : "inherit",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div ref={ref}>{children}</div>
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import useWindowSize from "~/hooks/useWindowSize";
|
||||
import { hideScrollbars } from "~/styles";
|
||||
|
||||
type Props = React.HTMLAttributes<HTMLDivElement> & {
|
||||
shadow?: boolean;
|
||||
@@ -94,16 +95,7 @@ const Wrapper = styled.div<{
|
||||
}};
|
||||
transition: box-shadow 100ms ease-in-out;
|
||||
|
||||
${(props) =>
|
||||
props.$hiddenScrollbars &&
|
||||
`
|
||||
-ms-overflow-style: none;
|
||||
overflow: -moz-scrollbars-none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
`}
|
||||
${(props) => props.$hiddenScrollbars && hideScrollbars()}
|
||||
`;
|
||||
|
||||
export default observer(React.forwardRef(Scrollable));
|
||||
|
||||
@@ -35,7 +35,6 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(
|
||||
const previousLocation = usePrevious(location);
|
||||
const { isMenuOpen } = useMenuContext();
|
||||
const { user } = auth;
|
||||
|
||||
const width = ui.sidebarWidth;
|
||||
const collapsed = ui.sidebarIsClosed && !isMenuOpen;
|
||||
const maxWidth = theme.sidebarMaxWidth;
|
||||
|
||||
@@ -3,6 +3,8 @@ import styled from "styled-components";
|
||||
type Props = {
|
||||
type?: "secondary" | "tertiary" | "danger";
|
||||
size?: "large" | "small" | "xsmall";
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
selectable?: boolean;
|
||||
weight?: "bold" | "normal";
|
||||
};
|
||||
|
||||
@@ -12,6 +14,7 @@ type Props = {
|
||||
*/
|
||||
const Text = styled.p<Props>`
|
||||
margin-top: 0;
|
||||
text-align: ${(props) => (props.dir ? props.dir : "left")};
|
||||
color: ${(props) =>
|
||||
props.type === "secondary"
|
||||
? props.theme.textSecondary
|
||||
@@ -35,7 +38,7 @@ const Text = styled.p<Props>`
|
||||
? "normal"
|
||||
: "inherit"};
|
||||
white-space: normal;
|
||||
user-select: none;
|
||||
user-select: ${(props) => (props.selectable ? "text" : "none")};
|
||||
`;
|
||||
|
||||
export default Text;
|
||||
|
||||
41
app/components/Typing.tsx
Normal file
41
app/components/Typing.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {
|
||||
/** The size to render the indicator, defaults to 24px */
|
||||
size?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A component to show an animated typing indicator.
|
||||
*/
|
||||
export default function Typing({ size = 24 }: Props) {
|
||||
return (
|
||||
<Wrapper height={size} width={size}>
|
||||
<Circle cx={size / 4} cy={size / 2} r="2" />
|
||||
<Circle cx={size / 2} cy={size / 2} r="2" />
|
||||
<Circle cx={size / 1.33333} cy={size / 2} r="2" />
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const Wrapper = styled.svg`
|
||||
fill: ${(props) => props.theme.textTertiary};
|
||||
|
||||
@keyframes blink {
|
||||
50% {
|
||||
fill: transparent;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const Circle = styled.circle`
|
||||
animation: 1s blink infinite;
|
||||
|
||||
&:nth-child(2) {
|
||||
animation-delay: 250ms;
|
||||
}
|
||||
&:nth-child(3) {
|
||||
animation-delay: 500ms;
|
||||
}
|
||||
`;
|
||||
@@ -6,6 +6,7 @@ import * as React from "react";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import Collection from "~/models/Collection";
|
||||
import Comment from "~/models/Comment";
|
||||
import Document from "~/models/Document";
|
||||
import FileOperation from "~/models/FileOperation";
|
||||
import Group from "~/models/Group";
|
||||
@@ -84,6 +85,7 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
memberships,
|
||||
policies,
|
||||
presence,
|
||||
comments,
|
||||
views,
|
||||
subscriptions,
|
||||
fileOperations,
|
||||
@@ -261,6 +263,20 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on("comments.create", (event: PartialWithId<Comment>) => {
|
||||
comments.add(event);
|
||||
});
|
||||
|
||||
this.socket.on("comments.update", (event: PartialWithId<Comment>) => {
|
||||
comments.add(event);
|
||||
});
|
||||
|
||||
this.socket.on("comments.delete", (event: WebsocketEntityDeletedEvent) => {
|
||||
comments.inThread(event.modelId).forEach((comment) => {
|
||||
comments.remove(comment.id);
|
||||
});
|
||||
});
|
||||
|
||||
this.socket.on("groups.create", (event: PartialWithId<Group>) => {
|
||||
groups.add(event);
|
||||
});
|
||||
@@ -323,6 +339,13 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
stars.remove(event.modelId);
|
||||
});
|
||||
|
||||
this.socket.on(
|
||||
"user.typing",
|
||||
(event: { userId: string; documentId: string; commentId: string }) => {
|
||||
comments.setTyping(event);
|
||||
}
|
||||
);
|
||||
|
||||
// received when a user is given access to a collection
|
||||
// if the user is us then we go ahead and load the collection from API.
|
||||
this.socket.on(
|
||||
|
||||
Reference in New Issue
Block a user