From f78e0c6ba9d9d0eb8f0c98b2322b1fde208106ab Mon Sep 17 00:00:00 2001 From: rebelchris Date: Wed, 29 Apr 2026 08:23:12 +0000 Subject: [PATCH 1/2] feat(extension): add awards to companion menu --- .../src/companion/CompanionMenu.spec.tsx | 205 ++++++++++++++++++ .../extension/src/companion/CompanionMenu.tsx | 122 +++++++++-- packages/shared/src/lib/boot.ts | 2 + 3 files changed, 316 insertions(+), 13 deletions(-) create mode 100644 packages/extension/src/companion/CompanionMenu.spec.tsx diff --git a/packages/extension/src/companion/CompanionMenu.spec.tsx b/packages/extension/src/companion/CompanionMenu.spec.tsx new file mode 100644 index 00000000000..9d92bfcd157 --- /dev/null +++ b/packages/extension/src/companion/CompanionMenu.spec.tsx @@ -0,0 +1,205 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { QueryClient } from '@tanstack/react-query'; +import { fireEvent, render, screen } from '@testing-library/react'; +import defaultUser from '@dailydotdev/shared/__tests__/fixture/loggedUser'; +import { TestBootProvider } from '@dailydotdev/shared/__tests__/helpers/boot'; +import { UserVote } from '@dailydotdev/shared/src/graphql/posts'; +import { SourceType } from '@dailydotdev/shared/src/graphql/sources'; +import { LazyModal } from '@dailydotdev/shared/src/components/modals/common/types'; +import type { PostBootData } from '@dailydotdev/shared/src/lib/boot'; +import { CoresRole } from '@dailydotdev/shared/src/lib/user'; +import { useLazyModal } from '@dailydotdev/shared/src/hooks/useLazyModal'; +import CompanionMenu from './CompanionMenu'; + +jest.mock('react-modal', () => ({ + setAppElement: jest.fn(), +})); + +jest.mock('@dailydotdev/shared/src/hooks/usePrompt', () => ({ + usePrompt: () => ({ + showPrompt: jest.fn(), + }), +})); + +jest.mock('./useCompanionActions', () => ({ + __esModule: true, + default: () => ({ + bookmark: jest.fn(), + removeBookmark: jest.fn(), + blockSource: jest.fn(), + disableCompanion: jest.fn(), + removeCompanionHelper: jest.fn(), + toggleCompanionExpanded: jest.fn(), + }), +})); + +jest.mock('@dailydotdev/shared/src/hooks', () => ({ + useToastNotification: () => ({ + displayToast: jest.fn(), + }), + useVotePost: () => ({ + toggleUpvote: jest.fn(), + toggleDownvote: jest.fn(), + }), +})); + +jest.mock('@dailydotdev/shared/src/hooks/useSharePost', () => ({ + useSharePost: () => ({ + openSharePost: jest.fn(), + }), +})); + +jest.mock('@dailydotdev/shared/src/hooks/useLazyModal', () => ({ + useLazyModal: jest.fn(), +})); + +const mockOpenModal = jest.fn(); + +const defaultPost: PostBootData = { + id: 'post-1', + title: 'Test post', + commentsPermalink: 'https://app.daily.dev/posts/post-1', + trending: 1, + summary: 'summary', + numUpvotes: 3, + numComments: 2, + bookmarked: false, + source: { + id: 'source-1', + name: 'Test source', + image: 'https://daily.dev/source.png', + type: SourceType.Machine, + } as PostBootData['source'], + image: 'https://daily.dev/post.png', + createdAt: new Date().toISOString(), + readTime: 4, + tags: ['react'], + permalink: 'https://app.daily.dev/posts/post-1', + author: { + id: 'author-1', + username: 'author', + name: 'Author', + image: 'https://daily.dev/author.png', + permalink: 'https://app.daily.dev/author', + coresRole: CoresRole.Creator, + } as unknown as PostBootData['author'], + commented: false, + type: 'article' as PostBootData['type'], + flags: { + banned: false, + deleted: false, + private: false, + visible: true, + showOnFeed: true, + promoteToPublic: 0, + campaignId: null, + sentAnalyticsReport: false, + }, + userState: { + vote: UserVote.None, + awarded: false, + }, + numAwards: 0, + featuredAward: undefined, +}; + +const renderComponent = ( + post: Partial = {}, + authUser = { + ...defaultUser, + coresRole: CoresRole.User, + }, +) => { + const client = new QueryClient(); + jest.mocked(useLazyModal).mockReturnValue({ + modal: null, + closeModal: jest.fn(), + openModal: mockOpenModal, + } as unknown as ReturnType); + + return render( + + + , + ); +}; + +describe('CompanionMenu', () => { + beforeEach(() => { + mockOpenModal.mockReset(); + }); + + it('renders the award button when the viewer can award the author', async () => { + renderComponent(); + + expect(await screen.findByLabelText('Award this post')).toBeInTheDocument(); + }); + + it('hides the award button when the viewer cannot award the author', () => { + renderComponent({}, { ...defaultUser, coresRole: CoresRole.None }); + + expect(screen.queryByLabelText('Award this post')).not.toBeInTheDocument(); + }); + + it('opens the give award modal when clicking the award button', async () => { + renderComponent(); + + fireEvent.click(await screen.findByLabelText('Award this post')); + + expect(mockOpenModal).toHaveBeenCalledWith({ + type: LazyModal.GiveAward, + props: { + type: 'POST', + entity: { + id: defaultPost.id, + receiver: defaultPost.author, + numAwards: defaultPost.numAwards, + }, + post: defaultPost, + }, + }); + }); + + it('opens the awards list when the post was already awarded', async () => { + renderComponent({ + userState: { + vote: UserVote.None, + awarded: true, + }, + numAwards: 1, + featuredAward: { + award: { + name: 'Gold', + image: 'https://daily.dev/gold.png', + value: 100, + }, + }, + }); + + fireEvent.click( + await screen.findByLabelText('You already awarded this post!'), + ); + + expect(mockOpenModal).toHaveBeenCalledWith({ + type: LazyModal.ListAwards, + props: { + queryProps: { + id: defaultPost.id, + type: 'POST', + }, + }, + }); + }); +}); diff --git a/packages/extension/src/companion/CompanionMenu.tsx b/packages/extension/src/companion/CompanionMenu.tsx index 48711e5351e..ed4a7c2670f 100644 --- a/packages/extension/src/companion/CompanionMenu.tsx +++ b/packages/extension/src/companion/CompanionMenu.tsx @@ -1,4 +1,4 @@ -import type { ReactElement } from 'react'; +import type { Dispatch, ReactElement, SetStateAction } from 'react'; import React, { useContext, useEffect, useRef, useState } from 'react'; import classNames from 'classnames'; import { @@ -13,6 +13,7 @@ import { EyeIcon, FeedbackIcon, FlagIcon, + MedalBadgeIcon, MenuIcon, ShareIcon, UpvoteIcon, @@ -55,6 +56,12 @@ import { usePrompt } from '@dailydotdev/shared/src/hooks/usePrompt'; import type { ReportedCallback } from '@dailydotdev/shared/src/components/modals'; import { ReportPostModal } from '@dailydotdev/shared/src/components/modals'; import { labels } from '@dailydotdev/shared/src/lib'; +import { Image } from '@dailydotdev/shared/src/components/image/Image'; +import { useCanAwardUser } from '@dailydotdev/shared/src/hooks/useCoresFeature'; +import type { LoggedUser } from '@dailydotdev/shared/src/lib/user'; +import GiveAwardModal from '@dailydotdev/shared/src/components/modals/award/GiveAwardModal'; +import type { ListAwardsModalProps } from '@dailydotdev/shared/src/components/modals/award/ListAwardsModal'; +import { ListAwardsModal } from '@dailydotdev/shared/src/components/modals/award/ListAwardsModal'; import useCompanionActions from './useCompanionActions'; import CompanionToggle from './CompanionToggle'; @@ -65,10 +72,10 @@ if (!isTesting) { type CompanionMenuProps = { post: PostBootData; companionHelper: boolean; - setPost: (T) => void; + setPost: Dispatch>; companionState: boolean; onOptOut: () => void; - setCompanionState: (T) => void; + setCompanionState: Dispatch>; verticalPosition: number; setVerticalPosition: (position: number) => void; isDragging: boolean; @@ -76,6 +83,9 @@ type CompanionMenuProps = { onOpenComments?: () => void; }; +const getCompanionModalParent = (): HTMLElement => + getCompanionWrapper() ?? document.body; + export default function CompanionMenu({ post, companionHelper, @@ -88,19 +98,30 @@ export default function CompanionMenu({ isDragging, setIsDragging, }: CompanionMenuProps): ReactElement { - const { modal, closeModal } = useLazyModal(); + const { modal, closeModal, openModal } = useLazyModal(); const { logEvent } = useLogContext(); const { user } = useContext(AuthContext); const { showPrompt } = usePrompt(); const [reportModal, setReportModal] = useState(); const { displayToast } = useToastNotification(); const dragStartRef = useRef({ y: 0, initialY: 0 }); + const author = post.author as LoggedUser | undefined; + const canAward = useCanAwardUser({ + sendingUser: user, + receivingUser: author, + }); const [showCompanionHelper, setShowCompanionHelper] = usePersistentContext( 'companion_helper', companionHelper, ); - const updatePost = async ({ update, event }) => { + const updatePost = async ({ + update, + event, + }: { + update: Partial; + event: LogEvent; + }) => { const oldPost = post; setPost({ ...post, @@ -163,6 +184,35 @@ export default function CompanionMenu({ const onShare = () => openSharePost({ post }); + const onAward = () => { + if (!author) { + return; + } + + if (post.userState?.awarded) { + openModal({ + type: LazyModal.ListAwards, + props: { + queryProps: { id: post.id, type: 'POST' }, + }, + }); + return; + } + + openModal({ + type: LazyModal.GiveAward, + props: { + type: 'POST', + entity: { + id: post.id, + receiver: author, + numAwards: post.numAwards, + }, + post, + }, + }); + }; + const optOut = async () => { const options: PromptOptions = { title: 'Disable the companion widget?', @@ -254,8 +304,9 @@ export default function CompanionMenu({ @@ -422,6 +473,35 @@ export default function CompanionMenu({ icon={} /> + {canAward && ( + +