+
+ {/* SnapshotButton sets `relative` on itself, which beats an
+ `absolute` passed in, so the wrapper carries the positioning. */}
+
+
+
{});
+}
diff --git a/packages/shared/src/hooks/useCopy.spec.ts b/packages/shared/src/hooks/useCopy.spec.ts
new file mode 100644
index 00000000000..905a7892053
--- /dev/null
+++ b/packages/shared/src/hooks/useCopy.spec.ts
@@ -0,0 +1,67 @@
+import { act, renderHook } from '@testing-library/react';
+import { useCopyLink } from './useCopy';
+
+const mockDisplayToast = jest.fn();
+const mockWriteText = jest.fn();
+
+jest.mock('./useToastNotification', () => ({
+ useToastNotification: () => ({ displayToast: mockDisplayToast }),
+}));
+
+jest.mock('./utils/useGetShortUrl', () => ({
+ useGetShortUrl: () => ({ getShortUrl: jest.fn() }),
+}));
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ Object.assign(navigator, { clipboard: { writeText: mockWriteText } });
+});
+
+it('copies the link and reports the copied state', async () => {
+ mockWriteText.mockResolvedValue(undefined);
+ const { result } = renderHook(() => useCopyLink(() => 'https://daily.dev'));
+
+ await act(async () => {
+ await result.current[1]();
+ });
+
+ expect(mockWriteText).toHaveBeenCalledWith('https://daily.dev');
+ expect(mockDisplayToast).toHaveBeenCalledWith(
+ '✅ Copied link to clipboard',
+ {},
+ );
+ expect(result.current[0]).toBe(true);
+});
+
+it('says so when the clipboard refuses the write', async () => {
+ mockWriteText.mockRejectedValue(
+ new DOMException('Document is not focused.', 'NotAllowedError'),
+ );
+ const { result } = renderHook(() => useCopyLink(() => 'https://daily.dev'));
+
+ await act(async () => {
+ await result.current[1]();
+ });
+
+ expect(mockDisplayToast).toHaveBeenCalledWith(
+ '❌ Could not copy, please try again',
+ {},
+ );
+ // Nothing was copied, so the caller must not render a copied confirmation.
+ expect(result.current[0]).toBe(false);
+});
+
+it('does not report a copy when there is no link', async () => {
+ const { result } = renderHook(() => useCopyLink(() => ''));
+
+ await act(async () => {
+ await result.current[1]();
+ });
+
+ expect(mockWriteText).not.toHaveBeenCalled();
+ expect(mockDisplayToast).toHaveBeenCalledWith(
+ '❌ Could not copy, link is missing',
+ {},
+ );
+ expect(result.current[0]).toBe(false);
+});
diff --git a/packages/shared/src/hooks/useCopy.ts b/packages/shared/src/hooks/useCopy.ts
index ac772be919b..cc8f6fbe787 100644
--- a/packages/shared/src/hooks/useCopy.ts
+++ b/packages/shared/src/hooks/useCopy.ts
@@ -14,6 +14,8 @@ type CopyNotifyFunctionProps = NotifyOptionalProps & {
const defaultMessage = '✅ Copied to clipboard';
const defaultLinkMessage = '✅ Copied link to clipboard';
const noLinkErrorMessage = '❌ Could not copy, link is missing';
+const copyFailedMessage = '❌ Could not copy, please try again';
+const noTextErrorMessage = '❌ Could not copy, there is nothing to copy';
export type CopyNotifyFunction =
| ((props?: CopyNotifyFunctionProps) => void)
@@ -28,33 +30,42 @@ export function useCopyLink(
const { getShortUrl } = useGetShortUrl();
const copy: CopyNotifyFunction = async (props = {}) => {
- const link = props.link || getLink();
+ const link = props.link || getLink?.();
const shortenLink = props.shorten || shorten;
- if (link) {
- // write the link to clipboard
+ if (!link) {
+ displayToast(noLinkErrorMessage, props);
+
+ return;
+ }
+
+ try {
await navigator.clipboard.writeText(link);
+ } catch {
+ // A refused write used to reject out of here, leaving the caller with no
+ // toast and no copied state, so the button read as dead.
+ displayToast(copyFailedMessage, props);
- // try with a shortened link as well, if requested
- if (shortenLink) {
- try {
- const clipBoardItem = new ClipboardItem({
- 'text/plain': getShortUrl(link).then((shortenedLink) => {
- return new Blob([shortenedLink], { type: 'text/plain' });
- }),
- });
- await navigator.clipboard.write([clipBoardItem]);
- } catch (e) {
- // eslint-disable-next-line no-console
- console.warn('Error copying to clipboard', e);
- }
- }
+ return;
+ }
- if (!props.disableToast) {
- displayToast(props.message || defaultLinkMessage, props);
+ // try with a shortened link as well, if requested
+ if (shortenLink) {
+ try {
+ const clipBoardItem = new ClipboardItem({
+ 'text/plain': getShortUrl(link).then((shortenedLink) => {
+ return new Blob([shortenedLink], { type: 'text/plain' });
+ }),
+ });
+ await navigator.clipboard.write([clipBoardItem]);
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.warn('Error copying to clipboard', e);
}
- } else {
- displayToast(noLinkErrorMessage, props);
+ }
+
+ if (!props.disableToast) {
+ displayToast(props.message || defaultLinkMessage, props);
}
setCopying(true);
@@ -71,7 +82,15 @@ export function useCopyText(text?: string): [boolean, CopyNotifyFunction] {
const { displayToast } = useToastNotification();
const copy: CopyNotifyFunction = async (props = {}) => {
- await navigator.clipboard.writeText(props.textToCopy || text);
+ const textToCopy = props.textToCopy || text;
+
+ if (!textToCopy) {
+ displayToast(noTextErrorMessage, props);
+
+ return;
+ }
+
+ await navigator.clipboard.writeText(textToCopy);
if (!props.disableToast) {
displayToast(props.message || defaultMessage, props);
diff --git a/packages/shared/src/lib/imageShare/captureShareImage.ts b/packages/shared/src/lib/imageShare/captureShareImage.ts
new file mode 100644
index 00000000000..0365ec1b77e
--- /dev/null
+++ b/packages/shared/src/lib/imageShare/captureShareImage.ts
@@ -0,0 +1,207 @@
+import type { RefObject } from 'react';
+import { createElement } from 'react';
+import type { SnapdomOptions } from '@zumer/snapdom';
+import LogoIcon from '../../svg/LogoIcon';
+import LogoText from '../../svg/LogoText';
+
+export const SHARE_IMAGE_WIDTH = 1200;
+export const SHARE_IMAGE_HEIGHT = 630;
+
+const LOGO_BAR_HEIGHT = 72;
+const LOGO_BAR_BORDER = 2;
+const LOGO_HEIGHT = 26;
+const LOGO_GAP = 8;
+const LOGO_ICON_RATIO = 35 / 20;
+const LOGO_TEXT_RATIO = 77 / 20;
+
+export type CaptureTarget = HTMLElement | RefObject;
+
+export interface CaptureShareImageOptions extends SnapdomOptions {
+ width?: number;
+ height?: number;
+ padding?: number;
+ frameBackgroundColor?: string;
+ branded?: boolean;
+}
+
+const TRANSPARENT = 'rgba(0, 0, 0, 0)';
+const CAPTURE_TIMEOUT_MS = 15000;
+
+// A cross-origin image without CORS headers leaves snapdom's inliner pending
+// forever, which would otherwise spin the trigger button indefinitely.
+const withTimeout = (promise: Promise): Promise =>
+ Promise.race([
+ promise,
+ new Promise((_, reject) => {
+ setTimeout(
+ () => reject(new Error('captureShareImage: capture timed out')),
+ CAPTURE_TIMEOUT_MS,
+ );
+ }),
+ ]);
+
+const resolveFrameBackground = (): string => {
+ const rootStyle = getComputedStyle(document.documentElement);
+ const rootBackground = rootStyle.backgroundColor;
+
+ if (rootBackground && rootBackground !== TRANSPARENT) {
+ return rootBackground;
+ }
+
+ const themeBackground = rootStyle
+ .getPropertyValue('--theme-background-default')
+ .trim();
+
+ if (themeBackground) {
+ return themeBackground;
+ }
+
+ return getComputedStyle(document.body).backgroundColor;
+};
+
+const svgToImage = async (markup: string): Promise => {
+ const image = new Image();
+ image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(markup)}`;
+ await image.decode();
+
+ return image;
+};
+
+const drawLogoBar = async (
+ context: CanvasRenderingContext2D,
+ canvasWidth: number,
+ canvasHeight: number,
+): Promise => {
+ const { renderToStaticMarkup } = await import('react-dom/server');
+ const rootStyle = getComputedStyle(document.documentElement);
+ const themeColor = rootStyle.getPropertyValue('--theme-text-primary').trim();
+ const color = themeColor || getComputedStyle(document.body).color;
+ const barBackground = rootStyle
+ .getPropertyValue('--theme-background-default')
+ .trim();
+ const barBorder = rootStyle
+ .getPropertyValue('--theme-border-subtlest-tertiary')
+ .trim();
+
+ const barTop = canvasHeight - LOGO_BAR_HEIGHT;
+
+ if (barBackground) {
+ context.fillStyle = barBackground;
+ context.fillRect(0, barTop, canvasWidth, LOGO_BAR_HEIGHT);
+ }
+
+ if (barBorder) {
+ context.fillStyle = barBorder;
+ context.fillRect(0, barTop, canvasWidth, LOGO_BAR_BORDER);
+ }
+
+ const toSizedMarkup = (markup: string, width: number): string =>
+ markup
+ .replace('
{!isNullOrUndefined(devcard) && (
-
+
+ }
+ onClick={() => generateThenDownload({})}
+ disabled={downloading || isLoading}
+ tag={isMobile ? 'a' : 'button'}
+ href={devCardSrc}
+ target={isMobile ? '_blank' : undefined}
+ >
+ Download
+
+ }
+ onClick={onShareDevCard}
+ disabled={sharing || isLoading}
+ >
+ Share
+
+
)}
diff --git a/packages/webapp/public/sounds/shutter.mp3 b/packages/webapp/public/sounds/shutter.mp3
new file mode 100644
index 00000000000..f49b95f152c
Binary files /dev/null and b/packages/webapp/public/sounds/shutter.mp3 differ
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7126de99fe4..41be8eddbbe 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -447,6 +447,9 @@ importers:
'@tiptap/starter-kit':
specifier: ^3.22.5
version: 3.22.5
+ '@zumer/snapdom':
+ specifier: ^2.23.1
+ version: 2.24.10
border-beam:
specifier: 1.3.0
version: 1.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -1124,7 +1127,7 @@ importers:
dependencies:
'@dailydotdev/world-kit':
specifier: 0.1.1
- version: link:../world-kit
+ version: 0.1.1
packages/world-kit: {}
@@ -1900,6 +1903,9 @@ packages:
peerDependencies:
postcss-selector-parser: ^7.0.0
+ '@dailydotdev/world-kit@0.1.1':
+ resolution: {integrity: sha512-t5pzFaCP5vbh7rjAb+lZ4L/wwSAwEVNFvHEfiL42nHBa/lOuFoMBQPTldEKuBW1mQlSAl3q4bh0ZQezpHhn6cA==}
+
'@discoveryjs/json-ext@0.5.7':
resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==}
engines: {node: '>=10.0.0'}
@@ -4834,6 +4840,9 @@ packages:
'@xtuc/long@4.2.2':
resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==}
+ '@zumer/snapdom@2.24.10':
+ resolution: {integrity: sha512-yK+5HvcP96aZCG8dcOuJDsOD1TACDeSTI0wlsmQkMeeGaM/JVHdBQZPS4h0Uae6bY/zx+WQCJtOKnrbB6D7NgQ==}
+
abab@2.0.6:
resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==}
deprecated: Use your platform's native atob() and btoa() methods instead
@@ -11390,6 +11399,8 @@ snapshots:
dependencies:
postcss-selector-parser: 7.0.0
+ '@dailydotdev/world-kit@0.1.1': {}
+
'@discoveryjs/json-ext@0.5.7': {}
'@dnd-kit/accessibility@3.1.1(react@18.3.1)':
@@ -14226,6 +14237,8 @@ snapshots:
'@xtuc/long@4.2.2': {}
+ '@zumer/snapdom@2.24.10': {}
+
abab@2.0.6: {}
accepts@1.3.8: