Skip to content

feat(issues): Fall back to image renderer #93858

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 18, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import ImageViewer from 'sentry/components/events/attachmentViewers/imageViewer';
import JsonViewer from 'sentry/components/events/attachmentViewers/jsonViewer';
import LogFileViewer from 'sentry/components/events/attachmentViewers/logFileViewer';
import type RRWebJsonViewer from 'sentry/components/events/attachmentViewers/rrwebJsonViewer';
import RRWebJsonViewer from 'sentry/components/events/attachmentViewers/rrwebJsonViewer';
import {WebMViewer} from 'sentry/components/events/attachmentViewers/webmViewer';
import type {IssueAttachment} from 'sentry/types/group';

Expand Down Expand Up @@ -37,26 +37,36 @@ type AttachmentRenderer =
| typeof RRWebJsonViewer
| typeof WebMViewer;

export const getInlineAttachmentRenderer = (
export const getImageAttachmentRenderer = (
attachment: IssueAttachment
): AttachmentRenderer | undefined => {
if (imageMimeTypes.includes(attachment.mimetype)) {
return ImageViewer;
}
if (webmMimeType === attachment.mimetype) {
return WebMViewer;
}
return undefined;
};

export const getInlineAttachmentRenderer = (
attachment: IssueAttachment
): AttachmentRenderer | undefined => {
const imageAttachmentRenderer = getImageAttachmentRenderer(attachment);
if (imageAttachmentRenderer) {
return imageAttachmentRenderer;
}

if (logFileMimeTypes.includes(attachment.mimetype)) {
return LogFileViewer;
}

if (
(jsonMimeTypes.includes(attachment.mimetype) && attachment.name === 'rrweb.json') ||
attachment.name.startsWith('rrweb-')
) {
return JsonViewer;
}
if (jsonMimeTypes.includes(attachment.mimetype)) {
if (attachment.name === 'rrweb.json' || attachment.name.startsWith('rrweb-')) {
return RRWebJsonViewer;
}

if (webmMimeType === attachment.mimetype) {
return WebMViewer;
return JsonViewer;
}

return undefined;
Expand Down
205 changes: 98 additions & 107 deletions static/app/components/events/eventTagsAndScreenshot/screenshot/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type {ReactEventHandler} from 'react';
import {Fragment, useState} from 'react';
import {useState} from 'react';
import {css} from '@emotion/react';
import styled from '@emotion/styled';

Expand All @@ -8,8 +8,9 @@ import {openConfirmModal} from 'sentry/components/confirm';
import {Button} from 'sentry/components/core/button';
import {ButtonBar} from 'sentry/components/core/button/buttonBar';
import {DropdownMenu} from 'sentry/components/dropdownMenu';
import ImageViewer from 'sentry/components/events/attachmentViewers/imageViewer';
import {
getInlineAttachmentRenderer,
getImageAttachmentRenderer,
imageMimeTypes,
webmMimeType,
} from 'sentry/components/events/attachmentViewers/previewAttachmentTypes';
Expand Down Expand Up @@ -70,115 +71,105 @@ function Screenshot({
onDelete(screenshotAttachmentId);
}

function renderContent(screenshotAttachment: EventAttachment) {
const AttachmentComponent = getInlineAttachmentRenderer(screenshotAttachment)!;

const downloadUrl = `/api/0/projects/${organization.slug}/${projectSlug}/events/${eventId}/attachments/${screenshot.id}/`;

return (
<Fragment>
{totalScreenshots > 1 && (
<StyledPanelHeader lightText>
const AttachmentComponent = getImageAttachmentRenderer(screenshot) ?? ImageViewer;
const downloadUrl = `/api/0/projects/${organization.slug}/${projectSlug}/events/${eventId}/attachments/${screenshot.id}/`;

return (
<StyledPanel>
{totalScreenshots > 1 && (
<StyledPanelHeader lightText>
<Button
disabled={screenshotInFocus === 0}
aria-label={t('Previous Screenshot')}
onClick={onPrevious}
icon={<IconChevron direction="left" />}
size="xs"
/>
{tct('[currentScreenshot] of [totalScreenshots]', {
currentScreenshot: screenshotInFocus + 1,
totalScreenshots,
})}
<Button
disabled={screenshotInFocus + 1 === totalScreenshots}
aria-label={t('Next Screenshot')}
onClick={onNext}
icon={<IconChevron direction="right" />}
size="xs"
/>
</StyledPanelHeader>
)}
<StyledPanelBody hasHeader={totalScreenshots > 1}>
{loadingImage && (
<StyledLoadingIndicator>
<LoadingIndicator mini />
</StyledLoadingIndicator>
)}
<AttachmentComponentWrapper
onClick={() => openVisualizationModal(screenshot, `${downloadUrl}?download=1`)}
>
<AttachmentComponent
orgSlug={organization.slug}
projectSlug={projectSlug}
eventId={eventId}
attachment={screenshot}
onLoad={() => setLoadingImage(false)}
onError={() => setLoadingImage(false)}
controls={false}
onCanPlay={() => setLoadingImage(false)}
/>
</AttachmentComponentWrapper>
</StyledPanelBody>
{!onlyRenderScreenshot && (
<StyledPanelFooter>
<ButtonBar gap={1}>
<Button
disabled={screenshotInFocus === 0}
aria-label={t('Previous Screenshot')}
onClick={onPrevious}
icon={<IconChevron direction="left" />}
size="xs"
/>
{tct('[currentScreenshot] of [totalScreenshots]', {
currentScreenshot: screenshotInFocus + 1,
totalScreenshots,
})}
<Button
disabled={screenshotInFocus + 1 === totalScreenshots}
aria-label={t('Next Screenshot')}
onClick={onNext}
icon={<IconChevron direction="right" />}
onClick={() =>
openVisualizationModal(screenshot, `${downloadUrl}?download=1`)
}
>
{t('View screenshot')}
</Button>
<DropdownMenu
position="bottom"
offset={4}
triggerProps={{
showChevron: false,
icon: <IconEllipsis />,
'aria-label': t('More screenshot actions'),
}}
size="xs"
/>
</StyledPanelHeader>
)}
<StyledPanelBody hasHeader={totalScreenshots > 1}>
{loadingImage && (
<StyledLoadingIndicator>
<LoadingIndicator mini />
</StyledLoadingIndicator>
)}
<AttachmentComponentWrapper
onClick={() =>
openVisualizationModal(screenshot, `${downloadUrl}?download=1`)
}
>
<AttachmentComponent
orgSlug={organization.slug}
projectSlug={projectSlug}
eventId={eventId}
attachment={screenshot}
onLoad={() => setLoadingImage(false)}
onError={() => setLoadingImage(false)}
controls={false}
onCanPlay={() => setLoadingImage(false)}
/>
</AttachmentComponentWrapper>
</StyledPanelBody>
{!onlyRenderScreenshot && (
<StyledPanelFooter>
<ButtonBar gap={1}>
<Button
size="xs"
onClick={() =>
openVisualizationModal(
screenshotAttachment,
`${downloadUrl}?download=1`
)
}
>
{t('View screenshot')}
</Button>
<DropdownMenu
position="bottom"
offset={4}
triggerProps={{
showChevron: false,
icon: <IconEllipsis />,
'aria-label': t('More screenshot actions'),
}}
size="xs"
items={[
{
key: 'download',
label: t('Download'),
onAction: () => {
window.location.assign(`${downloadUrl}?download=1`);
trackAnalytics(
'issue_details.issue_tab.screenshot_dropdown_download',
{organization}
);
},
},
{
key: 'delete',
label: t('Delete'),
onAction: () =>
openConfirmModal({
header: t('Delete this image?'),
message: t(
'This image was captured around the time that the event occurred. Are you sure you want to delete this image?'
),
onConfirm: () => handleDelete(screenshotAttachment.id),
}),
items={[
{
key: 'download',
label: t('Download'),
onAction: () => {
window.location.assign(`${downloadUrl}?download=1`);
trackAnalytics(
'issue_details.issue_tab.screenshot_dropdown_download',
{organization}
);
},
]}
/>
</ButtonBar>
</StyledPanelFooter>
)}
</Fragment>
);
}

return <StyledPanel>{renderContent(screenshot)}</StyledPanel>;
},
{
key: 'delete',
label: t('Delete'),
onAction: () =>
openConfirmModal({
header: t('Delete this image?'),
message: t(
'This image was captured around the time that the event occurred. Are you sure you want to delete this image?'
),
onConfirm: () => handleDelete(screenshot.id),
}),
},
]}
/>
</ButtonBar>
</StyledPanelFooter>
)}
</StyledPanel>
);
}

export default Screenshot;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import type {ComponentProps} from 'react';
import {Fragment, useCallback, useEffect, useMemo, useState} from 'react';
import {Fragment, useCallback, useMemo, useState} from 'react';
import {css} from '@emotion/react';
import styled from '@emotion/styled';
import * as Sentry from '@sentry/react';

import type {ModalRenderProps} from 'sentry/actionCreators/modal';
import Confirm from 'sentry/components/confirm';
Expand All @@ -11,7 +10,8 @@ import {ButtonBar} from 'sentry/components/core/button/buttonBar';
import {LinkButton} from 'sentry/components/core/button/linkButton';
import {Flex} from 'sentry/components/core/layout';
import {DateTime} from 'sentry/components/dateTime';
import {getInlineAttachmentRenderer} from 'sentry/components/events/attachmentViewers/previewAttachmentTypes';
import ImageViewer from 'sentry/components/events/attachmentViewers/imageViewer';
import {getImageAttachmentRenderer} from 'sentry/components/events/attachmentViewers/previewAttachmentTypes';
import {KeyValueData} from 'sentry/components/keyValueData';
import {t, tct} from 'sentry/locale';
import {space} from 'sentry/styles/space';
Expand Down Expand Up @@ -106,18 +106,8 @@ export default function ScreenshotModal({
};
}

const AttachmentComponent = getInlineAttachmentRenderer(currentEventAttachment)!;

useEffect(() => {
if (currentEventAttachment && !AttachmentComponent) {
Sentry.withScope(scope => {
scope.setExtra('mimetype', currentEventAttachment.mimetype);
scope.setExtra('attachmentName', currentEventAttachment.name);
scope.setFingerprint(['no-inline-attachment-renderer']);
scope.captureException(new Error('No screenshot attachment renderer found'));
});
}
}, [currentEventAttachment, AttachmentComponent]);
const AttachmentComponent =
getImageAttachmentRenderer(currentEventAttachment) ?? ImageViewer;

return (
<Fragment>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {EventFixture} from 'sentry-fixture/event';
import {EventAttachmentFixture} from 'sentry-fixture/eventAttachment';
import {OrganizationFixture} from 'sentry-fixture/organization';
import {ProjectFixture} from 'sentry-fixture/project';

import {render, screen} from 'sentry-test/reactTestingLibrary';

import {ScreenshotDataSection} from 'sentry/components/events/eventTagsAndScreenshot/screenshot/screenshotDataSection';
import ProjectsStore from 'sentry/stores/projectsStore';

describe('ScreenshotDataSection', function () {
const organization = OrganizationFixture({
features: ['event-attachments'],
orgRole: 'member',
attachmentsRole: 'member',
});
const project = ProjectFixture();
const event = EventFixture();

beforeEach(() => {
ProjectsStore.loadInitialData([project]);
});

it('renders without error when screenshot has application/json mimetype', async function () {
const attachment = EventAttachmentFixture({
name: 'screenshot.png',
mimetype: 'application/json',
headers: {
'Content-Type': 'application/json',
},
});

MockApiClient.addMockResponse({
url: `/projects/${organization.slug}/${project.slug}/events/${event.id}/attachments/`,
body: [attachment],
});

render(<ScreenshotDataSection event={event} projectSlug={project.slug} />, {
organization,
});

expect(await screen.findByTestId('image-viewer')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,17 @@ export function ScreenshotDataSection({
},
{enabled: !isShare}
);
const [screenshotInFocus, setScreenshotInFocus] = useState<number>(0);
const {mutate: deleteAttachment} = useDeleteEventAttachmentOptimistic();
const screenshots = attachments?.filter(({name}) => name.includes('screenshot')) ?? [];
const screenshots = attachments?.filter(attachment =>
attachment.name.includes('screenshot')
);

const [screenshotInFocus, setScreenshotInFocus] = useState<number>(0);
const showScreenshot = !isShare && !!screenshots?.length;
if (!showScreenshot) {
return null;
}

const showScreenshot = !isShare && !!screenshots.length;
const screenshot = screenshots[screenshotInFocus]!;

const handleDeleteScreenshot = (attachmentId: string) => {
Expand Down
Loading
Loading