diff --git a/packages/shared/src/components/CalendarHeatmap.tsx b/packages/shared/src/components/CalendarHeatmap.tsx index 1e8a33353b5..b1aac02b2f1 100644 --- a/packages/shared/src/components/CalendarHeatmap.tsx +++ b/packages/shared/src/components/CalendarHeatmap.tsx @@ -51,7 +51,7 @@ function getRange(count: number): number[] { return Array.from(new Array(Math.max(0, count)), (_, i) => i); } -function getBins(values: number[]): number[] { +export function getBins(values: number[]): number[] { const uniques = Array.from(new Set(values)).sort((a, b) => a - b); if (uniques.length <= BINS) { return [ @@ -66,7 +66,7 @@ function getBins(values: number[]): number[] { ); } -function getBin(value: number, bins: number[]): number { +export function getBin(value: number, bins: number[]): number { if (!value) { return 0; } diff --git a/packages/shared/src/components/modals/achievement/sortAchievements.spec.ts b/packages/shared/src/components/modals/achievement/sortAchievements.spec.ts index 41fc7875730..b3559b8ab42 100644 --- a/packages/shared/src/components/modals/achievement/sortAchievements.spec.ts +++ b/packages/shared/src/components/modals/achievement/sortAchievements.spec.ts @@ -1,6 +1,9 @@ import { AchievementType } from '../../../graphql/user/achievements'; import type { UserAchievement } from '../../../graphql/user/achievements'; -import { sortLockedAchievements } from './sortAchievements'; +import { + sortLockedAchievements, + sortRarestUnlockedAchievements, +} from './sortAchievements'; const createAchievement = ({ id, @@ -71,3 +74,79 @@ describe('sortLockedAchievements', () => { ]); }); }); + +describe('sortRarestUnlockedAchievements', () => { + const unlocked = ({ + id, + rarity, + points = 10, + unlockedAt = '2026-01-01T00:00:00.000Z', + }: { + id: string; + rarity: number | null; + points?: number; + unlockedAt?: string; + }): UserAchievement => { + const base = createAchievement({ + id, + progress: 1, + targetCount: 1, + points, + unlockedAt, + }); + + return { ...base, achievement: { ...base.achievement, rarity } }; + }; + + it('drops the locked ones', () => { + const result = sortRarestUnlockedAchievements([ + createAchievement({ + id: 'locked', + progress: 0, + targetCount: 5, + points: 1, + }), + unlocked({ id: 'earned', rarity: 20 }), + ]); + + expect(result.map((a) => a.achievement.id)).toEqual(['earned']); + }); + + it('puts the rarest first, and an unknown rarity last', () => { + const result = sortRarestUnlockedAchievements([ + unlocked({ id: 'common', rarity: 40 }), + unlocked({ id: 'unknown', rarity: null }), + unlocked({ id: 'rarest', rarity: 1 }), + ]); + + expect(result.map((a) => a.achievement.id)).toEqual([ + 'rarest', + 'common', + 'unknown', + ]); + }); + + it('breaks a rarity tie on points, then on the more recent unlock', () => { + const result = sortRarestUnlockedAchievements([ + unlocked({ + id: 'older', + rarity: 5, + points: 50, + unlockedAt: '2026-01-01T00:00:00.000Z', + }), + unlocked({ id: 'fewer-points', rarity: 5, points: 10 }), + unlocked({ + id: 'newer', + rarity: 5, + points: 50, + unlockedAt: '2026-06-01T00:00:00.000Z', + }), + ]); + + expect(result.map((a) => a.achievement.id)).toEqual([ + 'newer', + 'older', + 'fewer-points', + ]); + }); +}); diff --git a/packages/shared/src/components/modals/achievement/sortAchievements.ts b/packages/shared/src/components/modals/achievement/sortAchievements.ts index 430b392f7e8..827b2df7a1d 100644 --- a/packages/shared/src/components/modals/achievement/sortAchievements.ts +++ b/packages/shared/src/components/modals/achievement/sortAchievements.ts @@ -29,3 +29,34 @@ export const sortLockedAchievements = ( return b.achievement.points - a.achievement.points; }); }; + +/** + * Rarest first, so the profile widget and the share card can never disagree + * about which achievements are the ones worth showing. + */ +export const sortRarestUnlockedAchievements = ( + achievements: UserAchievement[], +): UserAchievement[] => { + return achievements + .filter((achievement) => achievement.unlockedAt !== null) + .sort((a, b) => { + const rarityA = a.achievement.rarity ?? Infinity; + const rarityB = b.achievement.rarity ?? Infinity; + if (rarityA !== rarityB) { + return rarityA - rarityB; + } + + const pointsDelta = b.achievement.points - a.achievement.points; + if (pointsDelta !== 0) { + return pointsDelta; + } + + const unlockedDateA = a.unlockedAt ? new Date(a.unlockedAt).getTime() : 0; + const unlockedDateB = b.unlockedAt ? new Date(b.unlockedAt).getTime() : 0; + if (unlockedDateA !== unlockedDateB) { + return unlockedDateB - unlockedDateA; + } + + return a.achievement.id.localeCompare(b.achievement.id); + }); +}; diff --git a/packages/shared/src/components/profile/ProfileHeader.tsx b/packages/shared/src/components/profile/ProfileHeader.tsx index 3061b34c3bf..78c31170415 100644 --- a/packages/shared/src/components/profile/ProfileHeader.tsx +++ b/packages/shared/src/components/profile/ProfileHeader.tsx @@ -1,20 +1,23 @@ -import type { ReactNode } from 'react'; -import React from 'react'; +import type { ReactElement, ReactNode } from 'react'; +import React, { forwardRef } from 'react'; import dynamic from 'next/dynamic'; +import { format } from 'date-fns'; import classNames from 'classnames'; +import { useQuery } from '@tanstack/react-query'; import { Image } from '../image/Image'; import { Typography, 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 +27,16 @@ import { locationToString } from '../../lib/utils'; import { IconSize } from '../Icon'; import { fallbackImages } from '../../lib/config'; import { ProfileDesktopPwaBackButton } from './ProfileBackButton'; +import { ProfileSnapshotButton } from '../../features/snapshot/ProfileSnapshotButton'; +import { ProfileSnapshotCard } from '../../features/snapshot/ProfileSnapshotCard'; +import { devCardQueryOptions } from '../../hooks/profile/useDevCard'; +import { Tooltip } from '../tooltip/Tooltip'; +import { useCopyLink } from '../../hooks/useCopy'; +import { useGetShortUrl } from '../../hooks/utils/useGetShortUrl'; +import { useLogContext } from '../../contexts/LogContext'; +import { LogEvent, Origin, TargetType } from '../../lib/log'; +import { ShareProvider } from '../../lib/share'; +import { ReferralCampaignKey } from '../../lib/referral'; import { ElementPlaceholder } from '../ElementPlaceholder'; @@ -46,6 +59,32 @@ const ProfileActions = dynamic( }, ); +const ProfileCard = forwardRef( + function ProfileCard({ user }, ref): ReactElement { + // The lifetime count the DevCard shows. Only an armed card mounts this, so + // a profile view does not fetch it. + const { data: devCard } = useQuery( + devCardQueryOptions({ userId: user.id }), + ); + const handle = user.username ?? user.id; + + return ( + + ); + }, +); + type ProfileHeaderProps = { user: PublicProfile; /** Optional for the same reason the profile's static props are: the counts @@ -67,6 +106,25 @@ const ProfileHeader = ({ const { name, username, bio, image, cover, isPlus } = user; const { user: loggedUser } = useAuthContext(); const isSameUser = propIsSameUser ?? loggedUser?.id === user.id; + const { logEvent } = useLogContext(); + const [isCopying, copyLink] = useCopyLink(); + const { getTrackedUrl } = useGetShortUrl(); + + const onCopyLink = () => { + logEvent({ + event_name: LogEvent.ShareProfile, + target_type: TargetType.ProfilePage, + target_id: user.id, + extra: JSON.stringify({ + provider: ShareProvider.CopyLink, + origin: Origin.ProfileHeader, + }), + }); + copyLink({ + link: getTrackedUrl(user.permalink, ReferralCampaignKey.ShareProfile), + shorten: true, + }); + }; return (
@@ -100,6 +158,24 @@ const ProfileHeader = ({ aria-label="Edit profile" /> + } + // Matches the edit button beside it, which takes Button's default. + size={ButtonSize.Medium} + variant={ButtonVariant.Float} + /> + +
diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.spec.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.spec.tsx index f3b4ad2bd77..e219905c800 100644 --- a/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.spec.tsx +++ b/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.spec.tsx @@ -142,5 +142,32 @@ describe('AchievementsWidget', () => { .filter((alt): alt is string => expectedVisibleNames.includes(alt ?? '')); expect(renderedNames).toEqual(expectedVisibleNames); + expect(screen.getByLabelText('Snapshot')).toBeInTheDocument(); + }); + + it('should not offer a snapshot before anything is unlocked', () => { + mockUseProfileAchievements.mockReturnValue({ + achievements: [ + createUserAchievement({ + id: 'locked', + name: 'Locked', + rarity: 1, + points: 100, + unlockedAt: null, + }), + ], + unlockedCount: 0, + totalCount: 1, + totalPoints: 0, + isPending: false, + isError: false, + }); + + renderComponent(); + + expect( + screen.getByText('No achievements unlocked yet'), + ).toBeInTheDocument(); + expect(screen.queryByLabelText('Snapshot')).not.toBeInTheDocument(); }); }); diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.tsx index 2e246ff8c46..61dc5d257ae 100644 --- a/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.tsx +++ b/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.tsx @@ -21,6 +21,10 @@ import { import { RaritySparkles } from '../achievements/RaritySparkles'; import HoverCard from '../../../../components/cards/common/HoverCard'; import { AchievementCard } from '../achievements/AchievementCard'; +import { AchievementsSnapshotCard } from '../../../snapshot/AchievementsSnapshotCard'; +import { sortRarestUnlockedAchievements } from '../../../../components/modals/achievement/sortAchievements'; +import { ProfileSnapshotButton } from '../../../snapshot/ProfileSnapshotButton'; +import { Origin } from '../../../../lib/log'; interface AchievementsWidgetProps { user: PublicProfile; @@ -47,28 +51,8 @@ function RecentAchievements({ const { achievements, isPending } = useProfileAchievements(user); const rarestUnlocked = achievements - ?.filter((a) => a.unlockedAt !== null) - .sort((a, b) => { - const rarityA = a.achievement.rarity ?? Infinity; - const rarityB = b.achievement.rarity ?? Infinity; - if (rarityA !== rarityB) { - return rarityA - rarityB; - } - - const pointsDelta = b.achievement.points - a.achievement.points; - if (pointsDelta !== 0) { - return pointsDelta; - } - - const unlockedDateA = a.unlockedAt ? new Date(a.unlockedAt).getTime() : 0; - const unlockedDateB = b.unlockedAt ? new Date(b.unlockedAt).getTime() : 0; - if (unlockedDateA !== unlockedDateB) { - return unlockedDateB - unlockedDateA; - } - - return a.achievement.id.localeCompare(b.achievement.id); - }) - .slice(0, 5); + ? sortRarestUnlockedAchievements(achievements).slice(0, 5) + : undefined; if (isPending) { return ; @@ -110,7 +94,7 @@ function RecentAchievements({ } >
- +
); @@ -133,7 +117,8 @@ function RecentAchievements({ export function AchievementsWidget({ user, }: AchievementsWidgetProps): ReactElement { - const { unlockedCount, totalCount } = useProfileAchievements(user); + const { achievements, unlockedCount, totalCount, totalPoints } = + useProfileAchievements(user); return ( @@ -148,11 +133,42 @@ export function AchievementsWidget({ Achievements - - - {unlockedCount}/{totalCount} - - +
+ + + {unlockedCount}/{totalCount} + + + {unlockedCount > 0 && ( + ( + ({ + image: achievement.image, + name: achievement.name, + }))} + points={totalPoints} + ref={ref} + seed={user.username ?? user.id} + total={totalCount} + unlocked={unlockedCount} + user={{ + handle: `@${user.username ?? user.id}`, + image: user.image, + name: user.name, + }} + /> + )} + /> + )} +
diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.spec.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.spec.tsx index c7b6a0e135e..d62b5fed301 100644 --- a/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.spec.tsx +++ b/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.spec.tsx @@ -184,6 +184,8 @@ describe('BadgesAndAwards component', () => { // Should not show any badge or award items expect(screen.queryByRole('list')).not.toBeInTheDocument(); + // Nor offer an image of two zeros + expect(screen.queryByLabelText('Snapshot')).not.toBeInTheDocument(); }); it('should render top reader badges when available', async () => { @@ -206,6 +208,7 @@ describe('BadgesAndAwards component', () => { // Check badge items expect(screen.getByText('JavaScript')).toBeInTheDocument(); expect(screen.getByText('React')).toBeInTheDocument(); + expect(screen.getByLabelText('Snapshot')).toBeInTheDocument(); }); it('should render awards when user has cores access', async () => { diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.tsx index 6d800232861..3547f9a79fa 100644 --- a/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.tsx +++ b/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.tsx @@ -24,6 +24,10 @@ import { BadgesAndAwardsSkeleton, } from './BadgesAndAwardsComponents'; import { anchorDefaultRel } from '../../../../lib/strings'; +import { ProfileSnapshotButton } from '../../../snapshot/ProfileSnapshotButton'; +import { BadgesSnapshotCard } from '../../../snapshot/BadgesSnapshotCard'; +import { formatDate, TimeFormatType } from '../../../../lib/dateFormat'; +import { Origin } from '../../../../lib/log'; export const BadgesAndAwards = ({ user, @@ -60,18 +64,57 @@ export const BadgesAndAwards = ({ const totalAwards = awards?.reduce((sum, award) => sum + (award?.count || 0), 0) ?? 0; + const topReaderBadges = topReaders?.[0]?.total ?? 0; return ( - - Badges & Awards - +
+ + Badges & Awards + + {(topReaderBadges > 0 || totalAwards > 0) && ( + ( + ({ + count: award.count, + image: award.image, + name: award.name, + })) ?? [] + } + badges={ + topReaders?.map((badge) => ({ + earnedAt: formatDate({ + value: badge.issuedAt, + type: TimeFormatType.TopReaderBadge, + }), + keyword: badge.keyword.flags?.title || badge.keyword.value, + })) ?? [] + } + ref={ref} + seed={user.username ?? user.id} + topReaderBadges={topReaderBadges} + totalAwards={totalAwards} + user={{ + handle: `@${user.username ?? user.id}`, + image: user.image, + name: user.name, + }} + /> + )} + /> + )} +
- +
diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/ProfileWidgets.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/ProfileWidgets.tsx index 8606f11a609..f7e04c9311f 100644 --- a/packages/shared/src/features/profile/components/ProfileWidgets/ProfileWidgets.tsx +++ b/packages/shared/src/features/profile/components/ProfileWidgets/ProfileWidgets.tsx @@ -2,15 +2,15 @@ import type { ReactElement } from 'react'; import React from 'react'; import classNames from 'classnames'; import { useQuery } from '@tanstack/react-query'; -import { startOfTomorrow, subDays, subMonths } from 'date-fns'; import dynamic from 'next/dynamic'; import { useAuthContext } from '../../../../contexts/AuthContext'; import { useSettingsContext } from '../../../../contexts/SettingsContext'; import { ActiveOrRecomendedSquads } from './ActiveOrRecomendedSquads'; -import type { ProfileReadingData, ProfileV2 } from '../../../../graphql/users'; -import { USER_READING_HISTORY_QUERY } from '../../../../graphql/users'; -import { generateQueryKey, RequestKey } from '../../../../lib/query'; -import { gqlClient } from '../../../../graphql/common'; +import type { ProfileV2 } from '../../../../graphql/users'; +import { + getProfileReadingWindow, + profileReadingHistoryQueryOptions, +} from '../../../../graphql/users'; import { canViewUserProfileAnalytics } from '../../../../lib/user'; import { ReadingOverview } from './ReadingOverview'; import { ProfileCompletion } from './ProfileCompletion'; @@ -96,25 +96,10 @@ export function ProfileWidgets({ !isAchievementsPending && shouldRenderTrackingWidget; - const before = startOfTomorrow(); - const after = subMonths(subDays(before, 2), 5); - - const { data: readingHistory, isLoading: isReadingHistoryLoading } = - useQuery({ - queryKey: generateQueryKey(RequestKey.ReadingStats, user), - queryFn: () => - gqlClient.request(USER_READING_HISTORY_QUERY, { - id: user?.id, - before, - after, - version: 2, - limit: 6, - }), - enabled: !!user && tokenRefreshed && !!before && !!after, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - refetchOnMount: false, - }); + const { before, after } = getProfileReadingWindow(); + const { data: readingHistory, isLoading: isReadingHistoryLoading } = useQuery( + profileReadingHistoryQueryOptions({ user, enabled: tokenRefreshed }), + ); const squads = sources?.edges?.map((s) => s.node.source) ?? []; return ( @@ -147,6 +132,7 @@ export function ProfileWidgets({ profileUserId: user.id, }) && } { expect(screen.getByText('react')).toBeInTheDocument(); expect(screen.getByText('+60%')).toBeInTheDocument(); // javascript percentage expect(screen.getByText('+40%')).toBeInTheDocument(); // react percentage + expect(screen.getByLabelText('Snapshot')).toBeInTheDocument(); + }); + + it('should not offer a snapshot when there is no reading to show', () => { + renderComponent({ + readHistory: [], + streak: { ...mockStreak, max: 0, total: 0, current: 0 }, + mostReadTags: [], + }); + + expect(screen.getByText('Reading Overview')).toBeInTheDocument(); + expect(screen.queryByLabelText('Snapshot')).not.toBeInTheDocument(); + }); + + it('should offer a snapshot for a streak with no reads in the window', () => { + renderComponent({ readHistory: [], mostReadTags: [] }); + + expect(screen.getByLabelText('Snapshot')).toBeInTheDocument(); }); it('should render the keyword title once it is available', async () => { diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverview.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverview.tsx index b2801030e6a..1a0672891f6 100644 --- a/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverview.tsx +++ b/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverview.tsx @@ -1,12 +1,18 @@ import type { ReactElement, ReactNode } from 'react'; -import React, { useMemo } from 'react'; +import React, { forwardRef } from 'react'; +import { useQuery } from '@tanstack/react-query'; import type { UserReadHistory, UserStreak, MostReadTag, } from '../../../../graphql/users'; +import { sumReadHistory } from '../../../../graphql/users'; import { ActivityContainer } from '../../../../components/profile/ActivitySection'; -import { CalendarHeatmap } from '../../../../components/CalendarHeatmap'; +import { + CalendarHeatmap, + getBin, + getBins, +} from '../../../../components/CalendarHeatmap'; import { migrateUserToStreaks } from '../../../../lib/constants'; import { ClickableText } from '../../../../components/buttons/ClickableText'; import { @@ -22,7 +28,15 @@ import { ReadingOverviewSkeleton, } from './ReadingOverviewComponents'; import { anchorDefaultRel, pluralize } from '../../../../lib/strings'; -import { largeNumberFormat } from '../../../../lib'; +import { largeNumberFormat } from '../../../../lib/numberFormat'; +import { ReadingOverviewSnapshotCard } from '../../../snapshot/ReadingOverviewSnapshotCard'; +import { ProfileSnapshotButton } from '../../../snapshot/ProfileSnapshotButton'; +import { tagTitlesQueryOptions } from '../../../../graphql/keywords'; +import type { PublicProfile } from '../../../../lib/user'; +import { Origin } from '../../../../lib/log'; + +/** ReadingOverviewSnapshotCard's heatmap grid: four rows of twenty-two. */ +const SNAPSHOT_HEATMAP_CELLS = 88; // Utility functions const readHistoryToValue = (value: UserReadHistory): number => value.reads; @@ -50,6 +64,7 @@ const readHistoryToTooltip = ( }; export interface ReadingOverviewProps { + user: PublicProfile; readHistory?: UserReadHistory[]; before: Date; after: Date; @@ -58,7 +73,60 @@ export interface ReadingOverviewProps { isLoading?: boolean; } +type ReadingOverviewCardProps = Omit; + +const ReadingOverviewCard = forwardRef< + HTMLDivElement, + ReadingOverviewCardProps +>(function ReadingOverviewCard( + { user, readHistory, before, after, streak, mostReadTags }, + ref, +): ReactElement { + const { data: tagTitles = {} } = useQuery(tagTitlesQueryOptions()); + + // The card draws one cell per bucket and stops at its grid, so the window + // is compressed into that many buckets rather than handed a day each: a + // day per cell would show the oldest weeks and drop everything since. + const start = after.getTime(); + const span = Math.max(1, before.getTime() - start); + const buckets = new Array(SNAPSHOT_HEATMAP_CELLS).fill(0); + + readHistory?.forEach((entry) => { + const offset = (new Date(entry.date).getTime() - start) / span; + const cell = Math.floor(offset * SNAPSHOT_HEATMAP_CELLS); + + buckets[Math.min(SNAPSHOT_HEATMAP_CELLS - 1, Math.max(0, cell))] += + readHistoryToValue(entry); + }); + + const bins = getBins(buckets); + + return ( + getBin(reads, bins))} + longestStreak={streak?.max} + monthsLabel="in the last months" + postsRead={sumReadHistory(readHistory)} + ref={ref} + seed={user.username ?? user.id} + topTags={ + mostReadTags?.map((tag) => ({ + name: tagTitles[tag.value] || tag.value, + percentage: Math.round((tag.percentage ?? 0) * 100), + })) ?? [] + } + totalReadingDays={streak?.total} + user={{ + handle: `@${user.username ?? user.id}`, + image: user.image, + name: user.name, + }} + /> + ); +}); + export function ReadingOverview({ + user, readHistory, before, after, @@ -66,15 +134,14 @@ export function ReadingOverview({ mostReadTags, isLoading = false, }: ReadingOverviewProps): ReactElement { - const totalReads = useMemo(() => { - if (!readHistory?.length) { - return 0; - } - return readHistory.reduce((acc, val) => { - const reads = val?.reads || 0; - return acc + (typeof reads === 'number' && reads >= 0 ? reads : 0); - }, 0); - }, [readHistory]); + const totalReads = sumReadHistory(readHistory); + // The card leaves out every section whose number is zero, so with no reads, + // no streak and no tags there would be nothing on it but the name. + const hasSnapshot = + totalReads > 0 || + !!streak?.max || + !!streak?.total || + !!mostReadTags?.length; if (isLoading) { return ; @@ -82,15 +149,35 @@ export function ReadingOverview({ return ( - - Reading Overview - +
+ + Reading Overview + + {hasSnapshot && ( + ( + + )} + /> + )} +
{ - - - - -); - -/* -------------------------------------------------------------------- page */ - -const Profile = () => ( - - - - - - - - - - - - - - - - - - - - - - - - - - - -); - -const meta: Meta = { - title: 'Features/Snapshot/Surfaces/Profile', - component: Profile, - parameters: { layout: 'fullscreen' }, -}; - -export default meta; - -export const Variations: StoryObj = {}; diff --git a/packages/webapp/__tests__/DevCardShare.spec.tsx b/packages/webapp/__tests__/DevCardShare.spec.tsx new file mode 100644 index 00000000000..3fa345ae85e --- /dev/null +++ b/packages/webapp/__tests__/DevCardShare.spec.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import { TestBootProvider } from '@dailydotdev/shared/__tests__/helpers/boot'; +import loggedUser from '@dailydotdev/shared/__tests__/fixture/loggedUser'; +import type { DevCardQueryData } from '@dailydotdev/shared/src/hooks/profile/useDevCard'; +import { DevCardTheme } from '@dailydotdev/shared/src/components/profile/devcard/common'; +import { + generateQueryKey, + RequestKey, +} from '@dailydotdev/shared/src/lib/query'; +import { LogEvent, Origin } from '@dailydotdev/shared/src/lib/log'; +import { ShareProvider } from '@dailydotdev/shared/src/lib/share'; +import { DevCardStep2 } from '../components/layouts/SettingsLayout/Customization/DevCard/DevCardStep2'; + +const writeText = jest.fn(); +const logEvent = jest.fn(); + +const devCard: DevCardQueryData = { + devCard: { + id: 'dc1', + user: { ...loggedUser, premium: false, reputation: 10 }, + createdAt: '2024-01-01T00:00:00.000Z', + theme: DevCardTheme.Default, + isProfileCover: false, + showBorder: true, + reputation: 10, + articlesRead: 3, + tags: [], + sources: [], + streak: { max: 1 }, + }, + userStreakProfile: { max: 1 }, +}; + +beforeEach(() => { + jest.clearAllMocks(); + writeText.mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); +}); + +it('copies the profile link with the share campaign on it', async () => { + const client = new QueryClient(); + client.setQueryData( + generateQueryKey(RequestKey.DevCard, { id: loggedUser.id }), + devCard, + ); + + render( + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Share' })); + + await waitFor(() => + expect(writeText).toHaveBeenCalledWith( + `${loggedUser.permalink}?userid=${loggedUser.id}&cid=share_profile`, + ), + ); + expect(logEvent).toHaveBeenCalledWith({ + event_name: LogEvent.ShareDevcard, + target_id: loggedUser.id, + extra: JSON.stringify({ + provider: ShareProvider.CopyLink, + origin: Origin.DevCard, + }), + }); +}); diff --git a/packages/webapp/components/layouts/SettingsLayout/Customization/DevCard/DevCardStep2.tsx b/packages/webapp/components/layouts/SettingsLayout/Customization/DevCard/DevCardStep2.tsx index f386d178e2e..7363219b003 100644 --- a/packages/webapp/components/layouts/SettingsLayout/Customization/DevCard/DevCardStep2.tsx +++ b/packages/webapp/components/layouts/SettingsLayout/Customization/DevCard/DevCardStep2.tsx @@ -15,14 +15,17 @@ import { useViewSize, ViewSize } from '@dailydotdev/shared/src/hooks'; import type { DevCardQueryData } from '@dailydotdev/shared/src/hooks/profile/useDevCard'; import { useDevCard } from '@dailydotdev/shared/src/hooks/profile/useDevCard'; import { useCopyLink } from '@dailydotdev/shared/src/hooks/useCopy'; +import { useGetShortUrl } from '@dailydotdev/shared/src/hooks/utils/useGetShortUrl'; import { downloadUrl } from '@dailydotdev/shared/src/lib/blob'; +import { ReferralCampaignKey } from '@dailydotdev/shared/src/lib/referral'; +import { ShareProvider } from '@dailydotdev/shared/src/lib/share'; import { generateQueryKey, RequestKey, } from '@dailydotdev/shared/src/lib/query'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; -import { LogEvent } from '@dailydotdev/shared/src/lib/log'; +import { LogEvent, Origin } from '@dailydotdev/shared/src/lib/log'; import { Button } from '@dailydotdev/shared/src/components/buttons/Button'; import { ClickableText } from '@dailydotdev/shared/src/components/buttons/ClickableText'; import { @@ -32,15 +35,20 @@ import { import { RadioItem } from '@dailydotdev/shared/src/components/fields/RadioItem'; import { IconSize } from '@dailydotdev/shared/src/components/Icon'; import { + DownloadIcon, GitHubIcon, OpenLinkIcon, + ShareIcon, TwitterIcon, } from '@dailydotdev/shared/src/components/icons'; import { DevCardFetchWrapper } from '@dailydotdev/shared/src/components/profile/devcard/DevCardFetchWrapper'; import { devCard } from '@dailydotdev/shared/src/lib/constants'; import { checkLowercaseEquality } from '@dailydotdev/shared/src/lib/strings'; import classNames from 'classnames'; -import { isNullOrUndefined } from '@dailydotdev/shared/src/lib/func'; +import { + isNullOrUndefined, + shouldUseNativeShare, +} from '@dailydotdev/shared/src/lib/func'; import { Switch } from '@dailydotdev/shared/src/components/fields/Switch'; import { Typography, @@ -90,6 +98,39 @@ export const DevCardStep2 = ({ [user?.name, user?.username, devCardSrc, type], ); const [copyingEmbed, copyEmbed] = useCopyLink(() => embedCode); + const [copyingProfileLink, copyProfileLink] = useCopyLink(); + const { getTrackedUrl } = useGetShortUrl(); + const onShareDevCard = async () => { + // The tracked link is known without a request, so both the share sheet + // and the clipboard get it inside the press; the copy swaps in the short + // link once it resolves. + const link = getTrackedUrl( + user?.permalink ?? '', + ReferralCampaignKey.ShareProfile, + ); + const logShare = (provider: ShareProvider) => + logEvent({ + event_name: LogEvent.ShareDevcard, + target_id: userId, + extra: JSON.stringify({ provider, origin: Origin.DevCard }), + }); + + if (shouldUseNativeShare()) { + try { + await navigator.share({ + text: `Check out my #DevCard on daily.dev\n${link}`, + }); + logShare(ShareProvider.Native); + } catch { + // Dismissing the sheet rejects too. + } + + return; + } + + logShare(ShareProvider.CopyLink); + copyProfileLink({ link, shorten: true }); + }; const [selectedTab, setSelectedTab] = useState(0); const { mutateAsync: onDownloadUrl, isPending: downloading } = useMutation({ mutationFn: downloadUrl, @@ -230,18 +271,29 @@ export const DevCardStep2 = ({ {!isNullOrUndefined(devcard) && ( - +
+ + +
)} diff --git a/packages/webapp/pages/game-center/index.tsx b/packages/webapp/pages/game-center/index.tsx index 6c0241f3c24..18e7c7f1b92 100644 --- a/packages/webapp/pages/game-center/index.tsx +++ b/packages/webapp/pages/game-center/index.tsx @@ -486,6 +486,7 @@ function GameCenterPage({