diff --git a/packages/shared/package.json b/packages/shared/package.json index 00e6ef543b4..02c0664bf98 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -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", diff --git a/packages/shared/src/components/icons/Snapshot/filled.svg b/packages/shared/src/components/icons/Snapshot/filled.svg new file mode 100644 index 00000000000..d4cc05f0b56 --- /dev/null +++ b/packages/shared/src/components/icons/Snapshot/filled.svg @@ -0,0 +1,13 @@ + + + Icon/Snapshot/Filled + + + + + + + + + + diff --git a/packages/shared/src/components/icons/Snapshot/index.tsx b/packages/shared/src/components/icons/Snapshot/index.tsx new file mode 100644 index 00000000000..8707b229fad --- /dev/null +++ b/packages/shared/src/components/icons/Snapshot/index.tsx @@ -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 => ( + +); diff --git a/packages/shared/src/components/icons/Snapshot/outlined.svg b/packages/shared/src/components/icons/Snapshot/outlined.svg new file mode 100644 index 00000000000..af265154e03 --- /dev/null +++ b/packages/shared/src/components/icons/Snapshot/outlined.svg @@ -0,0 +1,11 @@ + + + Icon/Snapshot/Outline + + + + + + + + diff --git a/packages/shared/src/components/icons/index.ts b/packages/shared/src/components/icons/index.ts index 52ee9458013..5c1057b1724 100644 --- a/packages/shared/src/components/icons/index.ts +++ b/packages/shared/src/components/icons/index.ts @@ -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'; diff --git a/packages/shared/src/components/imageShare/SnapshotButton.spec.tsx b/packages/shared/src/components/imageShare/SnapshotButton.spec.tsx new file mode 100644 index 00000000000..57c00d50959 --- /dev/null +++ b/packages/shared/src/components/imageShare/SnapshotButton.spec.tsx @@ -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( + , + ); +}; + +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(); +}); diff --git a/packages/shared/src/components/imageShare/SnapshotButton.tsx b/packages/shared/src/components/imageShare/SnapshotButton.tsx new file mode 100644 index 00000000000..e9a8e000d7d --- /dev/null +++ b/packages/shared/src/components/imageShare/SnapshotButton.tsx @@ -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>(); + + 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 ( + + + + ); +} diff --git a/packages/shared/src/components/profile/ProfileHeader.tsx b/packages/shared/src/components/profile/ProfileHeader.tsx index 3061b34c3bf..0622d8b0aac 100644 --- a/packages/shared/src/components/profile/ProfileHeader.tsx +++ b/packages/shared/src/components/profile/ProfileHeader.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react'; -import React from 'react'; +import React, { useRef } from 'react'; import dynamic from 'next/dynamic'; import classNames from 'classnames'; import { Image } from '../image/Image'; @@ -8,13 +8,14 @@ import { TypographyColor, TypographyType, } from '../typography/Typography'; -import { DevPlusIcon, EditIcon } from '../icons'; +import { DevPlusIcon, EditIcon, LinkIcon } from '../icons'; import type { PublicProfile } from '../../lib/user'; import type { UserStatsProps } from './UserStats'; import { UserStats } from './UserStats'; import JoinedDate from './JoinedDate'; import { Separator } from '../cards/common/common'; -import { Button, ButtonVariant } from '../buttons/Button'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { CopyStateIcon } from '../share/CopyStateIcon'; import { webappUrl } from '../../lib/constants'; import Link from '../utilities/Link'; import { useAuthContext } from '../../contexts/AuthContext'; @@ -24,6 +25,12 @@ import { locationToString } from '../../lib/utils'; import { IconSize } from '../Icon'; import { fallbackImages } from '../../lib/config'; import { ProfileDesktopPwaBackButton } from './ProfileBackButton'; +import { SnapshotButton } from '../imageShare/SnapshotButton'; +import { Tooltip } from '../tooltip/Tooltip'; +import { useCopyLink } from '../../hooks/useCopy'; +import { useLogContext } from '../../contexts/LogContext'; +import { LogEvent, TargetType } from '../../lib/log'; +import { ShareProvider } from '../../lib/share'; import { ElementPlaceholder } from '../ElementPlaceholder'; @@ -67,9 +74,25 @@ const ProfileHeader = ({ const { name, username, bio, image, cover, isPlus } = user; const { user: loggedUser } = useAuthContext(); const isSameUser = propIsSameUser ?? loggedUser?.id === user.id; + const headerRef = useRef(null); + const { logEvent } = useLogContext(); + const [isCopying, copyLink] = useCopyLink(() => user.permalink); + + const onCopyLink = () => { + copyLink(); + logEvent({ + event_name: LogEvent.ShareProfile, + target_type: TargetType.ProfilePage, + target_id: user.id, + extra: JSON.stringify({ provider: ShareProvider.CopyLink }), + }); + }; return ( -
+
Cover @@ -100,6 +123,23 @@ const ProfileHeader = ({ aria-label="Edit profile" /> + + +
diff --git a/packages/shared/src/components/share/CopyStateIcon.spec.tsx b/packages/shared/src/components/share/CopyStateIcon.spec.tsx new file mode 100644 index 00000000000..adaa7834668 --- /dev/null +++ b/packages/shared/src/components/share/CopyStateIcon.spec.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { LinkIcon } from '../icons'; +import { CopyStateIcon } from './CopyStateIcon'; + +const layers = (container: HTMLElement): string[] => { + const grid = container.querySelector('span.inline-grid'); + + if (!grid) { + throw new Error('CopyStateIcon did not render its grid'); + } + + return Array.from(grid.children).map( + (child) => child.getAttribute('class') ?? '', + ); +}; + +describe('CopyStateIcon', () => { + it('rests on the given glyph with the confirmation hidden', () => { + const { container } = render( + , + ); + const [resting, confirmation] = layers(container); + + expect(resting).not.toContain('opacity-0'); + expect(confirmation).toContain('opacity-0'); + }); + + it('swaps to a green confirmation once copied', () => { + const { container } = render(); + const [resting, confirmation] = layers(container); + + expect(resting).toContain('opacity-0'); + expect(confirmation).not.toContain('opacity-0'); + expect(confirmation).toContain('text-status-success'); + }); + + it('keeps both glyphs in one grid cell so the button never resizes', () => { + const { container } = render(); + + layers(container).forEach((className) => { + expect(className).toContain('col-start-1'); + expect(className).toContain('row-start-1'); + }); + }); +}); diff --git a/packages/shared/src/components/share/CopyStateIcon.tsx b/packages/shared/src/components/share/CopyStateIcon.tsx new file mode 100644 index 00000000000..40f1f3cb4dd --- /dev/null +++ b/packages/shared/src/components/share/CopyStateIcon.tsx @@ -0,0 +1,52 @@ +import type { ComponentType, ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { CopyIcon, VIcon } from '../icons'; +import type { IconProps } from '../Icon'; + +/** + * easeOutExpo — the curve the design-system dropdown animates on. It + * decelerates into the target with no overshoot, which is what keeps a swap + * from reading as a wobble. + */ +export const EASE_OUT_EXPO = 'ease-[cubic-bezier(0.16,1,0.3,1)]'; + +/** + * A copy is a rare, deliberate moment, so the confirmation earns real motion. + * Both glyphs share one grid cell so the button never resizes mid-swap, and + * the transition collapses to an instant swap under `prefers-reduced-motion`. + */ +export const CopyStateIcon = ({ + copied, + icon: Icon = CopyIcon, + className, + ...props +}: IconProps & { + copied: boolean; + /** What the button rests on — a link glyph where the payload is a URL. */ + icon?: ComponentType; +}): ReactElement => { + const layer = classNames( + className, + 'col-start-1 row-start-1 transition-[opacity,transform,filter] duration-200 motion-reduce:transition-none', + EASE_OUT_EXPO, + ); + + return ( + + + + + ); +}; diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.tsx index 2e246ff8c46..f0e045f7204 100644 --- a/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.tsx +++ b/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from 'react'; -import React from 'react'; +import React, { useRef } from 'react'; import classNames from 'classnames'; import Link from '../../../../components/utilities/Link'; import { ActivityContainer } from '../../../../components/profile/ActivitySection'; @@ -21,6 +21,8 @@ import { import { RaritySparkles } from '../achievements/RaritySparkles'; import HoverCard from '../../../../components/cards/common/HoverCard'; import { AchievementCard } from '../achievements/AchievementCard'; +import { SnapshotButton } from '../../../../components/imageShare/SnapshotButton'; +import { ButtonSize } from '../../../../components/buttons/common'; interface AchievementsWidgetProps { user: PublicProfile; @@ -134,9 +136,10 @@ export function AchievementsWidget({ user, }: AchievementsWidgetProps): ReactElement { const { unlockedCount, totalCount } = useProfileAchievements(user); + const widgetRef = useRef(null); return ( - +
Achievements - - - {unlockedCount}/{totalCount} - - +
+ + + {unlockedCount}/{totalCount} + + + +
diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.tsx index 6d800232861..7a67a1fe074 100644 --- a/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.tsx +++ b/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from 'react'; -import React from 'react'; +import React, { useRef } from 'react'; import { useQuery } from '@tanstack/react-query'; import { ActivityContainer } from '../../../../components/profile/ActivitySection'; import { topReaderBadgeDocs } from '../../../../lib/constants'; @@ -24,12 +24,15 @@ import { BadgesAndAwardsSkeleton, } from './BadgesAndAwardsComponents'; import { anchorDefaultRel } from '../../../../lib/strings'; +import { SnapshotButton } from '../../../../components/imageShare/SnapshotButton'; +import { ButtonSize } from '../../../../components/buttons/common'; export const BadgesAndAwards = ({ user, }: { user: PublicProfile; }): ReactElement | null => { + const widgetRef = useRef(null); const { data: topReaders, isPending: isTopReaderLoading } = useTopReader({ user, limit: 5, @@ -62,16 +65,24 @@ export const BadgesAndAwards = ({ awards?.reduce((sum, award) => sum + (award?.count || 0), 0) ?? 0; return ( - - - Badges & Awards - + +
+ + Badges & Awards + + +
value.reads; @@ -66,6 +68,7 @@ export function ReadingOverview({ mostReadTags, isLoading = false, }: ReadingOverviewProps): ReactElement { + const widgetRef = useRef(null); const totalReads = useMemo(() => { if (!readHistory?.length) { return 0; @@ -81,16 +84,24 @@ export function ReadingOverview({ } return ( - - - Reading Overview - + +
+ + Reading Overview + + +
{ +
+ + +
)} 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: