123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457 |
- import {Component, createRef} from 'react';
- import styled from '@emotion/styled';
- import ActivityAvatar from 'sentry/components/activity/item/avatar';
- import CommitLink from 'sentry/components/commitLink';
- import Duration from 'sentry/components/duration';
- import IssueLink from 'sentry/components/issueLink';
- import ExternalLink from 'sentry/components/links/externalLink';
- import Link from 'sentry/components/links/link';
- import PullRequestLink from 'sentry/components/pullRequestLink';
- import TimeSince from 'sentry/components/timeSince';
- import Version from 'sentry/components/version';
- import VersionHoverCard from 'sentry/components/versionHoverCard';
- import {t, tct, tn} from 'sentry/locale';
- import MemberListStore from 'sentry/stores/memberListStore';
- import TeamStore from 'sentry/stores/teamStore';
- import space from 'sentry/styles/space';
- import {Activity, GroupActivity, Organization} from 'sentry/types';
- import marked from 'sentry/utils/marked';
- const defaultProps = {
- defaultClipped: false,
- clipHeight: 68,
- };
- type DefaultProps = typeof defaultProps;
- type Props = {
- item: Activity;
- organization: Organization;
- className?: string;
- } & DefaultProps;
- type State = {
- clipped: Props['defaultClipped'];
- };
- class ActivityItem extends Component<Props, State> {
- static defaultProps = defaultProps;
- state: State = {
- clipped: this.props.defaultClipped,
- };
- componentDidMount() {
- if (this.activityBubbleRef.current) {
- const bubbleHeight = this.activityBubbleRef.current.offsetHeight;
- if (bubbleHeight > this.props.clipHeight) {
- // okay if this causes re-render; cannot determine until
- // rendered first anyways
- // eslint-disable-next-line react/no-did-mount-set-state
- this.setState({clipped: true});
- }
- }
- }
- renderVersionLink(version: string, item: GroupActivity) {
- const {organization} = this.props;
- const {project} = item;
- return version ? (
- <VersionHoverCard
- organization={organization}
- projectSlug={project.slug}
- releaseVersion={version}
- >
- <Version version={version} projectId={project.id} />
- </VersionHoverCard>
- ) : null;
- }
- activityBubbleRef = createRef<HTMLDivElement>();
- formatProjectActivity = (author, item) => {
- const data = item.data;
- const {organization} = this.props;
- const orgId = organization.slug;
- const issue = item.issue;
- const basePath = `/organizations/${orgId}/issues/`;
- const issueLink = issue ? (
- <IssueLink orgId={orgId} issue={issue} to={`${basePath}${issue.id}/`} card>
- {issue.shortId}
- </IssueLink>
- ) : null;
- const versionLink = this.renderVersionLink(data.version, item);
- switch (item.type) {
- case 'note':
- return tct('[author] commented on [issue]', {
- author,
- issue: (
- <IssueLink
- card
- orgId={orgId}
- issue={issue}
- to={`${basePath}${issue.id}/activity/#event_${item.id}`}
- >
- {issue.shortId}
- </IssueLink>
- ),
- });
- case 'set_resolved':
- return tct('[author] marked [issue] as resolved', {
- author,
- issue: issueLink,
- });
- case 'set_resolved_by_age':
- return tct('[author] marked [issue] as resolved due to age', {
- author,
- issue: issueLink,
- });
- case 'set_resolved_in_release':
- const {current_release_version, version} = item.data;
- if (current_release_version) {
- return tct(
- '[author] marked [issue] as resolved in releases greater than [version]',
- {
- author,
- version: this.renderVersionLink(current_release_version, item),
- issue: issueLink,
- }
- );
- }
- if (version) {
- return tct('[author] marked [issue] as resolved in [version]', {
- author,
- version: versionLink,
- issue: issueLink,
- });
- }
- return tct('[author] marked [issue] as resolved in the upcoming release', {
- author,
- issue: issueLink,
- });
- case 'set_resolved_in_commit':
- return tct('[author] marked [issue] as resolved in [version]', {
- author,
- version: (
- <CommitLink
- inline
- commitId={data.commit && data.commit.id}
- repository={data.commit && data.commit.repository}
- />
- ),
- issue: issueLink,
- });
- case 'set_resolved_in_pull_request':
- return tct('[author] marked [issue] as resolved in [version]', {
- author,
- version: (
- <PullRequestLink
- inline
- pullRequest={data.pullRequest}
- repository={data.pullRequest && data.pullRequest.repository}
- />
- ),
- issue: issueLink,
- });
- case 'set_unresolved':
- return tct('[author] marked [issue] as unresolved', {
- author,
- issue: issueLink,
- });
- case 'set_ignored':
- if (data.ignoreDuration) {
- return tct('[author] ignored [issue] for [duration]', {
- author,
- duration: <Duration seconds={data.ignoreDuration * 60} />,
- issue: issueLink,
- });
- }
- if (data.ignoreCount && data.ignoreWindow) {
- return tct(
- '[author] ignored [issue] until it happens [count] time(s) in [duration]',
- {
- author,
- count: data.ignoreCount,
- duration: <Duration seconds={data.ignoreWindow * 60} />,
- issue: issueLink,
- }
- );
- }
- if (data.ignoreCount) {
- return tct('[author] ignored [issue] until it happens [count] time(s)', {
- author,
- count: data.ignoreCount,
- issue: issueLink,
- });
- }
- if (data.ignoreUserCount && data.ignoreUserWindow) {
- return tct(
- '[author] ignored [issue] until it affects [count] user(s) in [duration]',
- {
- author,
- count: data.ignoreUserCount,
- duration: <Duration seconds={data.ignoreUserWindow * 60} />,
- issue: issueLink,
- }
- );
- }
- if (data.ignoreUserCount) {
- return tct('[author] ignored [issue] until it affects [count] user(s)', {
- author,
- count: data.ignoreUserCount,
- issue: issueLink,
- });
- }
- return tct('[author] ignored [issue]', {
- author,
- issue: issueLink,
- });
- case 'set_public':
- return tct('[author] made [issue] public', {
- author,
- issue: issueLink,
- });
- case 'set_private':
- return tct('[author] made [issue] private', {
- author,
- issue: issueLink,
- });
- case 'set_regression':
- if (data.version) {
- return tct('[author] marked [issue] as a regression in [version]', {
- author,
- version: versionLink,
- issue: issueLink,
- });
- }
- return tct('[author] marked [issue] as a regression', {
- author,
- issue: issueLink,
- });
- case 'create_issue':
- return tct('[author] linked [issue] on [provider]', {
- author,
- provider: data.provider,
- issue: issueLink,
- });
- case 'unmerge_destination':
- return tn(
- '%2$s migrated %1$s fingerprint from %3$s to %4$s',
- '%2$s migrated %1$s fingerprints from %3$s to %4$s',
- data.fingerprints.length,
- author,
- data.source ? (
- <a href={`${basePath}${data.source.id}`}>{data.source.shortId}</a>
- ) : (
- t('a group')
- ),
- issueLink
- );
- case 'first_seen':
- return tct('[author] saw [link:issue]', {
- author,
- issue: issueLink,
- });
- case 'assigned':
- let assignee;
- if (data.assigneeType === 'team') {
- const team = TeamStore.getById(data.assignee);
- assignee = team ? team.slug : '<unknown-team>';
- return tct('[author] assigned [issue] to #[assignee]', {
- author,
- issue: issueLink,
- assignee,
- });
- }
- if (item.user && data.assignee === item.user.id) {
- return tct('[author] assigned [issue] to themselves', {
- author,
- issue: issueLink,
- });
- }
- assignee = MemberListStore.getById(data.assignee);
- if (assignee && assignee.email) {
- return tct('[author] assigned [issue] to [assignee]', {
- author,
- assignee: <span title={assignee.email}>{assignee.name}</span>,
- issue: issueLink,
- });
- }
- if (data.assigneeEmail) {
- return tct('[author] assigned [issue] to [assignee]', {
- author,
- assignee: data.assigneeEmail,
- issue: issueLink,
- });
- }
- return tct('[author] assigned [issue] to an [help:unknown user]', {
- author,
- help: <span title={data.assignee} />,
- issue: issueLink,
- });
- case 'unassigned':
- return tct('[author] unassigned [issue]', {
- author,
- issue: issueLink,
- });
- case 'merge':
- return tct('[author] merged [count] [link:issues]', {
- author,
- count: data.issues.length + 1,
- link: <Link to={`${basePath}${issue.id}/`} />,
- });
- case 'release':
- return tct('[author] released version [version]', {
- author,
- version: versionLink,
- });
- case 'deploy':
- return tct('[author] deployed version [version] to [environment].', {
- author,
- version: versionLink,
- environment: data.environment || 'Default Environment',
- });
- case 'mark_reviewed':
- return tct('[author] marked [issue] as reviewed', {
- author,
- issue: issueLink,
- });
- default:
- return ''; // should never hit (?)
- }
- };
- render() {
- const {className, item} = this.props;
- const avatar = (
- <ActivityAvatar
- type={!item.user ? 'system' : 'user'}
- user={item.user ?? undefined}
- size={36}
- />
- );
- const author = {
- name: item.user ? item.user.name : 'Sentry',
- avatar,
- };
- const hasBubble = ['note', 'create_issue'].includes(item.type);
- const bubbleProps = {
- ...(item.type === 'note'
- ? {dangerouslySetInnerHTML: {__html: marked(item.data.text)}}
- : {}),
- ...(item.type === 'create_issue'
- ? {
- children: (
- <ExternalLink href={item.data.location}>{item.data.title}</ExternalLink>
- ),
- }
- : {}),
- };
- return (
- <div data-test-id="activity-feed-item" className={className}>
- {author.avatar}
- <div>
- {this.formatProjectActivity(
- <span>
- <ActivityAuthor>{author.name}</ActivityAuthor>
- </span>,
- item
- )}
- {hasBubble && (
- <Bubble
- ref={this.activityBubbleRef}
- clipped={this.state.clipped}
- {...bubbleProps}
- />
- )}
- <Meta>
- <Project>{item.project.slug}</Project>
- <StyledTimeSince date={item.dateCreated} />
- </Meta>
- </div>
- </div>
- );
- }
- }
- export default styled(ActivityItem)`
- display: grid;
- gap: ${space(1)};
- grid-template-columns: max-content auto;
- position: relative;
- margin: 0;
- padding: ${space(1)};
- border-bottom: 1px solid ${p => p.theme.innerBorder};
- line-height: 1.4;
- font-size: ${p => p.theme.fontSizeMedium};
- `;
- const ActivityAuthor = styled('span')`
- font-weight: 600;
- `;
- const Meta = styled('div')`
- color: ${p => p.theme.textColor};
- font-size: ${p => p.theme.fontSizeRelativeSmall};
- `;
- const Project = styled('span')`
- font-weight: bold;
- `;
- const Bubble = styled('div')<{clipped: boolean}>`
- background: ${p => p.theme.backgroundSecondary};
- margin: ${space(0.5)} 0;
- padding: ${space(1)} ${space(2)};
- border: 1px solid ${p => p.theme.border};
- border-radius: 3px;
- box-shadow: 0 1px 1px rgba(0, 0, 0, 0.04);
- position: relative;
- overflow: hidden;
- a {
- max-width: 100%;
- overflow-x: hidden;
- text-overflow: ellipsis;
- }
- p {
- &:last-child {
- margin-bottom: 0;
- }
- }
- ${p =>
- p.clipped &&
- `
- max-height: 68px;
- &:after {
- position: absolute;
- content: '';
- display: block;
- bottom: 0;
- right: 0;
- left: 0;
- height: 36px;
- background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 1));
- border-bottom: 6px solid #fff;
- border-radius: 0 0 3px 3px;
- pointer-events: none;
- }
- `}
- `;
- const StyledTimeSince = styled(TimeSince)`
- color: ${p => p.theme.gray300};
- padding-left: ${space(1)};
- `;
|