activityFeedItem.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470
  1. import {Component, createRef} from 'react';
  2. import styled from '@emotion/styled';
  3. import {ActivityAvatar} from 'sentry/components/activity/item/avatar';
  4. import CommitLink from 'sentry/components/commitLink';
  5. import Duration from 'sentry/components/duration';
  6. import IssueLink from 'sentry/components/issueLink';
  7. import ExternalLink from 'sentry/components/links/externalLink';
  8. import Link from 'sentry/components/links/link';
  9. import PullRequestLink from 'sentry/components/pullRequestLink';
  10. import TimeSince from 'sentry/components/timeSince';
  11. import Version from 'sentry/components/version';
  12. import VersionHoverCard from 'sentry/components/versionHoverCard';
  13. import {t, tct, tn} from 'sentry/locale';
  14. import MemberListStore from 'sentry/stores/memberListStore';
  15. import TeamStore from 'sentry/stores/teamStore';
  16. import {space} from 'sentry/styles/space';
  17. import type {Activity, GroupActivity, Organization} from 'sentry/types';
  18. import marked from 'sentry/utils/marked';
  19. const defaultProps = {
  20. defaultClipped: false,
  21. clipHeight: 68,
  22. };
  23. type DefaultProps = typeof defaultProps;
  24. type Props = {
  25. item: Activity;
  26. organization: Organization;
  27. className?: string;
  28. } & DefaultProps;
  29. type State = {
  30. clipped: Props['defaultClipped'];
  31. };
  32. class ActivityItem extends Component<Props, State> {
  33. static defaultProps = defaultProps;
  34. state: State = {
  35. clipped: this.props.defaultClipped,
  36. };
  37. componentDidMount() {
  38. if (this.activityBubbleRef.current) {
  39. const bubbleHeight = this.activityBubbleRef.current.offsetHeight;
  40. if (bubbleHeight > this.props.clipHeight) {
  41. // okay if this causes re-render; cannot determine until
  42. // rendered first anyways
  43. // eslint-disable-next-line react/no-did-mount-set-state
  44. this.setState({clipped: true});
  45. }
  46. }
  47. }
  48. renderVersionLink(version: string, item: GroupActivity) {
  49. const {organization} = this.props;
  50. const {project} = item;
  51. return version ? (
  52. <VersionHoverCard
  53. organization={organization}
  54. projectSlug={project.slug}
  55. releaseVersion={version}
  56. >
  57. <Version version={version} projectId={project.id} />
  58. </VersionHoverCard>
  59. ) : null;
  60. }
  61. activityBubbleRef = createRef<HTMLDivElement>();
  62. formatProjectActivity = (author, item) => {
  63. const data = item.data;
  64. const {organization} = this.props;
  65. const orgId = organization.slug;
  66. const issue = item.issue;
  67. const basePath = `/organizations/${orgId}/issues/`;
  68. const issueLink = issue ? (
  69. <IssueLink
  70. orgId={orgId}
  71. issue={issue}
  72. to={`${basePath}${issue.id}/?referrer=activity-feed-issue-link`}
  73. card
  74. >
  75. {issue.shortId}
  76. </IssueLink>
  77. ) : null;
  78. const versionLink = this.renderVersionLink(data.version, item);
  79. switch (item.type) {
  80. case 'note':
  81. return tct('[author] commented on [issue]', {
  82. author,
  83. issue: (
  84. <IssueLink
  85. card
  86. orgId={orgId}
  87. issue={issue}
  88. to={`${basePath}${issue.id}/activity/?referrer=activity-comment#event_${item.id}`}
  89. >
  90. {issue.shortId}
  91. </IssueLink>
  92. ),
  93. });
  94. case 'set_resolved':
  95. return tct('[author] marked [issue] as resolved', {
  96. author,
  97. issue: issueLink,
  98. });
  99. case 'set_resolved_by_age':
  100. return tct('[author] marked [issue] as resolved due to age', {
  101. author,
  102. issue: issueLink,
  103. });
  104. case 'set_resolved_in_release':
  105. const {current_release_version, version} = item.data;
  106. if (current_release_version) {
  107. return tct(
  108. '[author] marked [issue] as resolved in releases greater than [version]',
  109. {
  110. author,
  111. version: this.renderVersionLink(current_release_version, item),
  112. issue: issueLink,
  113. }
  114. );
  115. }
  116. if (version) {
  117. return tct('[author] marked [issue] as resolved in [version]', {
  118. author,
  119. version: versionLink,
  120. issue: issueLink,
  121. });
  122. }
  123. return tct('[author] marked [issue] as resolved in the upcoming release', {
  124. author,
  125. issue: issueLink,
  126. });
  127. case 'set_resolved_in_commit':
  128. if (data.commit) {
  129. return tct('[author] marked [issue] as resolved in [commit]', {
  130. author,
  131. commit: (
  132. <CommitLink
  133. inline
  134. commitId={data.commit.id}
  135. repository={data.commit.repository}
  136. />
  137. ),
  138. issue: issueLink,
  139. });
  140. }
  141. return tct('[author] marked [issue] as resolved in a commit', {
  142. author,
  143. issue: issueLink,
  144. });
  145. case 'set_resolved_in_pull_request':
  146. return tct('[author] marked [issue] as resolved in [pullRequest]', {
  147. author,
  148. pullRequest: data.pullRequest ? (
  149. <PullRequestLink
  150. inline
  151. pullRequest={data.pullRequest}
  152. repository={data.pullRequest.repository}
  153. />
  154. ) : (
  155. t('PR not available')
  156. ),
  157. issue: issueLink,
  158. });
  159. case 'set_unresolved':
  160. return tct('[author] marked [issue] as unresolved', {
  161. author,
  162. issue: issueLink,
  163. });
  164. case 'set_ignored':
  165. if (data.ignoreDuration) {
  166. return tct('[author] ignored [issue] for [duration]', {
  167. author,
  168. duration: <Duration seconds={data.ignoreDuration * 60} />,
  169. issue: issueLink,
  170. });
  171. }
  172. if (data.ignoreCount && data.ignoreWindow) {
  173. return tct(
  174. '[author] ignored [issue] until it happens [count] time(s) in [duration]',
  175. {
  176. author,
  177. count: data.ignoreCount,
  178. duration: <Duration seconds={data.ignoreWindow * 60} />,
  179. issue: issueLink,
  180. }
  181. );
  182. }
  183. if (data.ignoreCount) {
  184. return tct('[author] ignored [issue] until it happens [count] time(s)', {
  185. author,
  186. count: data.ignoreCount,
  187. issue: issueLink,
  188. });
  189. }
  190. if (data.ignoreUserCount && data.ignoreUserWindow) {
  191. return tct(
  192. '[author] ignored [issue] until it affects [count] user(s) in [duration]',
  193. {
  194. author,
  195. count: data.ignoreUserCount,
  196. duration: <Duration seconds={data.ignoreUserWindow * 60} />,
  197. issue: issueLink,
  198. }
  199. );
  200. }
  201. if (data.ignoreUserCount) {
  202. return tct('[author] ignored [issue] until it affects [count] user(s)', {
  203. author,
  204. count: data.ignoreUserCount,
  205. issue: issueLink,
  206. });
  207. }
  208. return tct('[author] ignored [issue]', {
  209. author,
  210. issue: issueLink,
  211. });
  212. case 'set_public':
  213. return tct('[author] made [issue] public', {
  214. author,
  215. issue: issueLink,
  216. });
  217. case 'set_private':
  218. return tct('[author] made [issue] private', {
  219. author,
  220. issue: issueLink,
  221. });
  222. case 'set_regression':
  223. if (data.version) {
  224. return tct('[author] marked [issue] as a regression in [version]', {
  225. author,
  226. version: versionLink,
  227. issue: issueLink,
  228. });
  229. }
  230. return tct('[author] marked [issue] as a regression', {
  231. author,
  232. issue: issueLink,
  233. });
  234. case 'create_issue':
  235. return tct('[author] linked [issue] on [provider]', {
  236. author,
  237. provider: data.provider,
  238. issue: issueLink,
  239. });
  240. case 'unmerge_destination':
  241. return tn(
  242. '%2$s migrated %1$s fingerprint from %3$s to %4$s',
  243. '%2$s migrated %1$s fingerprints from %3$s to %4$s',
  244. data.fingerprints.length,
  245. author,
  246. data.source ? (
  247. <a href={`${basePath}${data.source.id}`}>{data.source.shortId}</a>
  248. ) : (
  249. t('a group')
  250. ),
  251. issueLink
  252. );
  253. case 'first_seen':
  254. return tct('[author] saw [link:issue]', {
  255. author,
  256. issue: issueLink,
  257. });
  258. case 'assigned':
  259. let assignee;
  260. if (data.assigneeType === 'team') {
  261. const team = TeamStore.getById(data.assignee);
  262. assignee = team ? team.slug : '<unknown-team>';
  263. return tct('[author] assigned [issue] to #[assignee]', {
  264. author,
  265. issue: issueLink,
  266. assignee,
  267. });
  268. }
  269. if (item.user && data.assignee === item.user.id) {
  270. return tct('[author] assigned [issue] to themselves', {
  271. author,
  272. issue: issueLink,
  273. });
  274. }
  275. assignee = MemberListStore.getById(data.assignee);
  276. if (assignee?.email) {
  277. return tct('[author] assigned [issue] to [assignee]', {
  278. author,
  279. assignee: <span title={assignee.email}>{assignee.name}</span>,
  280. issue: issueLink,
  281. });
  282. }
  283. if (data.assigneeEmail) {
  284. return tct('[author] assigned [issue] to [assignee]', {
  285. author,
  286. assignee: data.assigneeEmail,
  287. issue: issueLink,
  288. });
  289. }
  290. return tct('[author] assigned [issue] to an [help:unknown user]', {
  291. author,
  292. help: <span title={data.assignee} />,
  293. issue: issueLink,
  294. });
  295. case 'unassigned':
  296. return tct('[author] unassigned [issue]', {
  297. author,
  298. issue: issueLink,
  299. });
  300. case 'merge':
  301. return tct('[author] merged [count] [link:issues]', {
  302. author,
  303. count: data.issues.length + 1,
  304. link: <Link to={`${basePath}${issue.id}/?referrer=activity-feed-merge`} />,
  305. });
  306. case 'release':
  307. return tct('[author] released version [version]', {
  308. author,
  309. version: versionLink,
  310. });
  311. case 'deploy':
  312. return tct('[author] deployed version [version] to [environment].', {
  313. author,
  314. version: versionLink,
  315. environment: data.environment || 'Default Environment',
  316. });
  317. case 'mark_reviewed':
  318. return tct('[author] marked [issue] as reviewed', {
  319. author,
  320. issue: issueLink,
  321. });
  322. default:
  323. return ''; // should never hit (?)
  324. }
  325. };
  326. render() {
  327. const {className, item} = this.props;
  328. const avatar = (
  329. <ActivityAvatar
  330. type={!item.user ? 'system' : 'user'}
  331. user={item.user ?? undefined}
  332. size={36}
  333. />
  334. );
  335. const author = {
  336. name: item.user ? item.user.name : 'Sentry',
  337. avatar,
  338. };
  339. const hasBubble = ['note', 'create_issue'].includes(item.type);
  340. const bubbleProps = {
  341. ...(item.type === 'note'
  342. ? {dangerouslySetInnerHTML: {__html: marked(item.data.text)}}
  343. : {}),
  344. ...(item.type === 'create_issue'
  345. ? {
  346. children: (
  347. <ExternalLink href={item.data.location}>{item.data.title}</ExternalLink>
  348. ),
  349. }
  350. : {}),
  351. };
  352. return (
  353. <div data-test-id="activity-feed-item" className={className}>
  354. {author.avatar}
  355. <div>
  356. {this.formatProjectActivity(
  357. <span>
  358. <ActivityAuthor>{author.name}</ActivityAuthor>
  359. </span>,
  360. item
  361. )}
  362. {hasBubble && (
  363. <Bubble
  364. ref={this.activityBubbleRef}
  365. clipped={this.state.clipped}
  366. {...bubbleProps}
  367. />
  368. )}
  369. <Meta>
  370. <Project>{item.project.slug}</Project>
  371. <StyledTimeSince date={item.dateCreated} />
  372. </Meta>
  373. </div>
  374. </div>
  375. );
  376. }
  377. }
  378. export default styled(ActivityItem)`
  379. display: grid;
  380. gap: ${space(1)};
  381. grid-template-columns: max-content auto;
  382. position: relative;
  383. margin: 0;
  384. padding: ${space(1)};
  385. border-bottom: 1px solid ${p => p.theme.innerBorder};
  386. line-height: 1.4;
  387. font-size: ${p => p.theme.fontSizeMedium};
  388. `;
  389. const ActivityAuthor = styled('span')`
  390. font-weight: ${p => p.theme.fontWeightBold};
  391. `;
  392. const Meta = styled('div')`
  393. color: ${p => p.theme.textColor};
  394. font-size: ${p => p.theme.fontSizeRelativeSmall};
  395. `;
  396. const Project = styled('span')`
  397. font-weight: ${p => p.theme.fontWeightBold};
  398. `;
  399. const Bubble = styled('div')<{clipped: boolean}>`
  400. background: ${p => p.theme.backgroundSecondary};
  401. margin: ${space(0.5)} 0;
  402. padding: ${space(1)} ${space(2)};
  403. border: 1px solid ${p => p.theme.border};
  404. border-radius: 3px;
  405. box-shadow: 0 1px 1px rgba(0, 0, 0, 0.04);
  406. position: relative;
  407. overflow: hidden;
  408. a {
  409. max-width: 100%;
  410. overflow-x: hidden;
  411. text-overflow: ellipsis;
  412. }
  413. p {
  414. &:last-child {
  415. margin-bottom: 0;
  416. }
  417. }
  418. ${p =>
  419. p.clipped &&
  420. `
  421. max-height: 68px;
  422. &:after {
  423. position: absolute;
  424. content: '';
  425. display: block;
  426. bottom: 0;
  427. right: 0;
  428. left: 0;
  429. height: 36px;
  430. background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 1));
  431. border-bottom: 6px solid #fff;
  432. border-radius: 0 0 3px 3px;
  433. pointer-events: none;
  434. }
  435. `}
  436. `;
  437. const StyledTimeSince = styled(TimeSince)`
  438. color: ${p => p.theme.gray300};
  439. padding-left: ${space(1)};
  440. `;