| 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 | |
| 8 | import type {ParsedDiff} from 'shared/patch/types'; |
| 9 | import type {DiffComment, DiffCommentReaction, DiffId} from '../types'; |
| 10 | |
| 11 | import * as stylex from '@stylexjs/stylex'; |
| 12 | import {ErrorNotice} from 'isl-components/ErrorNotice'; |
| 13 | import {Icon} from 'isl-components/Icon'; |
| 14 | import {Subtle} from 'isl-components/Subtle'; |
| 15 | import {Tooltip} from 'isl-components/Tooltip'; |
| 16 | import {useAtom, useAtomValue} from 'jotai'; |
| 17 | import {useEffect} from 'react'; |
| 18 | import {ComparisonType} from 'shared/Comparison'; |
| 19 | import {group} from 'shared/utils'; |
| 20 | import {colors, font, radius, spacing} from '../../../components/theme/tokens.stylex'; |
| 21 | import {AvatarImg} from '../Avatar'; |
| 22 | import {SplitDiffTable} from '../ComparisonView/SplitDiffView/SplitDiffHunk'; |
| 23 | import {Column, Row} from '../ComponentUtils'; |
| 24 | import {Link} from '../Link'; |
| 25 | import {T, t} from '../i18n'; |
| 26 | import platform from '../platform'; |
| 27 | import {RelativeDate} from '../relativeDate'; |
| 28 | import {layout} from '../stylexUtils'; |
| 29 | import {themeState} from '../theme'; |
| 30 | import {diffCommentData} from './codeReviewAtoms'; |
| 31 | |
| 32 | const 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 | |
| 80 | function 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 | |
| 127 | const useThemeHook = () => useAtomValue(themeState); |
| 128 | |
| 129 | function 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 | |
| 153 | const 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 | |
| 172 | function 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 | |
| 195 | export 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 | |