diff --git a/packages/app/src/app/pages/Sandbox/Editor/Workspace/screens/Comments/Dialog/Reply.tsx b/packages/app/src/app/pages/Sandbox/Editor/Workspace/screens/Comments/Dialog/Reply.tsx index 915a97037e4..75f33500f48 100644 --- a/packages/app/src/app/pages/Sandbox/Editor/Workspace/screens/Comments/Dialog/Reply.tsx +++ b/packages/app/src/app/pages/Sandbox/Editor/Workspace/screens/Comments/Dialog/Reply.tsx @@ -1,4 +1,4 @@ -import { Element, Menu, Stack } from '@codesandbox/components'; +import { Element, Menu, Stack, SkeletonText } from '@codesandbox/components'; import css from '@styled-system/css'; import { CommentFragment } from 'app/graphql/types'; import { useOvermind } from 'app/overmind'; @@ -73,3 +73,22 @@ export const Reply = ({ reply }: ReplyProps) => { ); }; + +export const SkeletonReply = props => ( + + + + + + + + + + + + + + + + +); diff --git a/packages/app/src/app/pages/Sandbox/Editor/Workspace/screens/Comments/Dialog/index.tsx b/packages/app/src/app/pages/Sandbox/Editor/Workspace/screens/Comments/Dialog/index.tsx index 44cc32b55ff..c53921050a7 100644 --- a/packages/app/src/app/pages/Sandbox/Editor/Workspace/screens/Comments/Dialog/index.tsx +++ b/packages/app/src/app/pages/Sandbox/Editor/Workspace/screens/Comments/Dialog/index.tsx @@ -19,19 +19,22 @@ import ReactDOM from 'react-dom'; import { AvatarBlock } from '../components/AvatarBlock'; import { EditComment } from '../components/EditComment'; import { Markdown } from './Markdown'; -import { Reply } from './Reply'; +import { Reply, SkeletonReply } from './Reply'; import { useScrollTop } from './use-scroll-top'; export const CommentDialog = props => ReactDOM.createPortal(, document.body); const DIALOG_WIDTH = 420; +const DIALOG_TRANSITION_DURATION = 0.25; +const REPLY_TRANSITION_DELAY = 0.25; export const Dialog: React.FC = () => { const { state } = useOvermind(); const controller = useAnimation(); const comment = state.comments.currentComment; + const replies = comment.comments; // This comment doens't exist in the database, it's an optimistic comment const isNewComment = comment.id === OPTIMISTIC_COMMENT_ID; @@ -47,6 +50,8 @@ export const Dialog: React.FC = () => { // this could rather be `getInitialPosition` const initialPosition = getInitialPosition(currentCommentPositions); + const [repliesRendered, setRepliesRendered] = React.useState(false); + // reset editing when comment changes React.useEffect(() => { setEditing(isNewComment); @@ -67,8 +72,13 @@ export const Dialog: React.FC = () => { currentCommentPositions, isCodeComment, isNewComment, + repliesRendered, ]); + React.useEffect(() => { + setRepliesRendered(false); + }, [comment.id]); + const onDragHandlerPan = (deltaX: number, deltaY: number) => { controller.set((_, target) => ({ x: Number(target.x) + deltaX, @@ -84,7 +94,7 @@ export const Dialog: React.FC = () => { key={isCodeComment ? 'code' : 'global'} initial={{ ...initialPosition, scale: 0.5, opacity: 0 }} animate={controller} - transition={{ duration: 0.25 }} + transition={{ duration: DIALOG_TRANSITION_DURATION }} style={{ position: 'absolute', zIndex: 2 }} > { editing={editing} setEditing={setEditing} /> - + {replies.length ? ( + setRepliesRendered(true)} + /> + ) : null} @@ -331,13 +346,45 @@ const CommentBody = ({ comment, editing, setEditing }) => { ); }; -const Replies = ({ replies }) => ( - <> - {replies.map(reply => - reply ? : 'Loading...' - )} - -); +const Replies = ({ replies, repliesRenderedCallback }) => { + const [isAnimating, setAnimating] = React.useState(true); + const repliesLoaded = !!replies[0]; + + /** Wait another ms after the dialog has transitioned into view */ + const delay = DIALOG_TRANSITION_DURATION + REPLY_TRANSITION_DELAY; + const REPLY_TRANSITION_DURATION = 0.25; + const SKELETON_HEIGHT = 146; + + React.useEffect(() => { + if (repliesLoaded && !isAnimating) repliesRenderedCallback(); + }, [repliesLoaded, isAnimating, repliesRenderedCallback]); + + return ( + setAnimating(false)} + > + {repliesLoaded ? ( + <> + {replies.map(reply => ( + + ))} + + ) : ( + + )} + + ); +}; const AddReply = ({ comment }) => { const { actions } = useOvermind(); diff --git a/packages/components/src/components/SkeletonText/index.tsx b/packages/components/src/components/SkeletonText/index.tsx new file mode 100644 index 00000000000..d2a3e6d4ad6 --- /dev/null +++ b/packages/components/src/components/SkeletonText/index.tsx @@ -0,0 +1,52 @@ +import styled, { css as styledcss, keyframes } from 'styled-components'; +import Color from 'color'; +import { Element } from '../Element'; + +// export interface ITextProps extends React.HTMLAttributes {} + +const pulse = keyframes` + 0% { background-position: 100% 50%; } + 100% { background-position: -100% 50%; } +`; + +export const SkeletonText = styled(Element)(props => { + const color = props.theme.colors.sideBar.border; + const themeType = props.theme.type; + + /** + * This is fun, + * We animate the background gradient to create a pulse animation + * + * To support all themes nicely, we can't really pick a value from the theme + * So, we take the sidebar.border and then change it's luminosity + * 14% for background and 16% for the pulse highlight on top + * We need to set the value to 100 - value for light themes + */ + + const backgroundLuminosity = themeType === 'light' ? 86 : 14; + const highlightLuminosity = themeType === 'light' ? 88 : 16; + + // @ts-ignore - we have multiple versions of color in the app + // which leads to confusing type checks + const [h, s] = Color(color).hsl().color; + + const background = Color({ h, s, l: backgroundLuminosity }).string(); + const highlight = Color({ h, s, l: highlightLuminosity }).string(); + + return styledcss` + height: 16px; + width: 200px; + border-radius: 2px; + opacity: 0.7; + animation: ${pulse} 4s linear infinite; + background: linear-gradient( + 90deg, + ${background} 0%, + ${background} 20%, + ${highlight} 50%, + ${background} 80%, + ${background} 100% + ); + background-size: 200% 200%; + `; +}); diff --git a/packages/components/src/components/SkeletonText/skeleton-text.stories.tsx b/packages/components/src/components/SkeletonText/skeleton-text.stories.tsx new file mode 100644 index 00000000000..2f26fd4fb36 --- /dev/null +++ b/packages/components/src/components/SkeletonText/skeleton-text.stories.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import { SkeletonText } from '.'; + +export default { + title: 'components/SkeletonText', + component: SkeletonText, +}; + +export const Basic = () => ; diff --git a/packages/components/src/components/ThemeProvider/index.tsx b/packages/components/src/components/ThemeProvider/index.tsx index 4dc24a849ec..61f208224ab 100644 --- a/packages/components/src/components/ThemeProvider/index.tsx +++ b/packages/components/src/components/ThemeProvider/index.tsx @@ -24,6 +24,15 @@ export const getThemes = () => { return results.filter(a => a); }; + +const guessType = theme => { + if (theme.type) return theme.type; + + if (theme.name && theme.name.toLowerCase().includes('light')) return 'light'; + + return 'dark'; +}; + export const makeTheme = (vsCodeTheme = {}, name?: string) => { // Our interface does not map 1-1 with vscode. // To add styles that remain themeable, we add @@ -35,9 +44,12 @@ export const makeTheme = (vsCodeTheme = {}, name?: string) => { colors: polyfilledVSCodeColors, }); + const type = guessType(vsCodeTheme); + if (name) { return { name, + type, ...theme, }; } diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 86781964882..de85b9de87d 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -14,6 +14,7 @@ export * from './components/SearchInput'; export * from './components/Select'; export * from './components/Stats'; export * from './components/Menu'; +export * from './components/SkeletonText'; export * from './components/Switch'; export * from './components/Text'; export * from './components/Textarea';