Browse Source

fixed user profile pic rendering

instituted vitetest
imwald
Silberengel 4 months ago
parent
commit
2a55fb3514
  1. 1218
      package-lock.json
  2. 11
      package.json
  3. 5
      src/components/Collapsible/index.tsx
  4. 2
      src/components/NoteCard/MainNoteCard.tsx
  5. 170
      src/components/UserAvatar/UserAvatar.test.tsx
  6. 65
      src/components/UserAvatar/index.tsx
  7. 44
      src/test/setup.ts
  8. 8
      vite.config.ts

1218
package-lock.json generated

File diff suppressed because it is too large Load Diff

11
package.json

@ -16,7 +16,9 @@
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"format": "prettier --write .", "format": "prettier --write .",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest",
"test:run": "vitest run"
}, },
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4", "@asciidoctor/core": "^3.0.4",
@ -98,6 +100,9 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.17.0", "@eslint/js": "^9.17.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^22.10.2", "@types/node": "^22.10.2",
"@types/react": "^18.3.17", "@types/react": "^18.3.17",
"@types/react-dom": "^18.3.5", "@types/react-dom": "^18.3.5",
@ -107,12 +112,14 @@
"eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.16", "eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.13.0", "globals": "^15.13.0",
"jsdom": "^27.1.0",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"prettier": "3.4.2", "prettier": "3.4.2",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"typescript": "~5.6.2", "typescript": "~5.6.2",
"typescript-eslint": "^8.18.1", "typescript-eslint": "^8.18.1",
"vite": "^6.0.3", "vite": "^6.0.3",
"vite-plugin-pwa": "^0.21.1" "vite-plugin-pwa": "^0.21.1",
"vitest": "^4.0.8"
} }
} }

5
src/components/Collapsible/index.tsx

@ -48,11 +48,12 @@ export default function Collapsible({
return ( return (
<div <div
className={cn('relative text-left overflow-hidden', className)} className={cn('relative text-left', className)}
ref={containerRef} ref={containerRef}
{...props} {...props}
style={{ style={{
maxHeight: !shouldCollapse || expanded ? 'none' : `${collapsedHeight}px` maxHeight: !shouldCollapse || expanded ? 'none' : `${collapsedHeight}px`,
overflow: !shouldCollapse || expanded ? 'visible' : 'hidden'
}} }}
> >
{children} {children}

2
src/components/NoteCard/MainNoteCard.tsx

@ -42,7 +42,7 @@ export default function MainNoteCard({
navigateToNote(noteUrl) navigateToNote(noteUrl)
}} }}
> >
<div className={`clickable ${embedded ? 'p-2 sm:p-3 border rounded-lg' : 'py-3'}`}> <div className={`clickable ${embedded ? 'p-2 sm:p-3 border rounded-lg' : 'py-3'}`} style={embedded ? { position: 'relative', isolation: 'isolate', overflow: 'visible' } : undefined}>
<Collapsible alwaysExpand={embedded}> <Collapsible alwaysExpand={embedded}>
<RepostDescription className={embedded ? '' : 'px-4'} reposter={reposter} /> <RepostDescription className={embedded ? '' : 'px-4'} reposter={reposter} />
<Note <Note

170
src/components/UserAvatar/UserAvatar.test.tsx

@ -0,0 +1,170 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, waitFor } from '@testing-library/react'
import UserAvatar from './index'
import * as useFetchProfileHook from '@/hooks/useFetchProfile'
// Mock the hooks and dependencies
vi.mock('@/hooks/useFetchProfile', () => ({
useFetchProfile: vi.fn()
}))
vi.mock('@/PageManager', () => ({
useSmartProfileNavigation: () => ({
navigateToProfile: vi.fn()
})
}))
vi.mock('@/lib/pubkey', () => ({
userIdToPubkey: (id: string) => id.startsWith('npub') ? 'decoded_pubkey' : id,
generateImageByPubkey: (pubkey: string) => `https://avatar.example.com/${pubkey}`
}))
vi.mock('@/lib/link', () => ({
toProfile: (pubkey: string) => `/profile/${pubkey}`
}))
describe('UserAvatar in Embedded Notes', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render avatar image fully visible without being covered', async () => {
// Mock profile with avatar
vi.mocked(useFetchProfileHook.useFetchProfile).mockReturnValue({
isFetching: false,
error: null,
profile: {
pubkey: 'test_pubkey',
npub: 'npub_test',
username: 'testuser',
avatar: 'https://example.com/avatar.jpg'
}
})
const { container } = render(
<div data-embedded-note>
<div className="p-2 sm:p-3 border rounded-lg">
<div className="relative">
<UserAvatar userId="test_pubkey" size="medium" />
</div>
</div>
</div>
)
// Find the avatar container
const avatarContainer = container.querySelector('[data-user-avatar]')
expect(avatarContainer).toBeInTheDocument()
// Find the image
const img = avatarContainer?.querySelector('img')
expect(img).toBeInTheDocument()
expect(img).toHaveAttribute('src', 'https://example.com/avatar.jpg')
// Check that the image is not hidden or covered
const computedStyle = window.getComputedStyle(img!)
expect(computedStyle.display).not.toBe('none')
expect(computedStyle.visibility).not.toBe('hidden')
expect(computedStyle.opacity).not.toBe('0')
// Check that the container has overflow-hidden for rounded corners
// Note: In test environment, computed styles may not reflect Tailwind classes
// So we check the className instead
expect(avatarContainer?.className).toContain('overflow-hidden')
// Simulate image load to remove loading placeholder
if (img) {
img.dispatchEvent(new Event('load'))
}
// Wait for React to update state and remove loading placeholder
await waitFor(() => {
const loadingPlaceholders = avatarContainer?.querySelectorAll('[class*="animate-pulse"]')
expect(loadingPlaceholders?.length || 0).toBe(0)
})
})
it('should render avatar without loading placeholder covering it', async () => {
vi.mocked(useFetchProfileHook.useFetchProfile).mockReturnValue({
isFetching: false,
error: null,
profile: {
pubkey: 'test_pubkey',
npub: 'npub_test',
username: 'testuser',
avatar: 'https://example.com/avatar.jpg'
}
})
const { container } = render(
<div data-embedded-note>
<div className="p-2 sm:p-3 border rounded-lg">
<UserAvatar userId="test_pubkey" size="medium" />
</div>
</div>
)
const avatarContainer = container.querySelector('[data-user-avatar]')
// Check that the image exists
const img = avatarContainer?.querySelector('img')
expect(img).toBeInTheDocument()
// Simulate image load to trigger removal of loading placeholder
if (img) {
img.dispatchEvent(new Event('load'))
}
// Wait for React to update state and remove loading placeholder
await waitFor(() => {
const loadingPlaceholders = avatarContainer?.querySelectorAll('[class*="animate-pulse"]')
expect(loadingPlaceholders?.length || 0).toBe(0)
})
// Check image is visible after loading
const imgStyle = window.getComputedStyle(img!)
expect(imgStyle.display).not.toBe('none')
expect(imgStyle.visibility).not.toBe('hidden')
expect(imgStyle.opacity).not.toBe('0')
})
it('should have correct z-index and positioning to prevent being covered', () => {
vi.mocked(useFetchProfileHook.useFetchProfile).mockReturnValue({
isFetching: false,
error: null,
profile: {
pubkey: 'test_pubkey',
npub: 'npub_test',
username: 'testuser',
avatar: 'https://example.com/avatar.jpg'
}
})
const { container } = render(
<div data-embedded-note>
<div className="p-2 sm:p-3 border rounded-lg">
<div className="flex items-center space-x-2">
<UserAvatar userId="test_pubkey" size="medium" />
</div>
</div>
</div>
)
const avatarContainer = container.querySelector('[data-user-avatar]')
const computedStyle = window.getComputedStyle(avatarContainer!)
// The container should have relative positioning (or static which is default)
// In CSS, if position is not set, it defaults to static
expect(['relative', 'static', '']).toContain(computedStyle.position)
// Check that display is block or inline-block (not inline which could cause issues)
expect(['block', 'inline-block', 'flex']).toContain(computedStyle.display)
// Most importantly: check that the image is visible and not covered
const img = avatarContainer?.querySelector('img')
expect(img).toBeInTheDocument()
const imgStyle = window.getComputedStyle(img!)
expect(imgStyle.opacity).not.toBe('0')
expect(imgStyle.display).not.toBe('none')
})
})

65
src/components/UserAvatar/index.tsx

@ -47,12 +47,10 @@ export default function UserAvatar({
// All hooks must be called before any early returns // All hooks must be called before any early returns
const [imgError, setImgError] = useState(false) const [imgError, setImgError] = useState(false)
const [currentSrc, setCurrentSrc] = useState(avatarSrc) const [currentSrc, setCurrentSrc] = useState(avatarSrc)
const [imageLoaded, setImageLoaded] = useState(false)
// Reset error state when src changes // Reset error state when src changes
useEffect(() => { useEffect(() => {
setImgError(false) setImgError(false)
setImageLoaded(false)
setCurrentSrc(avatarSrc) setCurrentSrc(avatarSrc)
}, [avatarSrc]) }, [avatarSrc])
@ -62,14 +60,12 @@ export default function UserAvatar({
setCurrentSrc(defaultAvatar) setCurrentSrc(defaultAvatar)
setImgError(false) setImgError(false)
} else { } else {
// Both failed, show placeholder // Both failed
setImgError(true) setImgError(true)
setImageLoaded(true)
} }
} }
const handleImageLoad = () => { const handleImageLoad = () => {
setImageLoaded(true)
setImgError(false) setImgError(false)
} }
@ -88,29 +84,23 @@ export default function UserAvatar({
return ( return (
<div <div
data-user-avatar data-user-avatar
className={cn('shrink-0 cursor-pointer relative overflow-hidden rounded-full bg-muted', UserAvatarSizeCnMap[size], className)} className={cn('shrink-0 cursor-pointer block overflow-hidden rounded-full bg-muted', UserAvatarSizeCnMap[size], className)}
style={{ position: 'relative', zIndex: 10, isolation: 'isolate', display: 'block' }}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
navigateToProfile(toProfile(displayPubkey)) navigateToProfile(toProfile(displayPubkey))
}} }}
> >
{!imgError && currentSrc ? ( {!imgError && currentSrc ? (
<> <img
{!imageLoaded && ( src={currentSrc}
<div className="absolute inset-0 bg-muted animate-pulse" /> alt={displayPubkey}
)} className="block w-full h-full object-cover object-center"
<img style={{ display: 'block', position: 'static', margin: 0, padding: 0, top: 0, left: 0, right: 0, bottom: 0 }}
src={currentSrc} onError={handleImageError}
alt={displayPubkey} onLoad={handleImageLoad}
className={cn( loading="lazy"
'h-full w-full object-cover object-center transition-opacity duration-200', />
imageLoaded ? 'opacity-100' : 'opacity-0'
)}
onError={handleImageError}
onLoad={handleImageLoad}
loading="lazy"
/>
</>
) : ( ) : (
// Show initials or placeholder when image fails // Show initials or placeholder when image fails
<div className="h-full w-full flex items-center justify-center text-xs font-medium text-muted-foreground"> <div className="h-full w-full flex items-center justify-center text-xs font-medium text-muted-foreground">
@ -149,12 +139,10 @@ export function SimpleUserAvatar({
// All hooks must be called before any early returns // All hooks must be called before any early returns
const [imgError, setImgError] = useState(false) const [imgError, setImgError] = useState(false)
const [currentSrc, setCurrentSrc] = useState(avatarSrc) const [currentSrc, setCurrentSrc] = useState(avatarSrc)
const [imageLoaded, setImageLoaded] = useState(false)
// Reset error state when src changes // Reset error state when src changes
useEffect(() => { useEffect(() => {
setImgError(false) setImgError(false)
setImageLoaded(false)
setCurrentSrc(avatarSrc) setCurrentSrc(avatarSrc)
}, [avatarSrc]) }, [avatarSrc])
@ -164,14 +152,12 @@ export function SimpleUserAvatar({
setCurrentSrc(defaultAvatar) setCurrentSrc(defaultAvatar)
setImgError(false) setImgError(false)
} else { } else {
// Both failed, show placeholder // Both failed
setImgError(true) setImgError(true)
setImageLoaded(true)
} }
} }
const handleImageLoad = () => { const handleImageLoad = () => {
setImageLoaded(true)
setImgError(false) setImgError(false)
} }
@ -192,22 +178,15 @@ export function SimpleUserAvatar({
className={cn('shrink-0 relative overflow-hidden rounded-full bg-muted', UserAvatarSizeCnMap[size], className)} className={cn('shrink-0 relative overflow-hidden rounded-full bg-muted', UserAvatarSizeCnMap[size], className)}
> >
{!imgError && currentSrc ? ( {!imgError && currentSrc ? (
<> <img
{!imageLoaded && ( src={currentSrc}
<div className="absolute inset-0 bg-muted animate-pulse" /> alt={displayPubkey}
)} className="block w-full h-full object-cover object-center"
<img style={{ display: 'block', position: 'static', margin: 0, padding: 0, top: 0, left: 0, right: 0, bottom: 0 }}
src={currentSrc} onError={handleImageError}
alt={displayPubkey} onLoad={handleImageLoad}
className={cn( loading="lazy"
'h-full w-full object-cover object-center transition-opacity duration-200', />
imageLoaded ? 'opacity-100' : 'opacity-0'
)}
onError={handleImageError}
onLoad={handleImageLoad}
loading="lazy"
/>
</>
) : ( ) : (
// Show initials or placeholder when image fails // Show initials or placeholder when image fails
<div className="h-full w-full flex items-center justify-center text-xs font-medium text-muted-foreground"> <div className="h-full w-full flex items-center justify-center text-xs font-medium text-muted-foreground">

44
src/test/setup.ts

@ -0,0 +1,44 @@
import '@testing-library/jest-dom'
import { afterEach, vi } from 'vitest'
import { cleanup } from '@testing-library/react'
// Mock IndexedDB before any modules are loaded
// This needs to be set up synchronously, not in beforeAll
if (typeof globalThis.indexedDB === 'undefined') {
globalThis.indexedDB = {
open: vi.fn(() => {
const request: any = {
onerror: null,
onsuccess: null,
onupgradeneeded: null,
result: {
createObjectStore: vi.fn(),
transaction: {
objectStore: vi.fn(() => ({
add: vi.fn(),
get: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
clear: vi.fn()
}))
}
},
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
// Simulate immediate success
setTimeout(() => {
if (request.onsuccess) {
request.onsuccess({} as any)
}
}, 0)
return request
})
} as any
}
// Cleanup after each test
afterEach(() => {
cleanup()
})

8
vite.config.ts

@ -1,9 +1,10 @@
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import { execSync } from 'child_process' import { execSync } from 'child_process'
import path from 'path' import path from 'path'
import { defineConfig } from 'vite' import { defineConfig } from 'vitest/config'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
import packageJson from './package.json' import packageJson from './package.json'
/// <reference types="vitest" />
const getGitHash = () => { const getGitHash = () => {
try { try {
@ -34,6 +35,11 @@ export default defineConfig({
'@': path.resolve(__dirname, './src') '@': path.resolve(__dirname, './src')
} }
}, },
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts'
},
plugins: [ plugins: [
react(), react(),
VitePWA({ VitePWA({

Loading…
Cancel
Save