diff --git a/packages/shared/src/components/history/ReadingHistory.spec.tsx b/packages/shared/src/components/history/ReadingHistory.spec.tsx index d25c8bee114..de835c2cf3d 100644 --- a/packages/shared/src/components/history/ReadingHistory.spec.tsx +++ b/packages/shared/src/components/history/ReadingHistory.spec.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { subDays } from 'date-fns'; import type { RenderResult } from '@testing-library/react'; -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import nock from 'nock'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type { PostItemCardProps } from '../post/PostItemCard'; @@ -199,6 +199,26 @@ describe('PostItemCard component', () => { ); }); + it('should copy the post link and confirm on the button itself', async () => { + const writeText = jest.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + + renderCard({ showCopyLink: true }); + + fireEvent.click(await screen.findByLabelText('Copy link')); + + await waitFor(() => + expect(writeText).toHaveBeenCalledWith(post.commentsPermalink), + ); + await screen.findByLabelText('Link copied'); + }); + + it('should not render the copy link button by default', async () => { + renderCard(); + await screen.findByText(postTitle); + expect(screen.queryByLabelText('Copy link')).not.toBeInTheDocument(); + }); + it('should call onHide on close button clicked', async () => { renderCard({ onHide }); const button = (await screen.findAllByRole('button'))[0]; diff --git a/packages/shared/src/components/history/ReadingHistoryList.tsx b/packages/shared/src/components/history/ReadingHistoryList.tsx index cf17c826d9b..befe9b2b9f2 100644 --- a/packages/shared/src/components/history/ReadingHistoryList.tsx +++ b/packages/shared/src/components/history/ReadingHistoryList.tsx @@ -23,37 +23,43 @@ export default function ReadHistoryList({ let currentDate: Date; return data?.pages.map((page, pageIndex) => - page.readHistory.edges.reduce((dom, { node: history }, edgeIndex) => { - const { timestamp } = history; - const date = new Date(timestamp); + page.readHistory.edges.reduce( + (dom, { node: history }, edgeIndex) => { + const { timestamp } = history; + // Optional only because PostItem is shared with surfaces that carry + // no timestamp; every reading-history edge has one. + const date = new Date(timestamp as Date); + + if (!currentDate || !isDateOnlyEqual(currentDate, date)) { + currentDate = date; + dom.push( + , + ); + } + + const indexes = { page: pageIndex, edge: edgeIndex }; - if (!currentDate || !isDateOnlyEqual(currentDate, date)) { - currentDate = date; dom.push( - onHide({ ...params, ...indexes })} + showVoteActions + showCopyLink + logOrigin={Origin.History} />, ); - } - - const indexes = { page: pageIndex, edge: edgeIndex }; - - dom.push( - onHide({ ...params, ...indexes })} - showVoteActions - logOrigin={Origin.History} - />, - ); - return dom; - }, []), + return dom; + }, + [], + ), ); // @NOTE see https://dailydotdev.atlassian.net/l/cp/dK9h1zoM // eslint-disable-next-line react-hooks/exhaustive-deps 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/post/PostItemCard.tsx b/packages/shared/src/components/post/PostItemCard.tsx index 4a49c05a0ed..ac148a74857 100644 --- a/packages/shared/src/components/post/PostItemCard.tsx +++ b/packages/shared/src/components/post/PostItemCard.tsx @@ -24,6 +24,8 @@ import { isSourceUserSource } from '../../graphql/sources'; import { ReadingHistoryOptionsMenu } from '../history/ReadingHistoryOptionsMenu'; import type { QueryIndexes } from '../../hooks/useReadingHistory'; +import { useCopyPostLink } from '../../hooks/useCopyPostLink'; +import { CopyStateIcon } from '../share/CopyStateIcon'; export interface PostItemCardProps { className?: string; @@ -32,6 +34,7 @@ export interface PostItemCardProps { clickable?: boolean; onHide?: (params: HidePostItemCardProps) => Promise; showVoteActions?: boolean; + showCopyLink?: boolean; logOrigin?: Origin; indexes?: QueryIndexes; } @@ -48,6 +51,7 @@ export default function PostItemCard({ onHide, className, showVoteActions = false, + showCopyLink = false, logOrigin = Origin.Feed, indexes, }: PostItemCardProps): ReactElement { @@ -66,6 +70,7 @@ export default function PostItemCard({ const isUserSource = isSourceUserSource(source); const { toggleUpvote, toggleDownvote } = useReadHistoryVotePost(); + const [copying, copyLink] = useCopyPostLink(post.commentsPermalink); const classes = classNames( 'relative flex w-full flex-row py-3 pl-9 pr-5', @@ -185,6 +190,19 @@ export default function PostItemCard({ onClick={onHideClick} /> )} + {showButtons && showCopyLink && ( + +); + +/* ---------------------------------------------------------- page furniture */ + +export type DeviceName = 'Desktop' | 'Tablet' | 'Mobile'; + +/** A control that only works at one of these widths is not a recommendation. */ +const DEVICES: Record = { + Desktop: { width: 680, viewport: '1020px and up' }, + Tablet: { width: 560, viewport: '768px' }, + Mobile: { width: 375, viewport: '375px' }, +}; + +/** A surface drawn at one real viewport width, so density is comparable. */ +export const Device = ({ + name, + children, +}: { + name: DeviceName; + children: React.ReactNode; +}) => ( +
+ + {name} ยท {DEVICES[name].viewport} + +
+ {children} +
+
+); + +/** Devices sit in a scroller rather than wrapping, so widths stay honest. */ +export const Rail = ({ children }: { children: React.ReactNode }) => ( +
+ {children} +
+); + +export const Variant = ({ + step, + headline, + note, + children, +}: { + step: string; + headline: string; + note: string; + children: React.ReactNode; +}) => ( + // Full width so a device rail can scroll across the whole canvas. +
+
+ + {step} + + + {headline} + + {note} +
+ {children} +
+); + +export const Category = ({ + title, + covers, + verdict, + children, +}: { + title: string; + covers: string; + verdict: string; + children: React.ReactNode; +}) => ( +
+
+

{title}

+ {covers} +

+ {verdict} +

+
+
{children}
+
+); + +/** Every category page opens with the same header, so they read as a set. */ +export const SurfacePage = ({ + title, + intro, + map, + children, +}: { + title: string; + intro: string; + map: string; + children: React.ReactNode; +}) => ( +
+
+

{title}

+

{intro}

+ {map} +
+ {children} +
+); diff --git a/packages/storybook/stories/features/snapshot/surfaces/HotTakesAndHistory.stories.tsx b/packages/storybook/stories/features/snapshot/surfaces/HotTakesAndHistory.stories.tsx new file mode 100644 index 00000000000..abaa2f31895 --- /dev/null +++ b/packages/storybook/stories/features/snapshot/surfaces/HotTakesAndHistory.stories.tsx @@ -0,0 +1,334 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + DownvoteIcon, + HotIcon, + MenuIcon, + MiniCloseIcon, + ReputationIcon, + UpvoteIcon, +} from '@dailydotdev/shared/src/components/icons'; +import type { DeviceName } from '../surfaceChrome'; +import { + AVATAR, + Category, + Control, + Device, + Rail, + SurfacePage, + Variant, +} from '../surfaceChrome'; + +type ScreenProps = { device: DeviceName }; + +const DEVICE_ORDER: DeviceName[] = ['Desktop', 'Tablet', 'Mobile']; + +const Rails = ({ + screen: Screen, +}: { + screen: React.ComponentType; +}) => ( + + {DEVICE_ORDER.map((device) => ( + + ))} + +); + +/* --------------------------------------------------------- the swipe modal */ + +const REACTIONS = [ + { glyph: 'โ„๏ธ', label: 'Cold take - downvote', className: '!size-14' }, + { glyph: '๐Ÿ˜', label: 'Skip hot take', className: '!size-12' }, + { glyph: '๐Ÿ”ฅ', label: 'Hot take - upvote', className: '!size-14' }, +]; + +const HotTakeModalScreen = ({ device }: ScreenProps) => ( + +
+
+ + Hot Takes + +
+ +
+
+
+
+ ๐Ÿ˜ +
+ + Most developers have a talent for turning simple problems into + overengineered nightmares. + + + “Simplicity is prerequisite for reliability” - Edsger + W. Dijkstra + +
+ + + + 587 + + + +
+
+ +
+ +
+ + + James Davis + + + @jamesdavis7 + + + + + 11.4K + +
+
+
+
+ +
+ {REACTIONS.map(({ glyph, label, className }) => ( +
+ +
+ +
+
+
+); + +/* -------------------------------------------------------- the profile list */ + +const TAKES = [ + { + emoji: '๐Ÿ”ฅ', + title: 'Microservices were a mistake for most teams', + subtitle: 'Distributed systems are a tax, not a feature', + upvotes: 128, + }, + { + emoji: '๐ŸงŠ', + title: 'Code review is mostly theatre', + subtitle: 'Two approvals, forty seconds of reading', + upvotes: 64, + }, +]; + +const HotTakeRow = ({ take }: { take: (typeof TAKES)[number] }) => ( +
+
+ {take.emoji} +
+
+ + {take.title} + + {take.subtitle} +
+
+ + +
+
+); + +const HotTakeListScreen = ({ device }: ScreenProps) => ( + +
+ Hot takes + {TAKES.map((take) => ( + + ))} +
+
+); + +/* -------------------------------------------------------- reading history */ + +const HISTORY = [ + 'Why iconic tech brands lost their dominance', + 'The case against microservices', + 'Postgres is all you need, again', +]; + +const HistoryRow = ({ + title, + device, +}: { + title: string; + device: DeviceName; +}) => ( +
+
+
+ +
+
+

+ {title} +

+ + 4 min read ยท 128 upvotes + +
+
+ {device === 'Desktop' && ( + <> +
+
+); + +const HistoryScreen = ({ device }: ScreenProps) => ( + +
+ + Reading history + + {HISTORY.map((title) => ( + + ))} +
+
+); + +/* -------------------------------------------------------------------- page */ + +const HotTakesAndHistory = () => ( + + + + + + + + + + + + + + + + + + + +); + +const meta: Meta = { + title: 'Features/Snapshot/Surfaces/Hot takes & history', + component: HotTakesAndHistory, + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +export const Variations: StoryObj = {};