Skip to content
Open
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
1 change: 1 addition & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@
"@tiptap/extension-placeholder": "^3.22.5",
"@tiptap/react": "^3.22.5",
"@tiptap/starter-kit": "^3.22.5",
"@zumer/snapdom": "^2.23.1",
"border-beam": "1.3.0",
"check-password-strength": "^2.0.10",
"cmdk": "^1.0.0",
Expand Down
26 changes: 26 additions & 0 deletions packages/shared/src/components/buttons/CopyConfirmIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { ReactElement } from 'react';
import React from 'react';
import classNames from 'classnames';
import { UpvoteIcon } from '../icons';
import type { IconProps } from '../Icon';

/**
* The confirmation half of a copy control: the same filled arrow and spin the
* upvote button uses, so the gesture that means "that worked" looks the same
* everywhere. Swap it in for the resting icon while the copy is confirmed.
*/
export function CopyConfirmIcon({
className,
...props
}: IconProps): ReactElement {
return (
<UpvoteIcon
{...props}
className={classNames(
className,
'animate-copy-confirm text-accent-avocado-default motion-reduce:animate-none',
)}
secondary
/>
);
}
13 changes: 13 additions & 0 deletions packages/shared/src/components/icons/Snapshot/filled.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions packages/shared/src/components/icons/Snapshot/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { ReactElement } from 'react';
import React from 'react';
import type { IconProps } from '../../Icon';
import Icon from '../../Icon';
import OutlinedIcon from './outlined.svg';
import FilledIcon from './filled.svg';

export const SnapshotIcon = (props: IconProps): ReactElement => (
<Icon {...props} IconPrimary={OutlinedIcon} IconSecondary={FilledIcon} />
);
11 changes: 11 additions & 0 deletions packages/shared/src/components/icons/Snapshot/outlined.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions packages/shared/src/components/icons/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ export * from './Shortcuts';
export * from './Sidebar';
export * from './Sites';
export * from './Slack';
export * from './Snapshot';
export * from './Sort';
export * from './Source';
export * from './Sparkle';
Expand Down
110 changes: 110 additions & 0 deletions packages/shared/src/components/imageShare/SnapshotButton.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { SnapshotButton } from './SnapshotButton';

const mockCapture = jest.fn();
const mockCopy = jest.fn();
const mockDownload = jest.fn();
const mockDisplayToast = jest.fn();

jest.mock('../../lib/imageShare/captureShareImage', () => ({
captureShareImage: (...args: unknown[]) => mockCapture(...args),
}));

jest.mock('../../lib/imageShare/copyShareImage', () => ({
copyShareImage: (...args: unknown[]) => mockCopy(...args),
}));

jest.mock('../../lib/imageShare/downloadShareImage', () => ({
downloadShareImage: (...args: unknown[]) => mockDownload(...args),
}));

jest.mock('../../features/snapshot/shutterSound', () => ({
playShutterSound: jest.fn(),
}));

jest.mock('../../hooks/useToastNotification', () => ({
useToastNotification: () => ({ displayToast: mockDisplayToast }),
ToastType: { Success: 'success', Error: 'error' },
}));

jest.mock('../../hooks/useRequestProtocol', () => ({
useRequestProtocol: () => ({ isCompanion: false }),
}));

const blob = new Blob(['png'], { type: 'image/png' });

const renderComponent = (props = {}) => {
const target = document.createElement('div');

return render(
<SnapshotButton filename="daily-snapshot" target={target} {...props} />,
);
};

const clickSnapshot = () =>
fireEvent.click(screen.getByLabelText('Snapshot'), {
preventDefault: jest.fn(),
});

beforeEach(() => {
jest.clearAllMocks();
mockCapture.mockResolvedValue(blob);
});

it('copies the image and says so', async () => {
mockCopy.mockResolvedValue(true);
renderComponent();

clickSnapshot();

await waitFor(() =>
expect(mockDisplayToast).toHaveBeenCalledWith('Image copied', {
variant: 'success',
}),
);
expect(mockDownload).not.toHaveBeenCalled();
});

it('falls back to a download when the clipboard is unavailable', async () => {
mockCopy.mockResolvedValue(false);
renderComponent({ filename: 'daily-profile-tomer' });

clickSnapshot();

await waitFor(() =>
expect(mockDownload).toHaveBeenCalledWith(blob, 'daily-profile-tomer'),
);
expect(mockDisplayToast).toHaveBeenCalledWith('Image saved', {
variant: 'success',
});
});

it('reports a failed capture instead of copying or downloading', async () => {
mockCapture.mockRejectedValue(new Error('target element has no size'));
mockCopy.mockResolvedValue(false);
renderComponent();

clickSnapshot();

await waitFor(() =>
expect(mockDisplayToast).toHaveBeenCalledWith(
'Could not create the snapshot, please try again',
{ variant: 'error' },
),
);
expect(mockDownload).not.toHaveBeenCalled();
});

it('hands the blob to onCapture instead of sharing it', async () => {
const onCapture = jest.fn();
mockCopy.mockResolvedValue(true);
renderComponent({ onCapture });

clickSnapshot();

await waitFor(() => expect(onCapture).toHaveBeenCalledWith(blob));
expect(mockCopy).not.toHaveBeenCalled();
expect(mockDownload).not.toHaveBeenCalled();
expect(mockDisplayToast).not.toHaveBeenCalled();
});
126 changes: 126 additions & 0 deletions packages/shared/src/components/imageShare/SnapshotButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import type { ReactElement } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import classNames from 'classnames';
import { Button, ButtonSize, ButtonVariant } from '../buttons/Button';
import { SnapshotIcon } from '../icons';
import { Tooltip } from '../tooltip/Tooltip';
import {
ToastType,
useToastNotification,
} from '../../hooks/useToastNotification';
import type {
CaptureShareImageOptions,
CaptureTarget,
} from '../../lib/imageShare/captureShareImage';
import { captureShareImage } from '../../lib/imageShare/captureShareImage';
import { downloadShareImage } from '../../lib/imageShare/downloadShareImage';
import { copyShareImage } from '../../lib/imageShare/copyShareImage';
import { playShutterSound } from '../../features/snapshot/shutterSound';

const SNAPSHOT_LABEL = 'Snapshot';

/** Matches the snapshot-shutter-sweep animation in utilities.css. */
const SHUTTER_SWEEP_MS = 380;

export interface SnapshotButtonProps {
target: CaptureTarget;
filename?: string;
label?: string;
showLabel?: boolean;
size?: ButtonSize;
variant?: ButtonVariant;
className?: string;
captureOptions?: CaptureShareImageOptions;
onCapture?: (blob: Blob) => void;
}

export function SnapshotButton({
target,
filename = 'daily-snapshot',
label = SNAPSHOT_LABEL,
showLabel = true,
captureOptions,
onCapture,
size = ButtonSize.Small,
variant = ButtonVariant.Tertiary,
className,
}: SnapshotButtonProps): ReactElement {
const { displayToast } = useToastNotification();
const [isCapturing, setIsCapturing] = useState(false);
const [isFlashing, setIsFlashing] = useState(false);
const flashTimeout = useRef<ReturnType<typeof setTimeout>>();

useEffect(
() => () => {
if (flashTimeout.current) {
clearTimeout(flashTimeout.current);
}
},
[],
);

const onSnapshot = useCallback(
async (event: React.MouseEvent) => {
// Every placement sits inside a clickable card, row or link.
event.preventDefault();
event.stopPropagation();
playShutterSound();
setIsFlashing(true);
flashTimeout.current = setTimeout(
() => setIsFlashing(false),
SHUTTER_SWEEP_MS,
);
setIsCapturing(true);

try {
const capture = captureShareImage(target, captureOptions);

if (onCapture) {
onCapture(await capture);
return;
}

// Pasting beats a file in Downloads for every target we share to, so
// the clipboard leads and the download is the fallback.
if (await copyShareImage(capture)) {
displayToast('Image copied', { variant: ToastType.Success });
return;
}

downloadShareImage(await capture, filename);
displayToast('Image saved', { variant: ToastType.Success });
} catch {
displayToast('Could not create the snapshot, please try again', {
variant: ToastType.Error,
});
} finally {
setIsCapturing(false);
}
},
[captureOptions, displayToast, filename, onCapture, target],
);

return (
<Tooltip content={label} visible={!showLabel}>
<Button
type="button"
aria-label={label}
className={classNames(
'relative shrink-0 overflow-hidden',
// A pseudo-element rather than a child: Button reads its children to
// decide whether it is icon-only, and an overlay node would widen it.
isFlashing && 'snapshot-shutter-sweep',
className,
)}
size={size}
variant={variant}
loading={isCapturing}
disabled={isCapturing}
icon={<SnapshotIcon />}
onClick={onSnapshot}
>
{showLabel ? label : undefined}
</Button>
</Tooltip>
);
}
Loading
Loading