diff --git a/.gitignore b/.gitignore index 08e83746..5ed5eb5a 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,6 @@ public/*.js package-lock.json yarn.lock pnpm-lock.yaml + +# local EIK test script +/scripts/eik-local.sh \ No newline at end of file diff --git a/bun.lockb b/bun.lockb index ecd55d2a..063d2b95 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 8c62d9b8..a99ae57c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/[user]/components/user-info.tsx b/src/app/[user]/components/user-info.tsx index 77cb0bcd..13b48714 100644 --- a/src/app/[user]/components/user-info.tsx +++ b/src/app/[user]/components/user-info.tsx @@ -65,6 +65,7 @@ const UserInfo: React.FC = ({ user }) => { followersTagsFilter, followerTagsLoading, followingTagsLoading, + setFetchFreshProfile, isFetchingMoreFollowers, isFetchingMoreFollowing, setFollowersTagsFilter, @@ -195,6 +196,7 @@ const UserInfo: React.FC = ({ user }) => { }} openQrCodeModal={() => setQrCodeModalOpen(true)} openListSettingsModal={() => setListSettingsOpen(true)} + setFetchFreshProfile={setFetchFreshProfile} />
diff --git a/src/components/ens-records-modal.tsx b/src/components/ens-records-modal.tsx new file mode 100644 index 00000000..bd3c8b35 --- /dev/null +++ b/src/components/ens-records-modal.tsx @@ -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> | ((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 = ({ 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) + } + + 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( +
+
e.stopPropagation()} + className='bg-neutral mt-12 h-fit min-w-[min(32rem,100%)] overflow-hidden rounded-md' + > + +
+
, + modalRoot + ) +} + +export default ENSRecordsModal diff --git a/src/components/profile-tooltip-wrapper.tsx b/src/components/profile-tooltip-wrapper.tsx index fbc0ec50..2e6629ca 100644 --- a/src/components/profile-tooltip-wrapper.tsx +++ b/src/components/profile-tooltip-wrapper.tsx @@ -43,7 +43,7 @@ const ProfileTooltipWrapper: React.FC = ({ router.push(`/${addressOrName}?ssr=false`) }} > - {children as React.ReactElement} + {children} ) } diff --git a/src/components/user-profile-card/index.tsx b/src/components/user-profile-card/index.tsx index 2b73bfd1..ac27e841 100644 --- a/src/components/user-profile-card/index.tsx +++ b/src/components/user-profile-card/index.tsx @@ -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' @@ -53,75 +54,74 @@ const UserProfileCard: React.FC = ({ }) => { 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 return (
{isLoading ? ( ) : profile?.address ? ( - { - 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 ?? (() => {}), + <> + { + 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), + 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: ( - - ), - customFollowButton: ( -
- {isConnectedUserCard ? ( - - - - ) : ( + nameMenu: ( + + ), + customFollowButton: isConnectedUserCard ? undefined : ( +
- )} -
- ), - }} - style={{ - width: '100%', - zIndex: 10, - }} - /> +
+ ), + }} + style={{ + width: '100%', + zIndex: 10, + }} + /> + {ensRecordsOpen && setEnsRecordsOpen(false)} />} + ) : (
{isRecommended ? ( diff --git a/src/components/user-profile/index.tsx b/src/components/user-profile/index.tsx index a3c56aaf..952a4931 100644 --- a/src/components/user-profile/index.tsx +++ b/src/components/user-profile/index.tsx @@ -1,10 +1,13 @@ -import React from 'react' +'use client' + +import React, { useState, type Dispatch, type SetStateAction } from 'react' import { useAccount } from 'wagmi' import { useRouter } from 'next/navigation' import { useWindowSize } from '@uidotdev/usehooks' import { FullWidthProfile } from 'ethereum-identity-kit' import FollowButton from '../follow-button' +import ENSRecordsModal from '#/components/ens-records-modal' import type { StatsResponse } from '#/types/requests' import useFollowingState from '#/hooks/use-following-state' import { useEFPProfile } from '#/contexts/efp-profile-context' @@ -26,6 +29,7 @@ interface UserProfileCardProps { refetchStats?: () => void openQrCodeModal?: () => void className?: string + setFetchFreshProfile?: Dispatch> | ((state: boolean) => void) } const UserProfile: React.FC = ({ @@ -43,64 +47,77 @@ const UserProfile: React.FC = ({ refetchProfile, refetchStats, className, + setFetchFreshProfile, }) => { const router = useRouter() const { width } = useWindowSize() const { selectedList } = useEFPProfile() const { address: userAddress } = useAccount() + const [ensRecordsOpen, setEnsRecordsOpen] = useState(false) const { followingState } = useFollowingState({ address: profile?.address }) + const ensRecordsName = profile?.ens?.name ?? (addressOrName.endsWith('.eth') ? addressOrName : undefined) return ( - { - router.push(`/${addressOrName}?ssr=false`) - }} - onStatClick={({ addressOrName, stat }: { addressOrName: string; stat: string }) => { - router.push(`/${addressOrName}?tab=${stat}`) - }} - className={className} - style={{ - paddingBottom: width && width < 768 ? '20px' : '110px', - }} - showFollowButton={profile?.address ? true : false} - showFollowerState={true} - extraOptions={{ - role: role, - prefetched: { - profile: { - data: profile ?? undefined, - isLoading: !!isLoading, - refetch: refetchProfile ?? (() => {}), - }, - stats: { - data: stats ?? undefined, - isLoading: !!isStatsLoading, - refetch: refetchStats ?? (() => {}), + <> + { + router.push(`/${addressOrName}?ssr=false`) + }} + onStatClick={({ addressOrName, stat }: { addressOrName: string; stat: string }) => { + router.push(`/${addressOrName}?tab=${stat}`) + }} + className={className} + style={{ + paddingBottom: width && width < 768 ? '20px' : '110px', + }} + showFollowButton={profile?.address ? true : false} + showFollowerState={true} + extraOptions={{ + role: role, + onEditProfileClick: () => setEnsRecordsOpen(true), + prefetched: { + profile: { + data: profile ?? undefined, + isLoading: !!isLoading, + refetch: refetchProfile ?? (() => {}), + }, + stats: { + data: stats ?? undefined, + isLoading: !!isStatsLoading, + refetch: refetchStats ?? (() => {}), + }, }, - }, - nameMenu: profile?.address ? ( - - ) : null, - openListSettings: openListSettingsModal, - customFollowButton: , - }} - /> + nameMenu: profile?.address ? ( + + ) : null, + openListSettings: openListSettingsModal, + customFollowButton: isMyProfile ? undefined : , + }} + /> + {ensRecordsOpen && ( + setEnsRecordsOpen(false)} + setFetchFreshProfile={setFetchFreshProfile} + /> + )} + ) }