-
Notifications
You must be signed in to change notification settings - Fork 55
Add ENS records profile edit modal #412
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c987a68
0f9c188
e7d4305
5dae680
b66f64b
0bb9555
833511e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -76,3 +76,6 @@ public/*.js | |
| package-lock.json | ||
| yarn.lock | ||
| pnpm-lock.yaml | ||
|
|
||
| # local EIK test script | ||
| /scripts/eik-local.sh | ||
| 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 on lines
+124
to
+133
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| 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 | ||
| 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' | ||||||
|
|
@@ -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 | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The same issue exists in |
||||||
|
|
||||||
| 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), | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
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! |
||||||
| 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 ? ( | ||||||
|
|
||||||
Uh oh!
There was an error while loading. Please reload this page.