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