Skip to content

Commit 29e7bc2

Browse files
authored
Dialog: Replies loader (#3781)
* reply loader structure * add skeleton text component! * use the skeleton in replies * reduce height transitions to 1 * resize when replies load * simplify callback * remove redundant sate
1 parent e7e42dc commit 29e7bc2

File tree

6 files changed

+151
-11
lines changed

6 files changed

+151
-11
lines changed

packages/app/src/app/pages/Sandbox/Editor/Workspace/screens/Comments/Dialog/Reply.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Element, Menu, Stack } from '@codesandbox/components';
1+
import { Element, Menu, Stack, SkeletonText } from '@codesandbox/components';
22
import css from '@styled-system/css';
33
import { CommentFragment } from 'app/graphql/types';
44
import { useOvermind } from 'app/overmind';
@@ -73,3 +73,22 @@ export const Reply = ({ reply }: ReplyProps) => {
7373
</>
7474
);
7575
};
76+
77+
export const SkeletonReply = props => (
78+
<Element marginX={4} paddingTop={6} {...props}>
79+
<Stack align="center" gap={2} marginBottom={4}>
80+
<SkeletonText style={{ width: '32px', height: '32px' }} />
81+
82+
<Stack direction="vertical" gap={1}>
83+
<SkeletonText style={{ width: '120px', height: '14px' }} />
84+
<SkeletonText style={{ width: '120px', height: '14px' }} />
85+
</Stack>
86+
</Stack>
87+
88+
<Stack direction="vertical" gap={1} marginBottom={6}>
89+
<SkeletonText style={{ width: '100%', height: '14px' }} />
90+
<SkeletonText style={{ width: '100%', height: '14px' }} />
91+
<SkeletonText style={{ width: '100%', height: '14px' }} />
92+
</Stack>
93+
</Element>
94+
);

packages/app/src/app/pages/Sandbox/Editor/Workspace/screens/Comments/Dialog/index.tsx

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,19 +19,22 @@ import ReactDOM from 'react-dom';
1919
import { AvatarBlock } from '../components/AvatarBlock';
2020
import { EditComment } from '../components/EditComment';
2121
import { Markdown } from './Markdown';
22-
import { Reply } from './Reply';
22+
import { Reply, SkeletonReply } from './Reply';
2323
import { useScrollTop } from './use-scroll-top';
2424

2525
export const CommentDialog = props =>
2626
ReactDOM.createPortal(<Dialog {...props} />, document.body);
2727

2828
const DIALOG_WIDTH = 420;
29+
const DIALOG_TRANSITION_DURATION = 0.25;
30+
const REPLY_TRANSITION_DELAY = 0.25;
2931

3032
export const Dialog: React.FC = () => {
3133
const { state } = useOvermind();
3234
const controller = useAnimation();
3335

3436
const comment = state.comments.currentComment;
37+
const replies = comment.comments;
3538

3639
// This comment doens't exist in the database, it's an optimistic comment
3740
const isNewComment = comment.id === OPTIMISTIC_COMMENT_ID;
@@ -47,6 +50,8 @@ export const Dialog: React.FC = () => {
4750
// this could rather be `getInitialPosition`
4851
const initialPosition = getInitialPosition(currentCommentPositions);
4952

53+
const [repliesRendered, setRepliesRendered] = React.useState(false);
54+
5055
// reset editing when comment changes
5156
React.useEffect(() => {
5257
setEditing(isNewComment);
@@ -67,8 +72,13 @@ export const Dialog: React.FC = () => {
6772
currentCommentPositions,
6873
isCodeComment,
6974
isNewComment,
75+
repliesRendered,
7076
]);
7177

78+
React.useEffect(() => {
79+
setRepliesRendered(false);
80+
}, [comment.id]);
81+
7282
const onDragHandlerPan = (deltaX: number, deltaY: number) => {
7383
controller.set((_, target) => ({
7484
x: Number(target.x) + deltaX,
@@ -84,7 +94,7 @@ export const Dialog: React.FC = () => {
8494
key={isCodeComment ? 'code' : 'global'}
8595
initial={{ ...initialPosition, scale: 0.5, opacity: 0 }}
8696
animate={controller}
87-
transition={{ duration: 0.25 }}
97+
transition={{ duration: DIALOG_TRANSITION_DURATION }}
8898
style={{ position: 'absolute', zIndex: 2 }}
8999
>
90100
<Stack
@@ -127,7 +137,12 @@ export const Dialog: React.FC = () => {
127137
editing={editing}
128138
setEditing={setEditing}
129139
/>
130-
<Replies replies={comment.comments} />
140+
{replies.length ? (
141+
<Replies
142+
replies={replies}
143+
repliesRenderedCallback={() => setRepliesRendered(true)}
144+
/>
145+
) : null}
131146
</Stack>
132147
<AddReply comment={comment} />
133148
</>
@@ -331,13 +346,45 @@ const CommentBody = ({ comment, editing, setEditing }) => {
331346
);
332347
};
333348

334-
const Replies = ({ replies }) => (
335-
<>
336-
{replies.map(reply =>
337-
reply ? <Reply reply={reply} key={reply.id} /> : 'Loading...'
338-
)}
339-
</>
340-
);
349+
const Replies = ({ replies, repliesRenderedCallback }) => {
350+
const [isAnimating, setAnimating] = React.useState(true);
351+
const repliesLoaded = !!replies[0];
352+
353+
/** Wait another <delay>ms after the dialog has transitioned into view */
354+
const delay = DIALOG_TRANSITION_DURATION + REPLY_TRANSITION_DELAY;
355+
const REPLY_TRANSITION_DURATION = 0.25;
356+
const SKELETON_HEIGHT = 146;
357+
358+
React.useEffect(() => {
359+
if (repliesLoaded && !isAnimating) repliesRenderedCallback();
360+
}, [repliesLoaded, isAnimating, repliesRenderedCallback]);
361+
362+
return (
363+
<motion.div
364+
initial={{ height: repliesLoaded ? 0 : SKELETON_HEIGHT }}
365+
animate={{ height: 'auto' }}
366+
transition={{
367+
delay,
368+
duration: REPLY_TRANSITION_DURATION,
369+
}}
370+
style={{
371+
minHeight: repliesLoaded ? 0 : SKELETON_HEIGHT,
372+
overflow: 'visible',
373+
}}
374+
onAnimationComplete={() => setAnimating(false)}
375+
>
376+
{repliesLoaded ? (
377+
<>
378+
{replies.map(reply => (
379+
<Reply reply={reply} key={reply.id} />
380+
))}
381+
</>
382+
) : (
383+
<SkeletonReply />
384+
)}
385+
</motion.div>
386+
);
387+
};
341388

342389
const AddReply = ({ comment }) => {
343390
const { actions } = useOvermind();
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import styled, { css as styledcss, keyframes } from 'styled-components';
2+
import Color from 'color';
3+
import { Element } from '../Element';
4+
5+
// export interface ITextProps extends React.HTMLAttributes<HTMLSpanElement> {}
6+
7+
const pulse = keyframes`
8+
0% { background-position: 100% 50%; }
9+
100% { background-position: -100% 50%; }
10+
`;
11+
12+
export const SkeletonText = styled(Element)(props => {
13+
const color = props.theme.colors.sideBar.border;
14+
const themeType = props.theme.type;
15+
16+
/**
17+
* This is fun,
18+
* We animate the background gradient to create a pulse animation
19+
*
20+
* To support all themes nicely, we can't really pick a value from the theme
21+
* So, we take the sidebar.border and then change it's luminosity
22+
* 14% for background and 16% for the pulse highlight on top
23+
* We need to set the value to 100 - value for light themes
24+
*/
25+
26+
const backgroundLuminosity = themeType === 'light' ? 86 : 14;
27+
const highlightLuminosity = themeType === 'light' ? 88 : 16;
28+
29+
// @ts-ignore - we have multiple versions of color in the app
30+
// which leads to confusing type checks
31+
const [h, s] = Color(color).hsl().color;
32+
33+
const background = Color({ h, s, l: backgroundLuminosity }).string();
34+
const highlight = Color({ h, s, l: highlightLuminosity }).string();
35+
36+
return styledcss`
37+
height: 16px;
38+
width: 200px;
39+
border-radius: 2px;
40+
opacity: 0.7;
41+
animation: ${pulse} 4s linear infinite;
42+
background: linear-gradient(
43+
90deg,
44+
${background} 0%,
45+
${background} 20%,
46+
${highlight} 50%,
47+
${background} 80%,
48+
${background} 100%
49+
);
50+
background-size: 200% 200%;
51+
`;
52+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import React from 'react';
2+
import { SkeletonText } from '.';
3+
4+
export default {
5+
title: 'components/SkeletonText',
6+
component: SkeletonText,
7+
};
8+
9+
export const Basic = () => <SkeletonText />;

packages/components/src/components/ThemeProvider/index.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,15 @@ export const getThemes = () => {
2424

2525
return results.filter(a => a);
2626
};
27+
28+
const guessType = theme => {
29+
if (theme.type) return theme.type;
30+
31+
if (theme.name && theme.name.toLowerCase().includes('light')) return 'light';
32+
33+
return 'dark';
34+
};
35+
2736
export const makeTheme = (vsCodeTheme = {}, name?: string) => {
2837
// Our interface does not map 1-1 with vscode.
2938
// To add styles that remain themeable, we add
@@ -35,9 +44,12 @@ export const makeTheme = (vsCodeTheme = {}, name?: string) => {
3544
colors: polyfilledVSCodeColors,
3645
});
3746

47+
const type = guessType(vsCodeTheme);
48+
3849
if (name) {
3950
return {
4051
name,
52+
type,
4153
...theme,
4254
};
4355
}

packages/components/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export * from './components/SearchInput';
1414
export * from './components/Select';
1515
export * from './components/Stats';
1616
export * from './components/Menu';
17+
export * from './components/SkeletonText';
1718
export * from './components/Switch';
1819
export * from './components/Text';
1920
export * from './components/Textarea';

0 commit comments

Comments
 (0)