6.3 KB224 lines
Blame
1/**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8import type {ParsedDiff} from 'shared/patch/types';
9import type {DiffComment, DiffCommentReaction, DiffId} from '../types';
10
11import * as stylex from '@stylexjs/stylex';
12import {ErrorNotice} from 'isl-components/ErrorNotice';
13import {Icon} from 'isl-components/Icon';
14import {Subtle} from 'isl-components/Subtle';
15import {Tooltip} from 'isl-components/Tooltip';
16import {useAtom, useAtomValue} from 'jotai';
17import {useEffect} from 'react';
18import {ComparisonType} from 'shared/Comparison';
19import {group} from 'shared/utils';
20import {colors, font, radius, spacing} from '../../../components/theme/tokens.stylex';
21import {AvatarImg} from '../Avatar';
22import {SplitDiffTable} from '../ComparisonView/SplitDiffView/SplitDiffHunk';
23import {Column, Row} from '../ComponentUtils';
24import {Link} from '../Link';
25import {T, t} from '../i18n';
26import platform from '../platform';
27import {RelativeDate} from '../relativeDate';
28import {layout} from '../stylexUtils';
29import {themeState} from '../theme';
30import {diffCommentData} from './codeReviewAtoms';
31
32const styles = stylex.create({
33 list: {
34 minWidth: '400px',
35 maxWidth: '600px',
36 maxHeight: '300px',
37 overflowY: 'auto',
38 alignItems: 'flex-start',
39 },
40 comment: {
41 alignItems: 'flex-start',
42 width: 'calc(100% - 12px)',
43 backgroundColor: colors.bg,
44 padding: '2px 6px',
45 borderRadius: radius.round,
46 },
47 commentInfo: {
48 gap: spacing.half,
49 marginBlock: spacing.half,
50 alignItems: 'flex-start',
51 },
52 inlineCommentFilename: {
53 marginBottom: spacing.half,
54 },
55 commentContent: {
56 whiteSpace: 'pre-wrap',
57 },
58 left: {
59 alignItems: 'end',
60 flexShrink: 0,
61 },
62 author: {
63 fontSize: font.small,
64 flexShrink: 0,
65 },
66 avatar: {
67 marginBlock: spacing.half,
68 },
69 byline: {
70 display: 'flex',
71 flexDirection: 'row',
72 gap: spacing.pad,
73 alignItems: 'center',
74 },
75 diffView: {
76 marginBlock: spacing.pad,
77 },
78});
79
80function Comment({comment, isTopLevel}: {comment: DiffComment; isTopLevel?: boolean}) {
81 return (
82 <Row xstyle={styles.comment}>
83 <Column {...stylex.props(styles.left)}>
84 <AvatarImg username={comment.author} url={comment.authorAvatarUri} xstyle={styles.avatar} />
85 </Column>
86 <Column xstyle={styles.commentInfo}>
87 <b {...stylex.props(styles.author)}>{comment.author}</b>
88 <div>
89 {isTopLevel && comment.filename && (
90 <Link
91 xstyle={styles.inlineCommentFilename}
92 onClick={() =>
93 comment.filename && platform.openFile(comment.filename, {line: comment.line})
94 }>
95 {comment.filename}
96 {comment.line == null ? '' : ':' + comment.line}
97 </Link>
98 )}
99 <div {...stylex.props(styles.commentContent)}>
100 <div className="rendered-markup" dangerouslySetInnerHTML={{__html: comment.html}} />
101 </div>
102 {comment.suggestedChange != null && comment.suggestedChange.patch != null && (
103 <InlineDiff patch={comment.suggestedChange.patch} />
104 )}
105 </div>
106 <Subtle {...stylex.props(styles.byline)}>
107 <RelativeDate date={comment.created} />
108 <Reactions reactions={comment.reactions} />
109 {comment.isResolved === true ? (
110 <span>
111 <T>Resolved</T>
112 </span>
113 ) : comment.isResolved === false ? (
114 <span>
115 <T>Unresolved</T>
116 </span>
117 ) : null}
118 </Subtle>
119 {comment.replies.map((reply, i) => (
120 <Comment key={i} comment={reply} />
121 ))}
122 </Column>
123 </Row>
124 );
125}
126
127const useThemeHook = () => useAtomValue(themeState);
128
129function InlineDiff({patch}: {patch: ParsedDiff}) {
130 const path = patch.newFileName ?? '';
131 return (
132 <div {...stylex.props(styles.diffView)}>
133 <div className="split-diff-view">
134 <SplitDiffTable
135 patch={patch}
136 path={path}
137 ctx={{
138 collapsed: false,
139 id: {
140 comparison: {type: ComparisonType.HeadChanges},
141 path,
142 },
143 setCollapsed: () => null,
144 display: 'unified',
145 useThemeHook,
146 }}
147 />
148 </div>
149 </div>
150 );
151}
152
153const emoji: Record<DiffCommentReaction['reaction'], string> = {
154 LIKE: '๐Ÿ‘',
155 WOW: '๐Ÿ˜ฎ',
156 SORRY: '๐Ÿค—',
157 LOVE: 'โค๏ธ',
158 HAHA: '๐Ÿ˜†',
159 ANGER: '๐Ÿ˜ก',
160 SAD: '๐Ÿ˜ข',
161 // GitHub reactions
162 CONFUSED: '๐Ÿ˜•',
163 EYES: '๐Ÿ‘€',
164 HEART: 'โค๏ธ',
165 HOORAY: '๐ŸŽ‰',
166 LAUGH: '๐Ÿ˜„',
167 ROCKET: '๐Ÿš€',
168 THUMBS_DOWN: '๐Ÿ‘Ž',
169 THUMBS_UP: '๐Ÿ‘',
170};
171
172function Reactions({reactions}: {reactions: Array<DiffCommentReaction>}) {
173 if (reactions.length === 0) {
174 return null;
175 }
176 const groups = Object.entries(group(reactions, r => r.reaction)).filter(
177 (group): group is [DiffCommentReaction['reaction'], DiffCommentReaction[]] =>
178 (group[1]?.length ?? 0) > 0,
179 );
180 groups.sort((a, b) => b[1].length - a[1].length);
181 const total = groups.reduce((last, g) => last + g[1].length, 0);
182 // Show only the 3 most used reactions as emoji, even if more are used
183 const icons = groups.slice(0, 2).map(g => <span>{emoji[g[0]]}</span>);
184 const names = reactions.map(r => r.name);
185 return (
186 <Tooltip title={names.join(', ')}>
187 <Row style={{gap: spacing.half}}>
188 <span style={{letterSpacing: '-2px'}}>{icons}</span>
189 <span>{total}</span>
190 </Row>
191 </Tooltip>
192 );
193}
194
195export default function DiffCommentsDetails({diffId}: {diffId: DiffId}) {
196 const [comments, refresh] = useAtom(diffCommentData(diffId));
197 useEffect(() => {
198 // make sure we fetch whenever loading the UI again
199 refresh();
200 }, [refresh]);
201
202 if (comments.state === 'loading') {
203 return (
204 <div>
205 <Icon icon="loading" />
206 </div>
207 );
208 }
209 if (comments.state === 'hasError') {
210 return (
211 <div>
212 <ErrorNotice title={t('Failed to fetch comments')} error={comments.error as Error} />
213 </div>
214 );
215 }
216 return (
217 <div {...stylex.props(layout.flexCol, styles.list)}>
218 {comments.data.map((comment, i) => (
219 <Comment key={i} comment={comment} isTopLevel />
220 ))}
221 </div>
222 );
223}
224