Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions PointerStar/ClientApp/src/components/GiphyVotingPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,21 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { GiphyVotingPanel } from './GiphyVotingPanel'
import { GIPHY_PAGE_SIZE, searchGiphy } from '../services/giphyApi'
import { getStoredFavoriteGifIds, setStoredFavoriteGifIds } from '../services/cookies'

vi.mock('../services/giphyApi', () => ({
GIPHY_PAGE_SIZE: 20,
searchGiphy: vi.fn(),
}))

vi.mock('../services/cookies', () => ({
getStoredFavoriteGifIds: vi.fn(() => []),
setStoredFavoriteGifIds: vi.fn(),
}))

const searchGiphyMock = vi.mocked(searchGiphy)
const getStoredFavoriteGifIdsMock = vi.mocked(getStoredFavoriteGifIds)
const setStoredFavoriteGifIdsMock = vi.mocked(setStoredFavoriteGifIds)

const createGif = (index: number) => ({
id: `gif-${index}`,
Expand All @@ -19,6 +27,9 @@ const createGif = (index: number) => ({
describe('GiphyVotingPanel', () => {
beforeEach(() => {
searchGiphyMock.mockReset()
getStoredFavoriteGifIdsMock.mockReset()
getStoredFavoriteGifIdsMock.mockReturnValue([])
setStoredFavoriteGifIdsMock.mockReset()
})

it('tracks the searched GIF query for recent chips', async () => {
Expand Down Expand Up @@ -111,4 +122,51 @@ describe('GiphyVotingPanel', () => {

expect(await screen.findByAltText('GIF 21')).toBeInTheDocument()
})

it('toggles GIF favorites from the search results', async () => {
searchGiphyMock.mockResolvedValue({
data: [createGif(1)],
pagination: {
count: 1,
offset: 0,
totalCount: 1,
},
})

render(<GiphyVotingPanel onVoteSubmit={vi.fn()} />)

fireEvent.change(screen.getByPlaceholderText('Search for a GIF...'), {
target: { value: 'cat' },
})

await waitFor(() => {
expect(searchGiphyMock).toHaveBeenCalledWith({
query: 'cat',
offset: 0,
})
}, { timeout: 3000 })

await screen.findByAltText('GIF 1')
const favoriteButton = await screen.findByRole('button', {
name: 'Add GIF 1 to favorites',
})
fireEvent.click(favoriteButton)

await waitFor(() => {
expect(setStoredFavoriteGifIdsMock).toHaveBeenLastCalledWith(['gif-1'])
})
})

it('shows and collapses the favorite GIF section', () => {
getStoredFavoriteGifIdsMock.mockReturnValue(['favorite-1'])

render(<GiphyVotingPanel onVoteSubmit={vi.fn()} />)

expect(screen.getByAltText('Favorite GIF favorite-1')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Hide' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Hide' }))
expect(screen.getByRole('button', { name: 'Show' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Show' }))
expect(screen.getByRole('button', { name: 'Hide' })).toBeInTheDocument()
})
})
141 changes: 140 additions & 1 deletion PointerStar/ClientApp/src/components/GiphyVotingPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Alert,
Box,
Button,
Collapse,
CircularProgress,
IconButton,
ImageList,
Expand All @@ -15,8 +16,13 @@ import {
useTheme,
} from '@mui/material'
import CloseIcon from '@mui/icons-material/Close'
import ExpandLessIcon from '@mui/icons-material/ExpandLess'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import FavoriteIcon from '@mui/icons-material/Favorite'
import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder'
import SearchIcon from '@mui/icons-material/Search'
import type { GiphyItem } from '../types/contracts'
import { getStoredFavoriteGifIds, setStoredFavoriteGifIds } from '../services/cookies'
import { GIPHY_PAGE_SIZE, searchGiphy } from '../services/giphyApi'

interface GiphyVotingPanelProps {
Expand Down Expand Up @@ -53,7 +59,22 @@ export const GiphyVotingPanel: React.FC<GiphyVotingPanelProps> = ({
const [activeQuery, setActiveQuery] = useState('')
const [nextOffset, setNextOffset] = useState(0)
const [hasMoreResults, setHasMoreResults] = useState(false)
const [favoriteGifIds, setFavoriteGifIds] = useState<string[]>(() => getStoredFavoriteGifIds())
const [areFavoritesExpanded, setAreFavoritesExpanded] = useState(true)
const requestIdRef = useRef(0)
const favoriteGifSet = useMemo(() => new Set(favoriteGifIds), [favoriteGifIds])
const favoriteGifs = useMemo(
() => favoriteGifIds.map((gifId) => ({
id: gifId,
title: `Favorite GIF ${gifId}`,
imageUrl: `https://media.giphy.com/media/${gifId}/giphy.gif`,
})),
[favoriteGifIds],
)

useEffect(() => {
setStoredFavoriteGifIds(favoriteGifIds)
}, [favoriteGifIds])

const handleSearch = useCallback(
async (query: string, offset = 0, append = false) => {
Expand Down Expand Up @@ -190,6 +211,14 @@ export const GiphyVotingPanel: React.FC<GiphyVotingPanelProps> = ({
void handleSearch(activeQuery, nextOffset, true)
}, [activeQuery, handleSearch, hasMoreResults, loading, loadingMore, nextOffset])

const handleToggleFavoriteGif = useCallback((gifId: string) => {
setFavoriteGifIds((currentIds) => (
currentIds.includes(gifId)
? currentIds.filter((currentId) => currentId !== gifId)
: [gifId, ...currentIds]
))
}, [])

const giphyColumns = isMdUp ? 4 : isSmUp ? 3 : 2

return (
Expand All @@ -208,6 +237,93 @@ export const GiphyVotingPanel: React.FC<GiphyVotingPanelProps> = ({
</Alert>
) : (
<>
<Paper sx={{ marginBottom: 2, padding: 1.5 }} variant="outlined">
<Box sx={{ alignItems: 'center', display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="subtitle2">Favorite GIFs ({favoriteGifIds.length})</Typography>
<Button
endIcon={areFavoritesExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
onClick={() => setAreFavoritesExpanded((expanded) => !expanded)}
size="small"
variant="text"
>
{areFavoritesExpanded ? 'Hide' : 'Show'}
</Button>
</Box>
<Collapse in={areFavoritesExpanded}>
{favoriteGifs.length > 0 ? (
<ImageList cols={giphyColumns} gap={8} sx={{ marginTop: 1 }} variant="masonry">
{favoriteGifs.map((gif) => (
<ImageListItem
aria-label={`Select GIF: ${gif.title}`}
aria-pressed={selectedId === gif.id}
key={`favorite-${gif.id}`}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
handleSelectGif(gif.id)
}
}}
sx={{
'&:focus-visible': {
outline: '2px solid',
outlineColor: 'primary.main',
outlineOffset: 2,
},
border: selectedId === gif.id ? '3px solid' : '1px solid',
borderColor: selectedId === gif.id ? 'primary.main' : 'divider',
cursor: 'pointer',
opacity: selectedId === gif.id ? 1 : 0.9,
position: 'relative',
transition: 'all 0.2s ease',
'&:hover': {
border: '3px solid',
borderColor: 'primary.main',
opacity: 1,
},
}}
onClick={() => handleSelectGif(gif.id)}
role="button"
tabIndex={0}
>
<IconButton
aria-label={`Remove ${gif.title} from favorites`}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
handleToggleFavoriteGif(gif.id)
}}
size="small"
sx={{
backgroundColor: 'rgba(0, 0, 0, 0.45)',
color: 'common.white',
position: 'absolute',
right: 4,
top: 4,
zIndex: 1,
'&:hover': {
backgroundColor: 'rgba(0, 0, 0, 0.6)',
},
}}
>
<FavoriteIcon fontSize="small" />
</IconButton>
<img
src={gif.imageUrl}
alt={gif.title}
loading="lazy"
style={{ cursor: 'pointer', display: 'block', width: '100%' }}
/>
</ImageListItem>
))}
</ImageList>
) : (
<Typography color="text.secondary" sx={{ marginTop: 1 }} variant="body2">
No favorite GIFs yet.
</Typography>
)}
</Collapse>
</Paper>

<Box sx={{ marginBottom: 2 }}>
<TextField
autoFocus
Expand Down Expand Up @@ -273,6 +389,7 @@ export const GiphyVotingPanel: React.FC<GiphyVotingPanelProps> = ({
opacity: selectedId === gif.id ? 1 : 0.8,
border: selectedId === gif.id ? '3px solid' : '1px solid',
borderColor: selectedId === gif.id ? 'primary.main' : 'divider',
position: 'relative',
transition: 'all 0.2s ease',
'&:hover': {
opacity: 1,
Expand All @@ -284,6 +401,28 @@ export const GiphyVotingPanel: React.FC<GiphyVotingPanelProps> = ({
role="button"
tabIndex={0}
>
<IconButton
aria-label={`${favoriteGifSet.has(gif.id) ? 'Remove' : 'Add'} ${gif.title} ${favoriteGifSet.has(gif.id) ? 'from' : 'to'} favorites`}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
handleToggleFavoriteGif(gif.id)
}}
size="small"
sx={{
backgroundColor: 'rgba(0, 0, 0, 0.45)',
color: favoriteGifSet.has(gif.id) ? 'error.light' : 'common.white',
position: 'absolute',
right: 4,
top: 4,
zIndex: 1,
'&:hover': {
backgroundColor: 'rgba(0, 0, 0, 0.6)',
},
}}
>
{favoriteGifSet.has(gif.id) ? <FavoriteIcon fontSize="small" /> : <FavoriteBorderIcon fontSize="small" />}
</IconButton>
<img
src={gif.imageUrl}
alt={gif.title}
Expand Down
27 changes: 27 additions & 0 deletions PointerStar/ClientApp/src/services/cookies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import { beforeEach, describe, expect, it } from 'vitest'

import {
acceptCookies,
getStoredFavoriteGifIds,
getStoredName,
getStoredRecentGifSearches,
hasCookieConsent,
hasUserRespondedToConsent,
rejectCookies,
setStoredFavoriteGifIds,
setStoredName,
setStoredRecentGifSearches,
} from './cookies'
Expand Down Expand Up @@ -72,4 +74,29 @@ describe('cookies', () => {

expect(getStoredRecentGifSearches()).toEqual([])
})

it('returns an empty array for favorite GIF IDs when nothing is stored', () => {
expect(getStoredFavoriteGifIds()).toEqual([])
})

it('blocks favorite GIF ID writes until consent is accepted', () => {
setStoredFavoriteGifIds(['gif-1'])

expect(getStoredFavoriteGifIds()).toEqual([])
})

it('persists and restores favorite GIF IDs after consent is accepted', () => {
acceptCookies()
setStoredFavoriteGifIds(['gif-1', 'gif-2'])

expect(getStoredFavoriteGifIds()).toEqual(['gif-1', 'gif-2'])
})

it('clears favorite GIF IDs when an empty array is stored', () => {
acceptCookies()
setStoredFavoriteGifIds(['gif-1'])
setStoredFavoriteGifIds([])

expect(getStoredFavoriteGifIds()).toEqual([])
})
})
23 changes: 23 additions & 0 deletions PointerStar/ClientApp/src/services/cookies.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const acceptedValue = 'accepted'
const consentCookieKey = 'CookieConsent'
const defaultExpirationDays = 30
const favoriteGifIdsKey = 'FavoriteGifIds'
const nameKey = 'Name'
const recentGifSearchesKey = 'RecentGifSearches'
const rejectedValue = 'rejected'
Expand Down Expand Up @@ -137,3 +138,25 @@ export function getStoredRecentGifSearches(): string[] {
export function setStoredRecentGifSearches(value: string[]) {
setCookieValue(recentGifSearchesKey, value.length > 0 ? JSON.stringify(value) : '')
}

export function getStoredFavoriteGifIds(): string[] {
const value = getCookieValue(favoriteGifIdsKey)
if (!value) {
return []
}

try {
const parsed = JSON.parse(value)
if (Array.isArray(parsed) && parsed.every((entry) => typeof entry === 'string')) {
return parsed
}
} catch (error) {
console.warn('Unable to parse stored favorite GIF IDs.', error)
}

return []
}

export function setStoredFavoriteGifIds(value: string[]) {
setCookieValue(favoriteGifIdsKey, value.length > 0 ? JSON.stringify(value) : '')
}
2 changes: 1 addition & 1 deletion PointerStar/Server/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
options.ClientTimeoutInterval = TimeSpan.FromMinutes(3);
});

builder.Services.AddScoped(_ => new Hashids("TODO: Environment Salt"));
builder.Services.AddScoped(_ => new Hashids("Pointer*"));
builder.Services.AddSingleton<IRoomManager, InMemoryRoomManager>();

var app = builder.Build();
Expand Down