addons/isl/src/codeReview/DiffComments.tsxblame
View source
b69ab311/**
b69ab312 * Copyright (c) Meta Platforms, Inc. and affiliates.
b69ab313 *
b69ab314 * This source code is licensed under the MIT license found in the
b69ab315 * LICENSE file in the root directory of this source tree.
b69ab316 */
b69ab317
b69ab318import type {ParsedDiff} from 'shared/patch/types';
b69ab319import type {DiffComment, DiffCommentReaction, DiffId} from '../types';
b69ab3110
b69ab3111import * as stylex from '@stylexjs/stylex';
b69ab3112import {ErrorNotice} from 'isl-components/ErrorNotice';
b69ab3113import {Icon} from 'isl-components/Icon';
b69ab3114import {Subtle} from 'isl-components/Subtle';
b69ab3115import {Tooltip} from 'isl-components/Tooltip';
b69ab3116import {useAtom, useAtomValue} from 'jotai';
b69ab3117import {useEffect} from 'react';
b69ab3118import {ComparisonType} from 'shared/Comparison';
b69ab3119import {group} from 'shared/utils';
b69ab3120import {colors, font, radius, spacing} from '../../../components/theme/tokens.stylex';
b69ab3121import {AvatarImg} from '../Avatar';
b69ab3122import {SplitDiffTable} from '../ComparisonView/SplitDiffView/SplitDiffHunk';
b69ab3123import {Column, Row} from '../ComponentUtils';
b69ab3124import {Link} from '../Link';
b69ab3125import {T, t} from '../i18n';
b69ab3126import platform from '../platform';
b69ab3127import {RelativeDate} from '../relativeDate';
b69ab3128import {layout} from '../stylexUtils';
b69ab3129import {themeState} from '../theme';
b69ab3130import {diffCommentData} from './codeReviewAtoms';
b69ab3131
b69ab3132const styles = stylex.create({
b69ab3133 list: {
b69ab3134 minWidth: '400px',
b69ab3135 maxWidth: '600px',
b69ab3136 maxHeight: '300px',
b69ab3137 overflowY: 'auto',
b69ab3138 alignItems: 'flex-start',
b69ab3139 },
b69ab3140 comment: {
b69ab3141 alignItems: 'flex-start',
b69ab3142 width: 'calc(100% - 12px)',
b69ab3143 backgroundColor: colors.bg,
b69ab3144 padding: '2px 6px',
b69ab3145 borderRadius: radius.round,
b69ab3146 },
b69ab3147 commentInfo: {
b69ab3148 gap: spacing.half,
b69ab3149 marginBlock: spacing.half,
b69ab3150 alignItems: 'flex-start',
b69ab3151 },
b69ab3152 inlineCommentFilename: {
b69ab3153 marginBottom: spacing.half,
b69ab3154 },
b69ab3155 commentContent: {
b69ab3156 whiteSpace: 'pre-wrap',
b69ab3157 },
b69ab3158 left: {
b69ab3159 alignItems: 'end',
b69ab3160 flexShrink: 0,
b69ab3161 },
b69ab3162 author: {
b69ab3163 fontSize: font.small,
b69ab3164 flexShrink: 0,
b69ab3165 },
b69ab3166 avatar: {
b69ab3167 marginBlock: spacing.half,
b69ab3168 },
b69ab3169 byline: {
b69ab3170 display: 'flex',
b69ab3171 flexDirection: 'row',
b69ab3172 gap: spacing.pad,
b69ab3173 alignItems: 'center',
b69ab3174 },
b69ab3175 diffView: {
b69ab3176 marginBlock: spacing.pad,
b69ab3177 },
b69ab3178});
b69ab3179
b69ab3180function Comment({comment, isTopLevel}: {comment: DiffComment; isTopLevel?: boolean}) {
b69ab3181 return (
b69ab3182 <Row xstyle={styles.comment}>
b69ab3183 <Column {...stylex.props(styles.left)}>
b69ab3184 <AvatarImg username={comment.author} url={comment.authorAvatarUri} xstyle={styles.avatar} />
b69ab3185 </Column>
b69ab3186 <Column xstyle={styles.commentInfo}>
b69ab3187 <b {...stylex.props(styles.author)}>{comment.author}</b>
b69ab3188 <div>
b69ab3189 {isTopLevel && comment.filename && (
b69ab3190 <Link
b69ab3191 xstyle={styles.inlineCommentFilename}
b69ab3192 onClick={() =>
b69ab3193 comment.filename && platform.openFile(comment.filename, {line: comment.line})
b69ab3194 }>
b69ab3195 {comment.filename}
b69ab3196 {comment.line == null ? '' : ':' + comment.line}
b69ab3197 </Link>
b69ab3198 )}
b69ab3199 <div {...stylex.props(styles.commentContent)}>
b69ab31100 <div className="rendered-markup" dangerouslySetInnerHTML={{__html: comment.html}} />
b69ab31101 </div>
b69ab31102 {comment.suggestedChange != null && comment.suggestedChange.patch != null && (
b69ab31103 <InlineDiff patch={comment.suggestedChange.patch} />
b69ab31104 )}
b69ab31105 </div>
b69ab31106 <Subtle {...stylex.props(styles.byline)}>
b69ab31107 <RelativeDate date={comment.created} />
b69ab31108 <Reactions reactions={comment.reactions} />
b69ab31109 {comment.isResolved === true ? (
b69ab31110 <span>
b69ab31111 <T>Resolved</T>
b69ab31112 </span>
b69ab31113 ) : comment.isResolved === false ? (
b69ab31114 <span>
b69ab31115 <T>Unresolved</T>
b69ab31116 </span>
b69ab31117 ) : null}
b69ab31118 </Subtle>
b69ab31119 {comment.replies.map((reply, i) => (
b69ab31120 <Comment key={i} comment={reply} />
b69ab31121 ))}
b69ab31122 </Column>
b69ab31123 </Row>
b69ab31124 );
b69ab31125}
b69ab31126
b69ab31127const useThemeHook = () => useAtomValue(themeState);
b69ab31128
b69ab31129function InlineDiff({patch}: {patch: ParsedDiff}) {
b69ab31130 const path = patch.newFileName ?? '';
b69ab31131 return (
b69ab31132 <div {...stylex.props(styles.diffView)}>
b69ab31133 <div className="split-diff-view">
b69ab31134 <SplitDiffTable
b69ab31135 patch={patch}
b69ab31136 path={path}
b69ab31137 ctx={{
b69ab31138 collapsed: false,
b69ab31139 id: {
b69ab31140 comparison: {type: ComparisonType.HeadChanges},
b69ab31141 path,
b69ab31142 },
b69ab31143 setCollapsed: () => null,
b69ab31144 display: 'unified',
b69ab31145 useThemeHook,
b69ab31146 }}
b69ab31147 />
b69ab31148 </div>
b69ab31149 </div>
b69ab31150 );
b69ab31151}
b69ab31152
b69ab31153const emoji: Record<DiffCommentReaction['reaction'], string> = {
b69ab31154 LIKE: '๐Ÿ‘',
b69ab31155 WOW: '๐Ÿ˜ฎ',
b69ab31156 SORRY: '๐Ÿค—',
b69ab31157 LOVE: 'โค๏ธ',
b69ab31158 HAHA: '๐Ÿ˜†',
b69ab31159 ANGER: '๐Ÿ˜ก',
b69ab31160 SAD: '๐Ÿ˜ข',
b69ab31161 // GitHub reactions
b69ab31162 CONFUSED: '๐Ÿ˜•',
b69ab31163 EYES: '๐Ÿ‘€',
b69ab31164 HEART: 'โค๏ธ',
b69ab31165 HOORAY: '๐ŸŽ‰',
b69ab31166 LAUGH: '๐Ÿ˜„',
b69ab31167 ROCKET: '๐Ÿš€',
b69ab31168 THUMBS_DOWN: '๐Ÿ‘Ž',
b69ab31169 THUMBS_UP: '๐Ÿ‘',
b69ab31170};
b69ab31171
b69ab31172function Reactions({reactions}: {reactions: Array<DiffCommentReaction>}) {
b69ab31173 if (reactions.length === 0) {
b69ab31174 return null;
b69ab31175 }
b69ab31176 const groups = Object.entries(group(reactions, r => r.reaction)).filter(
b69ab31177 (group): group is [DiffCommentReaction['reaction'], DiffCommentReaction[]] =>
b69ab31178 (group[1]?.length ?? 0) > 0,
b69ab31179 );
b69ab31180 groups.sort((a, b) => b[1].length - a[1].length);
b69ab31181 const total = groups.reduce((last, g) => last + g[1].length, 0);
b69ab31182 // Show only the 3 most used reactions as emoji, even if more are used
b69ab31183 const icons = groups.slice(0, 2).map(g => <span>{emoji[g[0]]}</span>);
b69ab31184 const names = reactions.map(r => r.name);
b69ab31185 return (
b69ab31186 <Tooltip title={names.join(', ')}>
b69ab31187 <Row style={{gap: spacing.half}}>
b69ab31188 <span style={{letterSpacing: '-2px'}}>{icons}</span>
b69ab31189 <span>{total}</span>
b69ab31190 </Row>
b69ab31191 </Tooltip>
b69ab31192 );
b69ab31193}
b69ab31194
b69ab31195export default function DiffCommentsDetails({diffId}: {diffId: DiffId}) {
b69ab31196 const [comments, refresh] = useAtom(diffCommentData(diffId));
b69ab31197 useEffect(() => {
b69ab31198 // make sure we fetch whenever loading the UI again
b69ab31199 refresh();
b69ab31200 }, [refresh]);
b69ab31201
b69ab31202 if (comments.state === 'loading') {
b69ab31203 return (
b69ab31204 <div>
b69ab31205 <Icon icon="loading" />
b69ab31206 </div>
b69ab31207 );
b69ab31208 }
b69ab31209 if (comments.state === 'hasError') {
b69ab31210 return (
b69ab31211 <div>
b69ab31212 <ErrorNotice title={t('Failed to fetch comments')} error={comments.error as Error} />
b69ab31213 </div>
b69ab31214 );
b69ab31215 }
b69ab31216 return (
b69ab31217 <div {...stylex.props(layout.flexCol, styles.list)}>
b69ab31218 {comments.data.map((comment, i) => (
b69ab31219 <Comment key={i} comment={comment} isTopLevel />
b69ab31220 ))}
b69ab31221 </div>
b69ab31222 );
b69ab31223}