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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,6 @@ public/*.js
package-lock.json
yarn.lock
pnpm-lock.yaml

# local EIK test script
/scripts/eik-local.sh
Binary file modified bun.lockb
Binary file not shown.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
"@vercel/kv": "^3.0.0",
"caniuse-lite": "^1.0.30001687",
"clsx": "^2.1.0",
"ethereum-identity-kit": "^0.2.74",
"ethereum-identity-kit": "^0.2.77",
"i18next": "^23.11.5",
"i18next-browser-languagedetector": "^8.0.0",
"i18next-http-backend": "^2.5.2",
Expand Down
2 changes: 2 additions & 0 deletions src/app/[user]/components/user-info.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ const UserInfo: React.FC<UserInfoProps> = ({ user }) => {
followersTagsFilter,
followerTagsLoading,
followingTagsLoading,
setFetchFreshProfile,
isFetchingMoreFollowers,
isFetchingMoreFollowing,
setFollowersTagsFilter,
Expand Down Expand Up @@ -195,6 +196,7 @@ const UserInfo: React.FC<UserInfoProps> = ({ user }) => {
}}
openQrCodeModal={() => setQrCodeModalOpen(true)}
openListSettingsModal={() => setListSettingsOpen(true)}
setFetchFreshProfile={setFetchFreshProfile}
/>
</div>
<div className='flex w-full max-w-[1920px] flex-col-reverse gap-4 px-4 md:-mt-28 lg:-mt-24 lg:flex-row xl:px-8'>
Expand Down
164 changes: 164 additions & 0 deletions src/components/ens-records-modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
'use client'

import React from 'react'
import { createPortal } from 'react-dom'
import { sha256 } from 'viem'
import { useTheme } from 'next-themes'
import { ENSRecords } from 'ethereum-identity-kit'
import { useAccount, useSignTypedData } from 'wagmi'
import type { SetStateAction, Dispatch } from 'react'
import { useQueryClient } from '@tanstack/react-query'

interface ENSRecordsModalProps {
name?: string | null
onClose: () => void
setFetchFreshProfile?: Dispatch<SetStateAction<boolean>> | ((state: boolean) => void)
}

const dataURLToBytes = (dataUrl: string): Uint8Array => {
const base64 = dataUrl.split(',')[1]
const binary = atob(base64 || '')
const bytes = new Uint8Array(binary.length)

for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}

return bytes
}

const forceRefetchImage = (rawUrl: string) => {
if (typeof window === 'undefined' || !rawUrl) return

let target: URL
try {
target = new URL(rawUrl, window.location.href)
} catch {
return
}

const targetKey = `${target.origin}${target.pathname}`
void fetch(target.toString(), { cache: 'reload', mode: 'no-cors' }).catch(() => {})

const cacheBust = Date.now().toString()
for (const img of Array.from(document.images)) {
const current = img.currentSrc || img.src
if (!current) continue

let candidate: URL
try {
candidate = new URL(current, window.location.href)
} catch {
continue
}

if (`${candidate.origin}${candidate.pathname}` !== targetKey) continue

const refreshed = new URL(target.toString())
refreshed.searchParams.set('_cb', cacheBust)
img.src = refreshed.toString()
}
}

const ENSRecordsModal: React.FC<ENSRecordsModalProps> = ({ name, onClose, setFetchFreshProfile }) => {
const { resolvedTheme } = useTheme()
const { address: connectedAddress } = useAccount()
const { signTypedDataAsync } = useSignTypedData()
const queryClient = useQueryClient()

if (!name) return null

const uploadImage = async (dataURL: string, type: 'avatar' | 'header') => {
if (!connectedAddress) throw new Error('Connect a wallet to upload ENS profile images')

const urlHash = sha256(dataURLToBytes(dataURL))
const expiry = `${Date.now() + 1000 * 60 * 60 * 24 * 7}`
const sig = await signTypedDataAsync({
primaryType: 'Upload',
domain: { name: 'Ethereum Name Service', version: '1' },
types: {
Upload: [
{ name: 'upload', type: 'string' },
{ name: 'expiry', type: 'string' },
{ name: 'name', type: 'string' },
{ name: 'hash', type: 'string' },
],
},
message: {
upload: type,
expiry,
name,
hash: urlHash,
},
})

const response = await fetch(`https://eidk.me/${encodeURIComponent(name)}${type === 'header' ? '/h' : ''}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
expiry,
dataURL,
sig,
unverifiedAddress: connectedAddress,
}),
})

if (!response.ok) {
if (response.status === 413) throw new Error('File size is too large (max 500KB)')
if (response.status === 415) throw new Error('Unsupported file type. Use JPG/JPEG.')
throw new Error(`Upload failed: ${response.statusText}`)
}

await new Promise((resolve) => setTimeout(resolve, 1000)) // wait for image to be uploaded to euc.li

const result = (await response.json()) as { url?: string }
const finalUrl = result.url || `https://euc.li/${encodeURIComponent(name)}${type === 'header' ? '/h' : ''}`

forceRefetchImage(finalUrl)

return finalUrl
}

const onSuccess = () => {
setTimeout(() => {
// @ts-expect-error - both types support function calls with a state argument
setFetchFreshProfile?.((state) => {
if (state) queryClient.refetchQueries({ queryKey: ['profile', connectedAddress] })
return true
})
onClose()
}, 1500)
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Comment on lines +124 to +133

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Second save silently skips refetch when profile is open via ENS name

queryClient.refetchQueries({ queryKey: ['profile', connectedAddress] }) is only reached when setFetchFreshProfile's previous state is already true (second and subsequent saves). At that point, the active query in use-user-profile.ts is keyed as ['profile', user, true] where user is the URL path segment — which is the ENS name (e.g., 'vitalik.eth') when a user navigates to /vitalik.eth. Since connectedAddress is always a hex address, the prefix ['profile', connectedAddress] does not match any active query key, making the refetchQueries call a no-op. The profile shows stale data after every save past the first one for any user whose profile URL is their ENS name.

Fix in Conductor Fix in Cursor Fix in Codex Fix in Claude Code


const modalRoot = typeof document === 'undefined' ? null : document.getElementById('modal-root')
if (!modalRoot) return null

// EFP owns the modal chrome (overlay + backdrop + click-outside-to-close).
// The ENSRecords component from ethereum-identity-kit renders only its own card
// content — it deliberately does not provide a fixed overlay of its own.
return createPortal(
<div
className='fixed top-0 left-0 z-100 flex h-screen w-screen justify-center overflow-scroll bg-black/40 px-2 py-12 sm:px-4'
onClick={onClose}
>
<div
onClick={(e) => e.stopPropagation()}
className='bg-neutral mt-12 h-fit min-w-[min(32rem,100%)] overflow-hidden rounded-md'
>
<ENSRecords
name={name}
defaultTab='records'
darkMode={resolvedTheme === 'dark' || resolvedTheme === 'halloween'}
onClose={onClose}
onImageUpload={uploadImage}
onSuccess={onSuccess}
/>
</div>
</div>,
modalRoot
)
}

export default ENSRecordsModal
2 changes: 1 addition & 1 deletion src/components/profile-tooltip-wrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ const ProfileTooltipWrapper: React.FC<ProfileTooltipWrapperProps> = ({
router.push(`/${addressOrName}?ssr=false`)
}}
>
{children as React.ReactElement}
{children}
</ProfileTooltip>
)
}
Expand Down
122 changes: 61 additions & 61 deletions src/components/user-profile-card/index.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
'use client'

import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import { useAccount } from 'wagmi'
import { useTranslation } from 'react-i18next'
import { ProfileCard } from 'ethereum-identity-kit'

import { cn } from '#/lib/utilities'
import Achievements from './components/achievements'
import FollowButton from '#/components/follow-button'
import ENSRecordsModal from '#/components/ens-records-modal'
import ThreeDotMenu from './components/three-dot-menu'
import { useProfileCard } from './hooks/use-profile-card'
import EnsLogo from 'public/assets/icons/socials/ens.svg'
import { useEFPProfile } from '#/contexts/efp-profile-context'
import LoadingProfileCard from './components/loading-profile-card'
import type { ProfileDetailsResponse, StatsResponse } from '#/types/requests'
Expand Down Expand Up @@ -53,75 +54,74 @@ const UserProfileCard: React.FC<UserProfileCardProps> = ({
}) => {
const router = useRouter()
const { t } = useTranslation()
const { address: connectedAddress } = useAccount()
const { selectedList } = useEFPProfile()
const [ensRecordsOpen, setEnsRecordsOpen] = useState(false)
const { followState, profileName, isConnectedUserCard } = useProfileCard(profile)
const ensRecordsName = profile?.ens?.name ?? profileName

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Silent no-op when ENS name is absent

ensRecordsName is profile?.ens?.name ?? profileName. profileName comes from useProfileCardfetchedEnsProfile?.name, which is undefined during the initial query load and permanently undefined for any address that has no primary ENS name. When ensRecordsOpen becomes true but ensRecordsName is undefined, ENSRecordsModal hits its if (!name) return null guard and renders nothing—the user clicks "Edit Profile" and the UI silently does nothing. A guard before calling setEnsRecordsOpen(true) (or surfacing a message) would prevent the invisible dead-end.

The same issue exists in src/components/user-profile/index.tsx line 57 (addressOrName.endsWith('.eth') ? addressOrName : undefinedundefined for hex-only profiles).

Fix in Conductor Fix in Cursor Fix in Codex Fix in Claude Code


return (
<div className={cn('bg-neutral flex w-[364px] flex-col gap-4 rounded-sm pb-3', className)}>
{isLoading ? (
<LoadingProfileCard hideFollowButton={true} className='bg-neutral' />
) : profile?.address ? (
<ProfileCard
list={profileList}
onStatClick={({ stat }) => {
router.push(`/${profile.address}?tab=${stat}&ssr=false`)
}}
showFollowerState={true}
showFollowButton={!hideFollowButton}
addressOrName={profile.address}
onProfileClick={(addressOrName) => {
router.push(`/${addressOrName}?ssr=false`)
}}
selectedList={selectedList}
className='bg-neutral'
extraOptions={{
openListSettings: openListSettingsModal,
prefetched: {
profile: {
data: profile ?? undefined,
isLoading: !!isLoading,
refetch: refetchProfile ?? (() => {}),
<>
<ProfileCard
list={profileList}
connectedAddress={connectedAddress}
onStatClick={({ stat }) => {
router.push(`/${profile.address}?tab=${stat}&ssr=false`)
}}
showFollowerState={true}
showFollowButton={!hideFollowButton}
addressOrName={profile.address}
onProfileClick={(addressOrName) => {
router.push(`/${addressOrName}?ssr=false`)
}}
selectedList={selectedList}
className='bg-neutral'
extraOptions={{
openListSettings: openListSettingsModal,
onEditProfileClick: () => setEnsRecordsOpen(true),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 onEditProfileClick is registered unconditionally, but the "Edit Profile" button should only be reachable for the connected user's own card. Binding it only when isConnectedUserCard is true makes the intent explicit and eliminates any accidental invocation if EIK's internal guard ever differs from the app's logic.

Suggested change
onEditProfileClick: () => setEnsRecordsOpen(true),
onEditProfileClick: isConnectedUserCard ? () => setEnsRecordsOpen(true) : undefined,

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Conductor Fix in Cursor Fix in Codex Fix in Claude Code

prefetched: {
profile: {
data: profile ?? undefined,
isLoading: !!isLoading,
refetch: refetchProfile ?? (() => {}),
},
stats: {
data: stats ?? undefined,
isLoading: !!isStatsLoading,
refetch: refetchStats ?? (() => {}),
},
},
stats: {
data: stats ?? undefined,
isLoading: !!isStatsLoading,
refetch: refetchStats ?? (() => {}),
},
},
nameMenu: (
<ThreeDotMenu
address={profile.address}
profileList={profileList}
primaryList={Number(profile.primary_list)}
profileName={profileName}
showMoreOptions={!!showMoreOptions}
isConnectedUserCard={isConnectedUserCard}
followState={followState}
openBlockModal={openBlockModal}
openQrCodeModal={openQrCodeModal}
openListSettingsModal={openListSettingsModal}
/>
),
customFollowButton: (
<div className='mt-16'>
{isConnectedUserCard ? (
<Link href={`https://app.ens.domains/${profile.ens?.name}`} target='_blank'>
<button className='flex items-center gap-1 rounded-sm bg-[#0080BC] p-1.5 py-2 font-semibold text-white transition-all hover:scale-110 hover:bg-[#07A9F5]'>
<EnsLogo className='h-auto w-5' />
<p>Edit Profile</p>
</button>
</Link>
) : (
nameMenu: (
<ThreeDotMenu
address={profile.address}
profileList={profileList}
primaryList={Number(profile.primary_list)}
profileName={profileName}
showMoreOptions={!!showMoreOptions}
isConnectedUserCard={isConnectedUserCard}
followState={followState}
openBlockModal={openBlockModal}
openQrCodeModal={openQrCodeModal}
openListSettingsModal={openListSettingsModal}
/>
),
customFollowButton: isConnectedUserCard ? undefined : (
<div className='mt-16'>
<FollowButton address={profile.address} />
)}
</div>
),
}}
style={{
width: '100%',
zIndex: 10,
}}
/>
</div>
),
}}
style={{
width: '100%',
zIndex: 10,
}}
/>
{ensRecordsOpen && <ENSRecordsModal name={ensRecordsName} onClose={() => setEnsRecordsOpen(false)} />}
</>
) : (
<div className={cn('relative z-10 flex flex-col rounded-sm', isRecommended ? 'bg-neutral' : 'glass-card')}>
{isRecommended ? (
Expand Down
Loading