activityFeedItem.tsx 12 KB

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