diff --git a/static/app/components/events/contexts/knownContext/user.spec.tsx b/static/app/components/events/contexts/knownContext/user.spec.tsx
index 6332006eb7f94a..57f490837428c4 100644
--- a/static/app/components/events/contexts/knownContext/user.spec.tsx
+++ b/static/app/components/events/contexts/knownContext/user.spec.tsx
@@ -1,6 +1,6 @@
import {EventFixture} from 'sentry-fixture/event';
-import {render, screen} from 'sentry-test/reactTestingLibrary';
+import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
import ContextCard from 'sentry/components/events/contexts/contextCard';
import {
@@ -36,7 +36,21 @@ const MOCK_REDACTION = {
describe('UserContext', () => {
it('returns values and according to the parameters', () => {
- expect(getUserContextData({data: MOCK_USER_CONTEXT})).toEqual([
+ const result = getUserContextData({data: MOCK_USER_CONTEXT});
+
+ // Extract the actionButton from the email item for comparison
+ const emailItem = result.find(item => item.key === 'email');
+ const {actionButton, ...emailItemWithoutButton} = emailItem || {};
+
+ expect(
+ result.map(item => {
+ if (item.key === 'email') {
+ const {actionButton: _, ...rest} = item;
+ return rest;
+ }
+ return item;
+ })
+ ).toEqual([
{
key: 'email',
subject: 'Email',
@@ -65,6 +79,9 @@ describe('UserContext', () => {
meta: undefined,
},
]);
+
+ // Verify actionButton exists for email
+ expect(actionButton).toBeDefined();
});
it('renders with meta annotations correctly', () => {
@@ -90,4 +107,22 @@ describe('UserContext', () => {
expect(screen.getByText('Name')).toBeInTheDocument();
expect(screen.getByText(/redacted/)).toBeInTheDocument();
});
+
+ it('allows copying email to clipboard', async () => {
+ const mockCopy = jest.fn().mockResolvedValue('');
+ Object.assign(navigator, {
+ clipboard: {
+ writeText: mockCopy,
+ },
+ });
+
+ const event = EventFixture();
+ render(
+
+ );
+
+ const copyButton = screen.getByRole('button', {name: 'Copy email to clipboard'});
+ await userEvent.click(copyButton);
+ expect(mockCopy).toHaveBeenCalledWith('leander.rodrigues@sentry.io');
+ });
});
diff --git a/static/app/components/events/contexts/knownContext/user.tsx b/static/app/components/events/contexts/knownContext/user.tsx
index 261000f7ed2d39..d44a385fe529b4 100644
--- a/static/app/components/events/contexts/knownContext/user.tsx
+++ b/static/app/components/events/contexts/knownContext/user.tsx
@@ -1,7 +1,12 @@
+import {useCallback} from 'react';
+
+import {Button} from 'sentry/components/core/button';
import {getContextKeys} from 'sentry/components/events/contexts/utils';
+import {IconCopy} from 'sentry/icons';
import {t} from 'sentry/locale';
import type {KeyValueListData} from 'sentry/types/group';
import {defined} from 'sentry/utils';
+import useCopyToClipboard from 'sentry/utils/useCopyToClipboard';
enum UserContextKeys {
ID = 'id',
@@ -104,6 +109,9 @@ export function getUserContextData({
? `mailto:${data.email}`
: undefined,
},
+ actionButton: defined(data.email) ? (
+
+ ) : undefined,
};
case UserContextKeys.GEO:
return {
@@ -125,3 +133,27 @@ export function getUserContextData({
.filter(item => defined(item.value) || defined(meta?.[item.key]))
);
}
+
+function CopyEmailButton({email}: {email: string}) {
+ const {copy} = useCopyToClipboard();
+
+ const handleCopy = useCallback(
+ (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ copy(email, {successMessage: t('Email copied to clipboard')});
+ },
+ [copy, email]
+ );
+
+ return (
+ }
+ onClick={handleCopy}
+ />
+ );
+}
diff --git a/static/app/views/issueDetails/participantList.spec.tsx b/static/app/views/issueDetails/participantList.spec.tsx
index 91da296d0e77bc..8552df590ae631 100644
--- a/static/app/views/issueDetails/participantList.spec.tsx
+++ b/static/app/views/issueDetails/participantList.spec.tsx
@@ -44,4 +44,24 @@ describe('ParticipantList', () => {
expect(screen.queryByText('Individuals (2)')).not.toBeInTheDocument();
});
+
+ it('copies email to clipboard when email is clicked', async () => {
+ const mockCopy = jest.fn().mockResolvedValue('');
+ Object.assign(navigator, {
+ clipboard: {
+ writeText: mockCopy,
+ },
+ });
+
+ render(
+
+ Click Me
+
+ );
+ await userEvent.click(screen.getByRole('button', {name: 'Click Me'}));
+ await waitFor(() => expect(screen.getByText('John Doe')).toBeVisible());
+ const email = screen.getByText('john.doe@example.com');
+ await userEvent.click(email);
+ expect(mockCopy).toHaveBeenCalledWith('john.doe@example.com');
+ });
});
diff --git a/static/app/views/issueDetails/participantList.tsx b/static/app/views/issueDetails/participantList.tsx
index 7a633c4adf0849..8cdcf0cbdd7b30 100644
--- a/static/app/views/issueDetails/participantList.tsx
+++ b/static/app/views/issueDetails/participantList.tsx
@@ -1,4 +1,4 @@
-import {Fragment, useState} from 'react';
+import {Fragment, useCallback, useState} from 'react';
import styled from '@emotion/styled';
import {AnimatePresence, motion} from 'framer-motion';
@@ -10,6 +10,7 @@ import {t, tn} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {Team} from 'sentry/types/organization';
import type {User} from 'sentry/types/user';
+import useCopyToClipboard from 'sentry/utils/useCopyToClipboard';
interface ParticipantScrollboxProps {
teams: Team[];
@@ -17,6 +18,16 @@ interface ParticipantScrollboxProps {
}
function ParticipantScrollbox({users, teams}: ParticipantScrollboxProps) {
+ const {copy} = useCopyToClipboard();
+
+ const handleCopyEmail = useCallback(
+ (email: string, event: React.MouseEvent) => {
+ event.stopPropagation();
+ copy(email, {successMessage: t('Email copied to clipboard')});
+ },
+ [copy]
+ );
+
if (!users.length && !teams.length) {
return null;
}
@@ -41,7 +52,14 @@ function ParticipantScrollbox({users, teams}: ParticipantScrollboxProps) {
{user.name}
- {user.email}
+ {user.email === user.name ? null : (
+ handleCopyEmail(user.email, e)}
+ title={t('Click to copy email')}
+ >
+ {user.email}
+
+ )}
))}
@@ -153,3 +171,10 @@ const SubText = styled('div')`
color: ${p => p.theme.subText};
font-size: ${p => p.theme.fontSize.xs};
`;
+
+const ClickableEmail = styled(SubText)`
+ cursor: pointer;
+ &:hover {
+ text-decoration: underline;
+ }
+`;
diff --git a/static/app/views/issueDetails/streamline/sidebar/participantList.spec.tsx b/static/app/views/issueDetails/streamline/sidebar/participantList.spec.tsx
index 9b0e38f658d2df..d46a2de1bc77d6 100644
--- a/static/app/views/issueDetails/streamline/sidebar/participantList.spec.tsx
+++ b/static/app/views/issueDetails/streamline/sidebar/participantList.spec.tsx
@@ -69,4 +69,19 @@ describe('ParticipantList', () => {
expect(await screen.findByText('#team-1')).toBeInTheDocument();
expect(await screen.findByText('#team-2')).toBeInTheDocument();
});
+
+ it('copies email to clipboard when email is clicked', async () => {
+ const mockCopy = jest.fn().mockResolvedValue('');
+ Object.assign(navigator, {
+ clipboard: {
+ writeText: mockCopy,
+ },
+ });
+
+ render();
+ await userEvent.click(screen.getByText('JD'), {skipHover: true});
+ const email = await screen.findByText('john.doe@example.com');
+ await userEvent.click(email);
+ expect(mockCopy).toHaveBeenCalledWith('john.doe@example.com');
+ });
});
diff --git a/static/app/views/issueDetails/streamline/sidebar/participantList.tsx b/static/app/views/issueDetails/streamline/sidebar/participantList.tsx
index 3e6754de1820d0..8d03cbc25d3056 100644
--- a/static/app/views/issueDetails/streamline/sidebar/participantList.tsx
+++ b/static/app/views/issueDetails/streamline/sidebar/participantList.tsx
@@ -1,4 +1,4 @@
-import {Fragment} from 'react';
+import {Fragment, useCallback} from 'react';
import {useTheme} from '@emotion/react';
import styled from '@emotion/styled';
@@ -13,6 +13,7 @@ import {space} from 'sentry/styles/space';
import type {Team} from 'sentry/types/organization';
import type {AvatarUser, User} from 'sentry/types/user';
import {userDisplayName} from 'sentry/utils/formatters';
+import useCopyToClipboard from 'sentry/utils/useCopyToClipboard';
import useOverlay from 'sentry/utils/useOverlay';
interface DropdownListProps {
@@ -34,6 +35,15 @@ export default function ParticipantList({
const theme = useTheme();
const showHeaders = users.length > 0 && teams && teams.length > 0;
+ const {copy} = useCopyToClipboard();
+
+ const handleCopyEmail = useCallback(
+ (email: string, event: React.MouseEvent) => {
+ event.stopPropagation();
+ copy(email, {successMessage: t('Email copied to clipboard')});
+ },
+ [copy]
+ );
return (
@@ -83,7 +93,12 @@ export default function ParticipantList({
{user.name}
{user.email === user.name ? null : (
- {user.email}
+ handleCopyEmail(user.email, e)}
+ title={t('Click to copy email')}
+ >
+ {user.email}
+
)}
{!hideTimestamp && }
@@ -144,6 +159,13 @@ const SmallText = styled('div')`
font-size: ${p => p.theme.fontSize.xs};
`;
+const ClickableEmail = styled(SmallText)`
+ cursor: pointer;
+ &:hover {
+ text-decoration: underline;
+ }
+`;
+
const StyledAvatarList = styled(AvatarList)`
justify-content: flex-end;
padding-left: ${space(0.75)};