diff --git a/netlify/edge-functions/seo.ts b/netlify/edge-functions/seo.ts index 9fd46429a..5dc834d5d 100644 --- a/netlify/edge-functions/seo.ts +++ b/netlify/edge-functions/seo.ts @@ -190,13 +190,13 @@ async function buildMeta(pathname: string, fullUrl: URL): Promise { const userMatch = pathname.match(/^\/users\/([^/]+)/); if (userMatch) { const address = userMatch[1]; - const apiUrl = `${API_BASE.replace(/\/$/, '')}/api/accounts/${encodeURIComponent(address)}`; + const apiUrl = `${API_BASE.replace(/\/$/, '')}/api/profile/${encodeURIComponent(address)}`; try { const r = await fetch(apiUrl, { headers: { accept: 'application/json' } }); if (r.ok) { const data: any = await r.json(); - const display = (data?.chain_name || address) as string; - const bio = (data?.bio || '').toString(); + const display = (data?.public_name || data?.profile?.chain_name || address) as string; + const bio = (data?.profile?.bio || '').toString(); return { title: `${display} – Profile – Superhero`, description: bio ? truncate(bio, 200) : 'View profile on Superhero, the crypto social network.', diff --git a/netlify/functions/ssr.ts b/netlify/functions/ssr.ts index a8dfc0572..fe82772ef 100644 --- a/netlify/functions/ssr.ts +++ b/netlify/functions/ssr.ts @@ -128,13 +128,13 @@ async function buildMeta(pathname: string): Promise { const userMatch = pathname.match(/^\/users\/([^/]+)/); if (userMatch) { const address = userMatch[1]; - const apiUrl = `${API_BASE.replace(/\/$/, '')}/api/accounts/${encodeURIComponent(address)}`; + const apiUrl = `${API_BASE.replace(/\/$/, '')}/api/profile/${encodeURIComponent(address)}`; try { const r = await fetch(apiUrl, { headers: { accept: 'application/json' } }); if (r.ok) { const data: any = await r.json(); - const display = (data?.chain_name || address) as string; - const bio = (data?.bio || '').toString(); + const display = (data?.public_name || data?.profile?.chain_name || address) as string; + const bio = (data?.profile?.bio || '').toString(); return { title: `${display} – Profile – Superhero`, description: truncate(bio || `View ${display} on Superhero, the crypto social network.`, 160), diff --git a/server/index.cjs b/server/index.cjs index 8d0b9fcb4..26b8bba5a 100644 --- a/server/index.cjs +++ b/server/index.cjs @@ -93,14 +93,19 @@ async function buildMeta(pathname, origin){ const um = pathname.match(/^\/users\/([^/]+)/); if (um) { const address = um[1]; + let display = address; let bio = ''; try { - const r = await fetch(`${API_BASE.replace(/\/$/, '')}/api/accounts/${encodeURIComponent(address)}`, { headers: { accept: 'application/json' } }); - if (r.ok) { const data = await r.json(); bio = String(data?.bio||'').trim(); } + const r = await fetch(`${API_BASE.replace(/\/$/, '')}/api/profile/${encodeURIComponent(address)}`, { headers: { accept: 'application/json' } }); + if (r.ok) { + const data = await r.json(); + display = String(data?.public_name || data?.profile?.chain_name || address).trim() || address; + bio = String(data?.profile?.bio || '').trim(); + } } catch {} return { - title: `${address} – Profile – Superhero`, - description: bio ? truncate(bio,200) : 'View profile on Superhero, the crypto social network.', + title: `${display} – Profile – Superhero`, + description: bio ? truncate(bio,200) : `View ${display} on Superhero, the crypto social network.`, canonical: `${origin}/users/${address}`, ogImage: `${origin}/og-default.png`, ogType: 'profile', diff --git a/src/@components/Address/AddressAvatarWithChainName.tsx b/src/@components/Address/AddressAvatarWithChainName.tsx index 419526460..75cd95cc7 100644 --- a/src/@components/Address/AddressAvatarWithChainName.tsx +++ b/src/@components/Address/AddressAvatarWithChainName.tsx @@ -12,6 +12,7 @@ import { useAccountBalances } from '@/hooks/useAccountBalances'; import { useChainName } from '@/hooks/useChainName'; import { cn } from '@/lib/utils'; import { Decimal } from '@/libs/decimal'; +import { resolveDisplayName } from '@/utils/displayName'; interface AddressAvatarWithChainNameProps { address: string; @@ -131,7 +132,10 @@ export const AddressAvatarWithChainName = memo(({ return null; } - const preferredName = (cachedProfile?.public_name || cachedProfile?.profile?.chain_name || chainName || '').trim(); + const preferredName = resolveDisplayName({ + publicName: cachedProfile?.public_name, + chainName: cachedProfile?.profile?.chain_name || chainName, + }); const avatarUrl = (cachedProfile?.profile?.avatarurl || '').trim() || null; const renderContent = () => ( @@ -173,19 +177,24 @@ export const AddressAvatarWithChainName = memo(({
{showPrimaryOnly ? ( (() => { - const displayName = preferredName || (!hideFallbackName ? 'Legend' : ''); - return displayName ? ( - - {displayName} - - ) : ( + if (preferredName) { + return ( + + {preferredName} + + ); + } + + if (hideFallbackName) return null; + + return ( - - {preferredName || (hideFallbackName ? '' : 'Legend')} + + {preferredName || (hideFallbackName ? '' : address)} +
setHover(true)} onMouseLeave={() => setHover(false)} onClick={handleClick} diff --git a/src/App.tsx b/src/App.tsx index f57f2453b..1f667a017 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,7 +8,6 @@ import ModalProvider from './components/ModalProvider'; import { useAeSdk, useAccount, useIsMobile, useWalletConnect, } from './hooks'; -import { useProfileFeed } from './hooks/useProfileFeed'; import { routes } from './routes'; import './styles/genz-components.scss'; import './styles/mobile-optimizations.scss'; @@ -39,7 +38,6 @@ const TipModal = React.lazy( const App = () => { const isMobile = useIsMobile(); useSuperheroChainNames(); - useProfileFeed({ refetchIntervalMs: 20_000 }); const { initSdk, activeAccount } = useAeSdk(); const { loadAccountData } = useAccount(); const { attemptReconnection } = useWalletConnect(); diff --git a/src/api/ProfileRegistryACI.json b/src/api/ProfileRegistryACI.json index 6364c4e5e..bf981986e 100644 --- a/src/api/ProfileRegistryACI.json +++ b/src/api/ProfileRegistryACI.json @@ -218,20 +218,6 @@ }, "stateful": true }, - { - "arguments": [ - { - "name": "source", - "type": "ProfileRegistry.display_source" - } - ], - "name": "set_display_source", - "payable": false, - "returns": { - "tuple": [] - }, - "stateful": true - }, { "arguments": [ { @@ -269,10 +255,6 @@ "int" ] } - }, - { - "name": "source", - "type": "ProfileRegistry.display_source" } ], "name": "set_profile_full", @@ -349,23 +331,6 @@ "typedef": "string", "vars": [] }, - { - "name": "display_source", - "typedef": { - "variant": [ - { - "Custom": [] - }, - { - "Chain": [] - }, - { - "X": [] - } - ] - }, - "vars": [] - }, { "name": "profile", "typedef": { @@ -406,10 +371,6 @@ ] } }, - { - "name": "display_source", - "type": "ProfileRegistry.display_source" - }, { "name": "chain_expires_at", "type": { diff --git a/src/api/backend.ts b/src/api/backend.ts index 5dedb3378..0c18facbd 100644 --- a/src/api/backend.ts +++ b/src/api/backend.ts @@ -52,11 +52,6 @@ export type ProfileAggregate = { public_name: string | null; }; -export type ProfileFeedResponse = { - items?: ProfileAggregate[]; - data?: ProfileAggregate[]; -} | ProfileAggregate[]; - export type XAttestationResponse = { signer: string; address: string; @@ -346,8 +341,6 @@ export const SuperheroApi = { const qp = new URLSearchParams(); if (includeOnChain != null) qp.set('includeOnChain', String(includeOnChain)); const query = qp.toString(); - // TODO: uncomment this when the backend is ready - return Promise.resolve(null); return this.fetchJson(`/api/profile/${encodeURIComponent(address)}${query ? `?${query}` : ''}`) as Promise; }, getProfilesByAddresses(addresses: string[], includeOnChain?: boolean) { @@ -356,14 +349,6 @@ export const SuperheroApi = { if (includeOnChain != null) qp.set('includeOnChain', String(includeOnChain)); return this.fetchJson(`/api/profile?${qp.toString()}`) as Promise; }, - getProfileFeed(limit = 500, offset = 0) { - const qp = new URLSearchParams(); - qp.set('limit', String(limit)); - qp.set('offset', String(offset)); - return Promise.resolve({ items: [], data: [] } as ProfileFeedResponse); - // TODO: uncomment this when the backend is ready - // return this.fetchJson(`/api/profile/feed?${qp.toString()}`) as Promise; - }, createXAttestation(address: string, accessToken: string) { return this.fetchJson('/api/profile/x/attestation', { method: 'POST', diff --git a/src/atoms/walletAtoms.ts b/src/atoms/walletAtoms.ts index 7f9ca90c2..50fdeafdd 100644 --- a/src/atoms/walletAtoms.ts +++ b/src/atoms/walletAtoms.ts @@ -22,8 +22,6 @@ export const aex9BalancesAtom = atomWithStorage>('wallet:a export const profileAtom = atom>({}); export const pinnedItemsAtom = atom([]); export const chainNamesAtom = atomWithStorage>('wallet:chainNames', {}); -/** Profile display names (public_name from /api/profile). Used for author labels in feed. */ -export const profileDisplayNamesAtom = atom>({}); export const verifiedUrlsAtom = atom([]); export const graylistedUrlsAtom = atom([]); export const tokenInfoAtom = atom>({}); diff --git a/src/components/Account/AccountFeed.tsx b/src/components/Account/AccountFeed.tsx index 04e3662c4..9d69a2af1 100644 --- a/src/components/Account/AccountFeed.tsx +++ b/src/components/Account/AccountFeed.tsx @@ -9,6 +9,8 @@ import { import { useInfiniteQuery } from '@tanstack/react-query'; import { SuperheroApi } from '@/api/backend'; import type { PostDto } from '@/api/generated'; +import { useAccountDisplayNames } from '@/hooks/useAccountDisplayNames'; +import { getPostSenderAddress } from '@/features/social/utils/postSender'; interface AccountFeedProps { address: string; @@ -126,6 +128,18 @@ const AccountFeed = ({ address, tab }: AccountFeedProps) => { }); }, [createdActivities, list]); + const tokenCreatedAddresses = useMemo( + () => combinedList + .filter((item) => String(item.id).startsWith('token-created:')) + .map((item) => getPostSenderAddress(item)) + .filter(Boolean), + [combinedList], + ); + + const { getHeaderLabel: getTokenCreatedHeaderLabel } = useAccountDisplayNames( + tokenCreatedAddresses, + ); + const sentinelRef = useRef(null); const fetchingRef = useRef(false); const initialLoading = aLoading || isLoading; @@ -245,6 +259,10 @@ const AccountFeed = ({ address, tab }: AccountFeedProps) => { { } } return nodes; - }, [combinedList, expandedGroups, navigate, toggleGroup]); + }, [combinedList, expandedGroups, navigate, toggleGroup, getTokenCreatedHeaderLabel]); return (
diff --git a/src/components/Identicon.tsx b/src/components/Identicon.tsx index 0cb3f51e7..5d9e36e86 100644 --- a/src/components/Identicon.tsx +++ b/src/components/Identicon.tsx @@ -9,8 +9,8 @@ type IdenticonProps = { }; const Identicon = ({ address, size = 32, name }: IdenticonProps) => { - // Check if this is a .chain name (has a name and it's not 'Legend') - const isChainName = name && name !== 'Legend' && name !== address; + // Treat names distinct from the raw address as display names. + const isChainName = name && name !== address; if (isChainName) { // Use multiavatar for .chain names diff --git a/src/components/modals/TipModal.tsx b/src/components/modals/TipModal.tsx index 5026bcb90..95d5c30ea 100644 --- a/src/components/modals/TipModal.tsx +++ b/src/components/modals/TipModal.tsx @@ -5,12 +5,12 @@ import { AddressAvatarWithChainName } from '@/@components/Address/AddressAvatarW import { encode, Encoded, Encoding } from '@aeternity/aepp-sdk'; import { useAtom } from 'jotai'; import { useQueryClient } from '@tanstack/react-query'; +import { useAccountDisplayName } from '@/hooks/useAccountDisplayName'; import { useAccount, useAeSdk } from '../../hooks'; import { toAettos, fromAettos } from '../../libs/dex'; import { Decimal } from '../../libs/decimal'; import AeButton from '../AeButton'; import { IconDiamond } from '../../icons'; -import { useChainName } from '../../hooks/useChainName'; import { tipStatusAtom, makeTipKey } from '../../atoms/tipAtoms'; const TipModal = ({ @@ -24,7 +24,8 @@ const TipModal = ({ }) => { const { sdk, activeAccount, activeNetwork } = useAeSdk(); const { balance } = useAccount(); - const { chainName } = useChainName(toAddress); + const { displayName } = useAccountDisplayName(toAddress); + const hasDistinctDisplayName = !!displayName && displayName !== toAddress; const [, setTipStatus] = useAtom(tipStatusAtom); const queryClient = useQueryClient(); @@ -289,8 +290,8 @@ const TipModal = ({ isHoverEnabled={false} />
- {chainName && ( -
{chainName}
+ {hasDistinctDisplayName && ( +
{displayName}
)}
{toAddress}
diff --git a/src/components/wallet/WalletOverviewCard.tsx b/src/components/wallet/WalletOverviewCard.tsx index 84d1e469b..9f68e6b78 100644 --- a/src/components/wallet/WalletOverviewCard.tsx +++ b/src/components/wallet/WalletOverviewCard.tsx @@ -197,38 +197,40 @@ const WalletOverviewCard = ({
-
- - {balanceAe.toLocaleString(undefined, { maximumFractionDigits: 6 })} - {' '} - AE - {aeFiat != null && ( - <> +
+
+ + {balanceAe.toLocaleString(undefined, { maximumFractionDigits: 6 })} {' '} - · - {' '} - - ≈ + AE + {aeFiat != null && ( + <> {' '} - {formatPrice(aeFiat, selectedCurrency)} - - - )} -
- )} - /> + · + {' '} + + ≈ + {' '} + {formatPrice(aeFiat, selectedCurrency)} + + + )} +
+ )} + /> +
-
+
{ const { t } = useTranslation(['common', 'social']); const postId = item.id; - const authorAddress = item.sender_address; - const { chainNames, profileDisplayNames } = useWallet(); - const displayName = profileDisplayNames?.[authorAddress] ?? chainNames?.[authorAddress] ?? t('common:defaultDisplayName'); + const authorAddress = getPostSenderAddress(item); + const { chainNames } = useWallet(); + const senderDisplayName = getPostSenderDisplayName(item); + const senderAvatarUrl = getPostSenderAvatarUrl(item); + const fallbackDisplayName = resolveDisplayName({ + chainName: chainNames?.[authorAddress], + address: authorAddress, + }) || t('common:defaultDisplayName'); + const displayName = senderDisplayName || fallbackDisplayName; + const headerLabel = getPostSenderHeaderLabel(item, fallbackDisplayName); const parentId = useParentId(item); const [parent, setParent] = useState(null); @@ -201,7 +214,7 @@ const ReplyToFeedItem = memo(({
- +
- +
{/* Header: name · handle (wide desktop) · time */}
-
{displayName}
- - @ - {authorAddress} - +
{headerLabel || displayName}
· {item.tx_hash ? ( -
- {formatAddress(authorAddress, 4, true)} -
- {/* Trend token holder pill (when viewing a token feed and author holds the token) */} {tokenHolderLabel && (
@@ -279,15 +284,22 @@ const ReplyToFeedItem = memo(({ {t('replyingTo')}
-
- {parent ? (profileDisplayNames?.[parent.sender_address] ?? chainNames?.[parent.sender_address] ?? t('common:defaultDisplayName')) : t('parent')} + {parent ? ( + getPostSenderHeaderLabel( + parent, + resolveDisplayName({ + chainName: chainNames?.[getPostSenderAddress(parent)], + address: getPostSenderAddress(parent), + }) || t('common:defaultDisplayName'), + ) + ) : t('parent')}
· diff --git a/src/features/social/components/TokenCreatedActivityItem.tsx b/src/features/social/components/TokenCreatedActivityItem.tsx index 39caae999..98b20d3a8 100644 --- a/src/features/social/components/TokenCreatedActivityItem.tsx +++ b/src/features/social/components/TokenCreatedActivityItem.tsx @@ -1,15 +1,22 @@ import { memo, useMemo, useCallback } from 'react'; -import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { AddressAvatarWithChainName } from '@/@components/Address/AddressAvatarWithChainName'; +import { useNavigate } from 'react-router-dom'; +import AddressAvatar from '@/components/AddressAvatar'; +import { resolveDisplayName } from '@/utils/displayName'; import { linkify } from '../../../utils/linkify'; import { useWallet } from '../../../hooks'; import type { PostDto } from '../../../api/generated'; import { compactTime } from '../../../utils/time'; +import { + getPostSenderAddress, + getPostSenderAvatarUrl, + getPostSenderHeaderLabel, +} from '../utils/postSender'; // SharePopover removed from activity row per design interface TokenCreatedActivityItemProps { item: PostDto; + displayName?: string; hideMobileDivider?: boolean; mobileTight?: boolean; // reduce vertical padding on mobile for middle items in a group // optional mobile-only footer area (e.g., Show more) rendered just above divider @@ -36,6 +43,7 @@ function useTokenName(item: PostDto): string | null { const TokenCreatedActivityItem = memo(({ item, + displayName, hideMobileDivider = false, mobileTight = false, footer, mobileNoTopPadding = false, @@ -45,9 +53,16 @@ const TokenCreatedActivityItem = memo(({ }: TokenCreatedActivityItemProps) => { const { t } = useTranslation('common'); const navigate = useNavigate(); - const { chainNames, profileDisplayNames } = useWallet(); - const creator = item.sender_address; - const displayName = profileDisplayNames?.[creator] ?? chainNames?.[creator] ?? t('defaultDisplayName'); + const { chainNames } = useWallet(); + const creator = getPostSenderAddress(item); + const senderAvatarUrl = getPostSenderAvatarUrl(item); + const fallbackDisplayName = resolveDisplayName({ + chainName: chainNames?.[creator], + address: creator, + }) || t('defaultDisplayName'); + const resolvedDisplayName = displayName + || getPostSenderHeaderLabel(item, fallbackDisplayName) + || fallbackDisplayName; const tokenName = useTokenName(item); const tokenLink = tokenName ? `/trends/tokens/${tokenName}` : undefined; @@ -67,15 +82,15 @@ const TokenCreatedActivityItem = memo(({ >
- +
e.stopPropagation()} className="font-semibold text-white/90 truncate whitespace-nowrap max-w-[22ch] no-gradient-text" - title={displayName} + title={resolvedDisplayName} > - {displayName} + {resolvedDisplayName} created {tokenName && ( diff --git a/src/features/social/components/TokenCreatedFeedItem.tsx b/src/features/social/components/TokenCreatedFeedItem.tsx index 1316ca56a..61089ed32 100644 --- a/src/features/social/components/TokenCreatedFeedItem.tsx +++ b/src/features/social/components/TokenCreatedFeedItem.tsx @@ -1,14 +1,20 @@ -import { AddressAvatarWithChainName } from '@/@components/Address/AddressAvatarWithChainName'; +import AddressAvatar from '@/components/AddressAvatar'; import { cn } from '@/lib/utils'; import { memo, useMemo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { linkify } from '../../../utils/linkify'; +import { resolveDisplayName } from '@/utils/displayName'; import { BlockchainInfoPopover } from './BlockchainInfoPopover'; import SharePopover from './SharePopover'; import { useWallet } from '../../../hooks'; import type { PostDto } from '../../../api/generated'; import { compactTime, fullTimestamp } from '../../../utils/time'; +import { + getPostSenderAddress, + getPostSenderAvatarUrl, + getPostSenderHeaderLabel, +} from '../utils/postSender'; interface TokenCreatedFeedItemProps { item: PostDto; @@ -32,10 +38,15 @@ function useTokenName(item: PostDto): string | null { // Token-created feed item rendered similar to a reply with a header box const TokenCreatedFeedItem = memo(({ item, onOpenPost }: TokenCreatedFeedItemProps) => { const postId = item.id; - const authorAddress = item.sender_address; + const authorAddress = getPostSenderAddress(item); const { t } = useTranslation(['common', 'social']); - const { chainNames, profileDisplayNames } = useWallet(); - const displayName = profileDisplayNames?.[authorAddress] ?? chainNames?.[authorAddress] ?? t('common:defaultDisplayName'); + const { chainNames } = useWallet(); + const senderAvatarUrl = getPostSenderAvatarUrl(item); + const fallbackDisplayName = resolveDisplayName({ + chainName: chainNames?.[authorAddress], + address: authorAddress, + }) || t('common:defaultDisplayName'); + const displayName = getPostSenderHeaderLabel(item, fallbackDisplayName) || fallbackDisplayName; const tokenName = useTokenName(item); const tokenLink = tokenName ? `/trends/tokens/${tokenName}` : undefined; @@ -64,7 +75,7 @@ const TokenCreatedFeedItem = memo(({ item, onOpenPost }: TokenCreatedFeedItemPro
- +
- +
@@ -92,7 +103,6 @@ const TokenCreatedFeedItem = memo(({ item, onOpenPost }: TokenCreatedFeedItemPro
{compactTime(item.created_at as unknown as string)}
-
{authorAddress}
{/* Tokenized trend header */}
{ +const TradeActivityItem = memo(({ item, displayName }: TradeActivityItemProps) => { const { t } = useTranslation('social'); const navigate = useNavigate(); - const { chainNames, profileDisplayNames } = useWallet(); const account = item.account || item.address || ''; - const displayName = profileDisplayNames?.[account] ?? chainNames?.[account] ?? t('common:defaultDisplayName'); + const resolvedDisplayName = displayName || t('common:defaultDisplayName'); const tokenName = item?.token?.name || item?.token?.symbol || ''; const tokenLabel = item?.token?.symbol || item?.token?.name || 'Token'; const tokenTag = tokenName || tokenLabel; @@ -102,21 +101,23 @@ const TradeActivityItem = memo(({ item }: TradeActivityItemProps) => {
- +
- +
-
{displayName}
+
{resolvedDisplayName}
·
{compactTime(item.created_at)}
-
{account}
+ {account && ( +
{account}
+ )}
diff --git a/src/features/social/utils/postSender.ts b/src/features/social/utils/postSender.ts new file mode 100644 index 000000000..0f53831c2 --- /dev/null +++ b/src/features/social/utils/postSender.ts @@ -0,0 +1,39 @@ +import type { PostDto } from '@/api/generated'; + +export type PostSenderInfo = { + address?: string | null; + public_name?: string | null; + avatarurl?: string | null; + bio?: string | null; + display_source?: string | null; +}; + +export function getPostSender(post: PostDto | null | undefined): PostSenderInfo | null { + const sender = (post as any)?.sender; + if (!sender || typeof sender !== 'object') return null; + return sender as PostSenderInfo; +} + +export function getPostSenderAddress(post: PostDto | null | undefined): string { + const senderAddress = String(getPostSender(post)?.address || '').trim(); + if (senderAddress) return senderAddress; + return String((post as any)?.sender_address || (post as any)?.senderAddress || '').trim(); +} + +export function getPostSenderDisplayName(post: PostDto | null | undefined): string { + return String(getPostSender(post)?.public_name || '').trim(); +} + +export function getPostSenderHeaderLabel( + post: PostDto | null | undefined, + fallbackLabel?: string | null, +): string { + const publicName = getPostSenderDisplayName(post); + if (publicName) return `@${publicName}`; + return String(fallbackLabel || '').trim(); +} + +export function getPostSenderAvatarUrl(post: PostDto | null | undefined): string | null { + const avatarUrl = String(getPostSender(post)?.avatarurl || '').trim(); + return avatarUrl || null; +} diff --git a/src/features/social/views/FeedList.tsx b/src/features/social/views/FeedList.tsx index 02d856782..649e265ce 100644 --- a/src/features/social/views/FeedList.tsx +++ b/src/features/social/views/FeedList.tsx @@ -4,6 +4,7 @@ import React, { import { useLocation, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; +import { useAccountDisplayNames } from '@/hooks/useAccountDisplayNames'; import { PostsService } from '../../../api/generated'; import type { PostDto } from '../../../api/generated'; import { SuperheroApi } from '../../../api/backend'; @@ -25,6 +26,7 @@ import { Head } from '../../../seo/Head'; import { CONFIG } from '../../../config'; import { useLatestTransactions } from '../../../hooks/useLatestTransactions'; import type { TokenDto } from '../../../api/generated/models/TokenDto'; +import { getPostSenderAddress } from '../utils/postSender'; // Custom hook function useUrlQuery() { @@ -236,7 +238,8 @@ const FeedList = ({ return (latestTransactions || []) .filter((tx) => String(tx?.tx_type || '').toLowerCase() === 'buy') .map((tx) => { - const fallbackId = `${tx?.created_at || ''}:${tx?.account || tx?.address || ''}:${tx?.volume || ''}`; + const accountAddress = String((tx as any)?.account || tx?.address || '').trim(); + const fallbackId = `${tx?.created_at || ''}:${accountAddress}:${tx?.volume || ''}`; const id = `trade:${tx?.tx_hash || tx?.id || fallbackId}`; return { kind: 'trade' as const, @@ -244,7 +247,7 @@ const FeedList = ({ created_at: tx?.created_at || new Date().toISOString(), tx_hash: tx?.tx_hash || '', tx_type: tx?.tx_type || 'buy', - account: tx?.account || tx?.address || '', + account: accountAddress, volume: tx?.volume || '0', priceUsd: (tx as any)?.buy_price?.usd ?? (tx as any)?.price_data?.usd ?? (tx as any)?.price ?? '', token: (tx as any)?.token || null, @@ -768,6 +771,32 @@ const FeedList = ({ return filtered; }, [combinedList, filterBy, isTradeItem]); + const tradeAddresses = useMemo( + () => filteredAndSortedList + .filter((item) => isTradeItem(item)) + .map((item) => String( + (item as TradeActivityItemData).account + || (item as TradeActivityItemData).address + || '', + ).trim()) + .filter(Boolean), + [filteredAndSortedList, isTradeItem], + ); + + const { getDisplayName: getTradeDisplayName } = useAccountDisplayNames(tradeAddresses); + + const tokenCreatedAddresses = useMemo( + () => filteredAndSortedList + .filter((item) => !isTradeItem(item) && String((item as PostDto).id).startsWith('token-created:')) + .map((item) => getPostSenderAddress(item as PostDto)) + .filter(Boolean), + [filteredAndSortedList, isTradeItem], + ); + + const { getHeaderLabel: getTokenCreatedHeaderLabel } = useAccountDisplayNames( + tokenCreatedAddresses, + ); + // Memoized event handlers for better performance const handleSortChange = useCallback( (newSortBy: string) => { @@ -947,8 +976,16 @@ const FeedList = ({ const item = filteredAndSortedList[i]; if (isTradeItem(item)) { maybeInsertTrending(); + const account = String(item.account || item.address || '').trim(); nodes.push( - , + , ); i += 1; renderedCount += 1; @@ -1018,6 +1055,10 @@ const FeedList = ({ { const nodes: React.ReactNode[] = []; @@ -1071,12 +1121,32 @@ const FeedList = ({ // eslint-disable-next-line no-restricted-syntax for (const item of filteredAndSortedList) { maybeInsertTrending(); - nodes.push(renderPostItem(item as PostDto)); + if (isTradeItem(item)) { + const account = String(item.account || item.address || '').trim(); + nodes.push( + , + ); + } else { + nodes.push(renderPostItem(item as PostDto)); + } renderedCount += 1; } return nodes; - }, [filteredAndSortedList, renderPostItem]); + }, [ + filteredAndSortedList, + renderPostItem, + isTradeItem, + getTradeDisplayName, + t, + ]); // Preload PostDetail chunk to avoid first-click lazy load delay useEffect(() => { diff --git a/src/features/social/views/PostDetail.tsx b/src/features/social/views/PostDetail.tsx index 550d0d721..d42321878 100644 --- a/src/features/social/views/PostDetail.tsx +++ b/src/features/social/views/PostDetail.tsx @@ -4,8 +4,7 @@ import { useNavigate, useParams, Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Decimal } from '@/libs/decimal'; import { useAeSdk } from '@/hooks/useAeSdk'; -import { useWallet } from '@/hooks'; -import { formatAddress } from '@/utils/address'; +import { useAccountDisplayNames } from '@/hooks/useAccountDisplayNames'; import { Head } from '../../../seo/Head'; import { PostsService, PostDto } from '../../../api/generated'; import AeButton from '../../../components/AeButton'; @@ -21,9 +20,20 @@ import { resolvePostByKey } from '../utils/resolvePost'; import { usePostTipSummary } from '../hooks/usePostTipSummary'; import { usePostTips } from '../hooks/usePostTips'; +const PostTipSenderLink = ({ address, label }: { address: string; label: string }) => { + return ( + + {label || address} + + ); +}; + const PostTipOverview = ({ post, explorerUrl }: { post: any; explorerUrl?: string }) => { const { t } = useTranslation('social'); - const { chainNames } = useWallet(); const postId = String(post?.id || ''); const receiver = String(post?.sender_address || post?.senderAddress || ''); @@ -33,6 +43,8 @@ const PostTipOverview = ({ post, explorerUrl }: { post: any; explorerUrl?: strin const { data: tips = [], isLoading } = usePostTips(postId, receiver); const top = tips.slice(0, 10); + const tipSenderAddresses = top.map((tip) => String(tip.sender || '').trim()).filter(Boolean); + const { getDisplayName: getTipSenderDisplayName } = useAccountDisplayNames(tipSenderAddresses); const explorerBase = (explorerUrl || '').replace(/\/$/, ''); return ( @@ -55,28 +67,25 @@ const PostTipOverview = ({ post, explorerUrl }: { post: any; explorerUrl?: strin {!isLoading && tips.length > 0 && (
- {top.map((t) => ( -
+ {top.map((tip) => ( +
- - {chainNames?.[t.sender] || formatAddress(t.sender, 3, true)} - -
{t.date}
+ +
{tip.date}
@@ -278,7 +294,9 @@ const PostDetail = ({ standalone = true }: { standalone?: boolean } = {}) => { title={`Post on Superhero.com: "${(postData as any)?.content?.slice(0, 100) || 'Post'}"`} description={(postData as any)?.content?.slice(0, 160) || t('social:viewPostDefaultDescription')} canonicalPath={`/post/${(postData as any)?.slug || String((postData as any)?.id || slug).replace(/_v3$/, '')}`} - ogImage={(Array.isArray((postData as any)?.media) && (postData as any).media[0]) || undefined} + ogImage={ + (Array.isArray((postData as any)?.media) && (postData as any).media[0]) || undefined + } jsonLd={{ '@context': 'https://schema.org', '@type': 'SocialMediaPosting', diff --git a/src/hooks/index.ts b/src/hooks/index.ts index ed316a10c..2f224e9dd 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -14,6 +14,8 @@ export { useChart } from './useChart'; export { useOwnedTokens } from './useOwnedTokens'; export { usePortfolioValue } from './usePortfolioValue'; export { useIsMobile } from './useIsMobile'; +export { useAccountDisplayName } from './useAccountDisplayName'; +export { useAccountDisplayNames } from './useAccountDisplayNames'; // Re-export atoms for direct usage if needed export * from '../atoms/walletAtoms'; diff --git a/src/hooks/useAccountDisplayName.ts b/src/hooks/useAccountDisplayName.ts new file mode 100644 index 000000000..cbf9fb6bb --- /dev/null +++ b/src/hooks/useAccountDisplayName.ts @@ -0,0 +1,38 @@ +import { useQuery } from '@tanstack/react-query'; +import { SuperheroApi } from '@/api/backend'; +import { resolveDisplayName } from '@/utils/displayName'; +import { useChainName } from './useChainName'; + +type UseAccountDisplayNameOptions = { + enabled?: boolean; + staleTime?: number; + refetchInterval?: number | false; +}; + +export function useAccountDisplayName( + address: string, + options?: UseAccountDisplayNameOptions, +) { + const normalizedAddress = String(address || '').trim(); + const { chainName } = useChainName(normalizedAddress); + const query = useQuery({ + queryKey: ['SuperheroApi.getProfile', normalizedAddress], + queryFn: () => SuperheroApi.getProfile(normalizedAddress), + enabled: (options?.enabled ?? true) && !!normalizedAddress, + staleTime: options?.staleTime ?? 60_000, + refetchInterval: options?.refetchInterval, + refetchOnWindowFocus: false, + }); + + const displayName = resolveDisplayName({ + publicName: query.data?.public_name, + chainName: query.data?.profile?.chain_name || chainName, + address: normalizedAddress, + }); + + return { + ...query, + chainName, + displayName, + }; +} diff --git a/src/hooks/useAccountDisplayNames.ts b/src/hooks/useAccountDisplayNames.ts new file mode 100644 index 000000000..7536cfea0 --- /dev/null +++ b/src/hooks/useAccountDisplayNames.ts @@ -0,0 +1,126 @@ +import { useAtom } from 'jotai'; +import { + useEffect, useMemo, useCallback, useRef, +} from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { chainNamesAtom } from '@/atoms/walletAtoms'; +import { + type ProfileAggregate, + SuperheroApi, +} from '@/api/backend'; +import { resolveDisplayName } from '@/utils/displayName'; + +type UseAccountDisplayNamesOptions = { + enabled?: boolean; + staleTime?: number; + refetchInterval?: number | false; + includeOnChain?: boolean; +}; + +type GetDisplayNameOptions = { + fallbackToAddress?: boolean; + fallbackLabel?: string; +}; + +export function useAccountDisplayNames( + addresses: string[], + options?: UseAccountDisplayNamesOptions, +) { + const [chainNames, setChainNames] = useAtom(chainNamesAtom); + const queryClient = useQueryClient(); + const stableInputAddressesRef = useRef([]); + const stableNormalizedAddressesRef = useRef([]); + + const nextInputAddresses = (addresses || []).map((address) => String(address || '').trim()); + const inputAddressesChanged = stableInputAddressesRef.current.length !== nextInputAddresses.length + || stableInputAddressesRef.current.some((address, index) => address !== nextInputAddresses[index]); + + if (inputAddressesChanged) { + stableInputAddressesRef.current = nextInputAddresses; + stableNormalizedAddressesRef.current = Array.from( + new Set(nextInputAddresses.filter(Boolean)), + ).sort(); + } + + const normalizedAddresses = stableNormalizedAddressesRef.current; + + const query = useQuery({ + queryKey: [ + 'SuperheroApi.getProfilesByAddresses', + normalizedAddresses, + options?.includeOnChain ?? null, + ], + queryFn: () => SuperheroApi.getProfilesByAddresses( + normalizedAddresses, + options?.includeOnChain, + ), + enabled: (options?.enabled ?? true) && normalizedAddresses.length > 0, + staleTime: options?.staleTime ?? 60_000, + refetchInterval: options?.refetchInterval, + refetchOnWindowFocus: false, + }); + + const profilesByAddress = useMemo>( + () => (query.data || []).reduce>((acc, profile) => { + const address = String(profile?.address || '').trim(); + if (address) acc[address] = profile; + return acc; + }, {}), + [query.data], + ); + + useEffect(() => { + if (!query.data?.length) return; + + const nextChainNames: Record = {}; + + query.data.forEach((profile) => { + const address = String(profile?.address || '').trim(); + if (!address) return; + + queryClient.setQueryData(['SuperheroApi.getProfile', address], profile); + + const chainName = String(profile?.profile?.chain_name || '').trim(); + if (chainName) { + nextChainNames[address] = chainName; + } + }); + + if (Object.keys(nextChainNames).length) { + setChainNames((prev) => ({ ...prev, ...nextChainNames })); + } + }, [query.data, queryClient, setChainNames]); + + const getDisplayName = useCallback(( + address: string, + getOptions?: GetDisplayNameOptions, + ) => { + const normalizedAddress = String(address || '').trim(); + const profile = profilesByAddress[normalizedAddress]; + const fallbackLabel = String(getOptions?.fallbackLabel || '').trim(); + const displayName = resolveDisplayName({ + publicName: profile?.public_name, + chainName: profile?.profile?.chain_name || chainNames[normalizedAddress], + address: getOptions?.fallbackToAddress === false ? undefined : normalizedAddress, + }); + + return displayName || fallbackLabel; + }, [profilesByAddress, chainNames]); + + const getHeaderLabel = useCallback(( + address: string, + getOptions?: GetDisplayNameOptions, + ) => { + const normalizedAddress = String(address || '').trim(); + const publicName = String(profilesByAddress[normalizedAddress]?.public_name || '').trim(); + if (publicName) return `@${publicName}`; + return getDisplayName(normalizedAddress, getOptions); + }, [profilesByAddress, getDisplayName]); + + return { + ...query, + profilesByAddress, + getDisplayName, + getHeaderLabel, + }; +} diff --git a/src/hooks/useProfile.ts b/src/hooks/useProfile.ts index 71b7d71a7..8826e40e8 100644 --- a/src/hooks/useProfile.ts +++ b/src/hooks/useProfile.ts @@ -457,8 +457,6 @@ export function useProfile(targetAddress?: string) { toOption(normalizedUsername || null), toOption(normalizedChainName || null), toOption(hasValidChainExpiry ? nextChainExpiresAt : null), - // This display source is not used for now, but it is required by the contract. - { Custom: [] }, ], ); txHash = extractTxHash(fullResult) || txHash; diff --git a/src/hooks/useProfileFeed.ts b/src/hooks/useProfileFeed.ts deleted file mode 100644 index 296596eb5..000000000 --- a/src/hooks/useProfileFeed.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { useEffect, useMemo } from 'react'; -import { useAtom } from 'jotai'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { - type ProfileAggregate, - type ProfileFeedResponse, - SuperheroApi, -} from '@/api/backend'; -import { chainNamesAtom, profileDisplayNamesAtom } from '@/atoms/walletAtoms'; - -function normalizeProfileFeed(payload: ProfileFeedResponse | undefined): ProfileAggregate[] { - if (!payload) return []; - if (Array.isArray(payload)) return payload; - if (Array.isArray(payload.items)) return payload.items; - if (Array.isArray(payload.data)) return payload.data; - return []; -} - -export function useProfileFeed(options?: { - limit?: number; - offset?: number; - refetchIntervalMs?: number; -}) { - const queryClient = useQueryClient(); - const [, setChainNames] = useAtom(chainNamesAtom); - const [, setProfileDisplayNames] = useAtom(profileDisplayNamesAtom); - const limit = options?.limit ?? 500; - const offset = options?.offset ?? 0; - const refetchIntervalMs = options?.refetchIntervalMs ?? 20_000; - - const query = useQuery({ - queryKey: ['SuperheroApi.getProfileFeed', limit, offset], - queryFn: () => SuperheroApi.getProfileFeed(limit, offset), - staleTime: refetchIntervalMs, - refetchInterval: refetchIntervalMs, - refetchOnWindowFocus: false, - }); - - const profiles = useMemo( - () => normalizeProfileFeed(query.data), - [query.data], - ); - - useEffect(() => { - if (!profiles.length) return; - - const nextChainNames: Record = {}; - const nextDisplayNames: Record = {}; - profiles.forEach((profile) => { - if (!profile?.address) return; - queryClient.setQueryData(['SuperheroApi.getProfile', profile.address], profile); - - const chainName = (profile.profile?.chain_name || '').trim(); - if (chainName) { - nextChainNames[profile.address] = chainName; - } - const displayName = (profile.public_name || '').trim(); - if (displayName) { - nextDisplayNames[profile.address] = displayName; - } - }); - - if (Object.keys(nextChainNames).length) { - setChainNames((prev) => ({ ...prev, ...nextChainNames })); - } - if (Object.keys(nextDisplayNames).length) { - setProfileDisplayNames((prev) => ({ ...prev, ...nextDisplayNames })); - } - }, [profiles, queryClient, setChainNames, setProfileDisplayNames]); - - return query; -} diff --git a/src/hooks/useWallet.ts b/src/hooks/useWallet.ts index 72b7f416f..92614191c 100644 --- a/src/hooks/useWallet.ts +++ b/src/hooks/useWallet.ts @@ -10,7 +10,6 @@ import { profileAtom, pinnedItemsAtom, chainNamesAtom, - profileDisplayNamesAtom, verifiedUrlsAtom, graylistedUrlsAtom, tokenInfoAtom, @@ -32,7 +31,6 @@ export const useWallet = () => { const [profile, setProfile] = useAtom(profileAtom); const [pinnedItems, setPinnedItems] = useAtom(pinnedItemsAtom); const [chainNames, setChainNames] = useAtom(chainNamesAtom); - const [profileDisplayNames] = useAtom(profileDisplayNamesAtom); const [verifiedUrls, setVerifiedUrls] = useAtom(verifiedUrlsAtom); const [graylistedUrls, setGraylistedUrls] = useAtom(graylistedUrlsAtom); const [tokenInfo, setTokenInfo] = useAtom(tokenInfoAtom); @@ -82,7 +80,6 @@ export const useWallet = () => { profile, pinnedItems, chainNames, - profileDisplayNames, verifiedUrls, graylistedUrls, tokenInfo, diff --git a/src/utils/__tests__/xOAuth.test.ts b/src/utils/__tests__/xOAuth.test.ts new file mode 100644 index 000000000..92d5e2297 --- /dev/null +++ b/src/utils/__tests__/xOAuth.test.ts @@ -0,0 +1,123 @@ +import { + afterEach, describe, expect, it, vi, +} from 'vitest'; +import { + computeCodeChallenge, + getAndClearXOAuthPKCE, + storeXOAuthPKCE, +} from '@/utils/xOAuth'; + +describe('xOAuth', () => { + afterEach(() => { + window.sessionStorage.clear(); + window.localStorage.clear(); + vi.unstubAllGlobals(); + }); + + it('uses Web Crypto when subtle.digest is available', async () => { + const digest = vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3]).buffer); + vi.stubGlobal('crypto', { subtle: { digest } }); + + await expect(computeCodeChallenge('verifier')).resolves.toBe('AQID'); + expect(digest).toHaveBeenCalledWith('SHA-256', digest.mock.calls[0][1]); + expect(ArrayBuffer.isView(digest.mock.calls[0][1])).toBe(true); + }); + + it('falls back when subtle.digest is unavailable', async () => { + vi.stubGlobal('crypto', {}); + + await expect(computeCodeChallenge('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk')) + .resolves + .toBe('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'); + }); + + it('persists OAuth state in both session and local storage', () => { + storeXOAuthPKCE({ + state: 'superhero_x_state_1', + codeVerifier: 'verifier', + address: 'ak_test', + redirectUri: 'https://example.com/profile/x/callback', + }); + + expect(window.sessionStorage.getItem('superhero_x_oauth_pkce')).toContain('"state":"superhero_x_state_1"'); + expect(window.localStorage.getItem('superhero_x_oauth_pkce')).toContain('"state":"superhero_x_state_1"'); + }); + + it('reads fallback OAuth state from local storage and clears both stores', () => { + const payload = JSON.stringify({ + state: 'superhero_x_state_2', + codeVerifier: 'verifier-2', + address: 'ak_test_2', + redirectUri: 'https://example.com/profile/x/callback', + createdAt: Date.now(), + }); + window.localStorage.setItem('superhero_x_oauth_pkce', payload); + + expect(getAndClearXOAuthPKCE()).toEqual({ + state: 'superhero_x_state_2', + codeVerifier: 'verifier-2', + address: 'ak_test_2', + redirectUri: 'https://example.com/profile/x/callback', + }); + expect(window.sessionStorage.getItem('superhero_x_oauth_pkce')).toBeNull(); + expect(window.localStorage.getItem('superhero_x_oauth_pkce')).toBeNull(); + }); + + it('prefers session storage when both stores are present', () => { + window.sessionStorage.setItem('superhero_x_oauth_pkce', JSON.stringify({ + state: 'superhero_x_state_session', + codeVerifier: 'session-verifier', + address: 'ak_session', + redirectUri: 'https://example.com/profile/x/callback', + createdAt: Date.now(), + })); + window.localStorage.setItem('superhero_x_oauth_pkce', JSON.stringify({ + state: 'superhero_x_state_local', + codeVerifier: 'local-verifier', + address: 'ak_local', + redirectUri: 'https://example.com/profile/x/callback', + createdAt: Date.now(), + })); + + expect(getAndClearXOAuthPKCE()).toEqual({ + state: 'superhero_x_state_session', + codeVerifier: 'session-verifier', + address: 'ak_session', + redirectUri: 'https://example.com/profile/x/callback', + }); + }); + + it('falls back to local storage when session payload is malformed', () => { + window.sessionStorage.setItem('superhero_x_oauth_pkce', '{"broken":'); + window.localStorage.setItem('superhero_x_oauth_pkce', JSON.stringify({ + state: 'superhero_x_state_local_fallback', + codeVerifier: 'local-fallback', + address: 'ak_local_fallback', + redirectUri: 'https://example.com/profile/x/callback', + createdAt: Date.now(), + })); + + expect(getAndClearXOAuthPKCE()).toEqual({ + state: 'superhero_x_state_local_fallback', + codeVerifier: 'local-fallback', + address: 'ak_local_fallback', + redirectUri: 'https://example.com/profile/x/callback', + }); + }); + + it('rejects expired payloads and clears both stores', () => { + const expired = JSON.stringify({ + state: 'superhero_x_state_expired', + codeVerifier: 'expired-verifier', + address: 'ak_expired', + redirectUri: 'https://example.com/profile/x/callback', + createdAt: Date.now() - (16 * 60 * 1000), + }); + window.sessionStorage.setItem('superhero_x_oauth_pkce', expired); + window.localStorage.setItem('superhero_x_oauth_pkce', expired); + + expect(getAndClearXOAuthPKCE()).toBeNull(); + expect(window.sessionStorage.getItem('superhero_x_oauth_pkce')).toBeNull(); + expect(window.localStorage.getItem('superhero_x_oauth_pkce')).toBeNull(); + }); +}); diff --git a/src/utils/displayName.ts b/src/utils/displayName.ts new file mode 100644 index 000000000..3729e9ae5 --- /dev/null +++ b/src/utils/displayName.ts @@ -0,0 +1,17 @@ +type ResolveDisplayNameInput = { + publicName?: string | null; + chainName?: string | null; + address?: string | null; +}; + +const normalizeDisplayName = (value?: string | null) => String(value || '').trim(); + +export function resolveDisplayName({ + publicName, + chainName, + address, +}: ResolveDisplayNameInput): string { + return normalizeDisplayName(publicName) + || normalizeDisplayName(chainName) + || normalizeDisplayName(address); +} diff --git a/src/utils/xOAuth.ts b/src/utils/xOAuth.ts index d6bf34c4b..9898a56d3 100644 --- a/src/utils/xOAuth.ts +++ b/src/utils/xOAuth.ts @@ -24,6 +24,41 @@ export function getXCallbackRedirectUri(): string { return `${origin}${X_OAUTH_CALLBACK_PATH}`; } +function getCryptoApi(): Crypto | undefined { + if (typeof globalThis === 'undefined') return undefined; + return globalThis.crypto; +} + +const SHA256_INITIAL = [ + 0x6a09e667, + 0xbb67ae85, + 0x3c6ef372, + 0xa54ff53a, + 0x510e527f, + 0x9b05688c, + 0x1f83d9ab, + 0x5be0cd19, +]; + +const SHA256_K = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, + 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, + 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, + 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, + 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, + 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, + 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, + 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, + 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]; + function base64UrlEncode(bytes: Uint8Array): string { const bin = Array.from(bytes) .map((b) => String.fromCodePoint(b)) @@ -31,11 +66,82 @@ function base64UrlEncode(bytes: Uint8Array): string { return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } +function rightRotate(value: number, shift: number): number { + return (value >>> shift) | (value << (32 - shift)); +} + +function sha256Bytes(message: Uint8Array): Uint8Array { + const paddedLength = Math.ceil((message.length + 9) / 64) * 64; + const padded = new Uint8Array(paddedLength); + padded.set(message); + padded[message.length] = 0x80; + + const bitLength = message.length * 8; + const view = new DataView(padded.buffer); + view.setUint32(paddedLength - 8, Math.floor(bitLength / 0x100000000), false); + view.setUint32(paddedLength - 4, bitLength >>> 0, false); + + const state = [...SHA256_INITIAL]; + const words = new Uint32Array(64); + + for (let offset = 0; offset < paddedLength; offset += 64) { + for (let i = 0; i < 16; i += 1) { + words[i] = view.getUint32(offset + i * 4, false); + } + for (let i = 16; i < 64; i += 1) { + const s0 = rightRotate(words[i - 15], 7) + ^ rightRotate(words[i - 15], 18) + ^ (words[i - 15] >>> 3); + const s1 = rightRotate(words[i - 2], 17) + ^ rightRotate(words[i - 2], 19) + ^ (words[i - 2] >>> 10); + words[i] = (((words[i - 16] + s0) >>> 0) + ((words[i - 7] + s1) >>> 0)) >>> 0; + } + + let [a, b, c, d, e, f, g, h] = state; + + for (let i = 0; i < 64; i += 1) { + const sum1 = rightRotate(e, 6) ^ rightRotate(e, 11) ^ rightRotate(e, 25); + const choice = (e & f) ^ (~e & g); + const temp1 = ((((h + sum1) >>> 0) + ((choice + SHA256_K[i]) >>> 0)) + words[i]) >>> 0; + const sum0 = rightRotate(a, 2) ^ rightRotate(a, 13) ^ rightRotate(a, 22); + const majority = (a & b) ^ (a & c) ^ (b & c); + const temp2 = (sum0 + majority) >>> 0; + + h = g; + g = f; + f = e; + e = (d + temp1) >>> 0; + d = c; + c = b; + b = a; + a = (temp1 + temp2) >>> 0; + } + + state[0] = (state[0] + a) >>> 0; + state[1] = (state[1] + b) >>> 0; + state[2] = (state[2] + c) >>> 0; + state[3] = (state[3] + d) >>> 0; + state[4] = (state[4] + e) >>> 0; + state[5] = (state[5] + f) >>> 0; + state[6] = (state[6] + g) >>> 0; + state[7] = (state[7] + h) >>> 0; + } + + const output = new Uint8Array(32); + const outputView = new DataView(output.buffer); + state.forEach((value, index) => { + outputView.setUint32(index * 4, value, false); + }); + return output; +} + /** Generate a random code_verifier (43–128 chars). */ export function generateCodeVerifier(): string { const array = new Uint8Array(32); - if (typeof crypto !== 'undefined' && crypto.getRandomValues) { - crypto.getRandomValues(array); + const cryptoApi = getCryptoApi(); + if (cryptoApi?.getRandomValues) { + cryptoApi.getRandomValues(array); } else { for (let i = 0; i < array.length; i += 1) array[i] = Math.floor(Math.random() * 256); } @@ -46,8 +152,12 @@ export function generateCodeVerifier(): string { export async function computeCodeChallenge(verifier: string): Promise { const encoder = new TextEncoder(); const data = encoder.encode(verifier); - const hash = await crypto.subtle.digest('SHA-256', data); - return base64UrlEncode(new Uint8Array(hash)); + const subtle = getCryptoApi()?.subtle; + if (subtle?.digest) { + const hash = await subtle.digest('SHA-256', data); + return base64UrlEncode(new Uint8Array(hash)); + } + return base64UrlEncode(sha256Bytes(data)); } const X_OAUTH_STATE_PREFIX = 'superhero_x_'; @@ -55,8 +165,9 @@ const X_OAUTH_STATE_PREFIX = 'superhero_x_'; /** Generate a short random state and return full state string (includes prefix for validation). */ export function generateOAuthState(): string { const array = new Uint8Array(16); - if (typeof crypto !== 'undefined' && crypto.getRandomValues) { - crypto.getRandomValues(array); + const cryptoApi = getCryptoApi(); + if (cryptoApi?.getRandomValues) { + cryptoApi.getRandomValues(array); } else { for (let i = 0; i < array.length; i += 1) array[i] = Math.floor(Math.random() * 256); } @@ -68,6 +179,9 @@ export function isOurOAuthState(state: string): boolean { } export const X_OAUTH_STORAGE_KEY = 'superhero_x_oauth_pkce'; +const X_OAUTH_STORAGE_TTL_MS = 15 * 60 * 1000; + +type SimpleStorage = Pick; export type XOAuthStored = { state: string; @@ -76,25 +190,94 @@ export type XOAuthStored = { redirectUri: string; }; -export function storeXOAuthPKCE(data: XOAuthStored): void { +type XOAuthStoredEnvelope = XOAuthStored & { + createdAt: number; +}; + +function getStorage(kind: 'session' | 'local'): SimpleStorage | null { + if (typeof window === 'undefined') return null; try { - sessionStorage.setItem(X_OAUTH_STORAGE_KEY, JSON.stringify(data)); + return kind === 'session' ? window.sessionStorage : window.localStorage; + } catch { + return null; + } +} + +function writeOAuthStorage(storage: SimpleStorage | null, payload: XOAuthStoredEnvelope): void { + if (!storage) return; + try { + storage.setItem(X_OAUTH_STORAGE_KEY, JSON.stringify(payload)); } catch { // ignore } } -export function getAndClearXOAuthPKCE(): XOAuthStored | null { +function clearOAuthStorage(storage: SimpleStorage | null): void { + if (!storage) return; try { - const raw = sessionStorage.getItem(X_OAUTH_STORAGE_KEY); - sessionStorage.removeItem(X_OAUTH_STORAGE_KEY); + storage.removeItem(X_OAUTH_STORAGE_KEY); + } catch { + // ignore + } +} + +function readOAuthStorage(storage: SimpleStorage | null): XOAuthStoredEnvelope | null { + if (!storage) return null; + try { + const raw = storage.getItem(X_OAUTH_STORAGE_KEY); if (!raw) return null; - return JSON.parse(raw) as XOAuthStored; + const parsed = JSON.parse(raw) as Partial; + if ( + typeof parsed?.state !== 'string' + || typeof parsed?.codeVerifier !== 'string' + || typeof parsed?.address !== 'string' + || typeof parsed?.redirectUri !== 'string' + ) { + clearOAuthStorage(storage); + return null; + } + const createdAt = Number(parsed.createdAt); + if (!Number.isFinite(createdAt) || Date.now() - createdAt > X_OAUTH_STORAGE_TTL_MS) { + clearOAuthStorage(storage); + return null; + } + return { + state: parsed.state, + codeVerifier: parsed.codeVerifier, + address: parsed.address, + redirectUri: parsed.redirectUri, + createdAt, + }; } catch { + clearOAuthStorage(storage); return null; } } +export function storeXOAuthPKCE(data: XOAuthStored): void { + const payload: XOAuthStoredEnvelope = { + ...data, + createdAt: Date.now(), + }; + writeOAuthStorage(getStorage('session'), payload); + writeOAuthStorage(getStorage('local'), payload); +} + +export function getAndClearXOAuthPKCE(): XOAuthStored | null { + const session = getStorage('session'); + const local = getStorage('local'); + const stored = readOAuthStorage(session) || readOAuthStorage(local); + clearOAuthStorage(session); + clearOAuthStorage(local); + if (!stored) return null; + return { + state: stored.state, + codeVerifier: stored.codeVerifier, + address: stored.address, + redirectUri: stored.redirectUri, + }; +} + /** Build the X OAuth 2.0 authorize URL (PKCE). Redirect user here to start "Connect X". */ export async function buildXAuthorizeUrl(params: { clientId: string; diff --git a/src/views/UserProfile.tsx b/src/views/UserProfile.tsx index 7b3dee792..3c3b4163e 100644 --- a/src/views/UserProfile.tsx +++ b/src/views/UserProfile.tsx @@ -43,6 +43,7 @@ import { CONFIG } from '../config'; import { useModal } from '../hooks'; import { useProfile } from '../hooks/useProfile'; import { IconDiamond, IconLink } from '../icons'; +import { resolveDisplayName } from '@/utils/displayName'; type TabType = 'feed' | 'owned' | 'created' | 'transactions'; export default function UserProfile({ @@ -195,6 +196,11 @@ export default function UserProfile({ const bioText = (profileInfo?.profile?.bio || '').trim() || (accountInfo?.bio || '').trim() || profile?.profile?.bio; + const displayName = resolveDisplayName({ + publicName: profileInfo?.public_name, + chainName, + address: effectiveAddress, + }); const isXVerified = Boolean( String(profileInfo?.profile?.x_username || '').trim() || String(onChainProfile?.x_username || '').trim(), @@ -330,13 +336,13 @@ export default function UserProfile({ const content = (
-
+
{/* Avatar and Identity */}
@@ -374,9 +380,9 @@ export default function UserProfile({ className="relative" />
-
-

- {profileInfo?.public_name || chainName || 'Legend'} +
+

+ {displayName}

{effectiveAddress} @@ -390,8 +396,8 @@ export default function UserProfile({
{/* Action buttons */} -
- {(canEdit && false) ? ( +
+ {canEdit ? (
- {(canEdit && !isXVerified && false) && ( + {canEdit && !isXVerified && ( )} @@ -462,8 +468,9 @@ export default function UserProfile({
{decimalBalance ? (() => { try { - const value = typeof decimalBalance.toNumber === 'function' - ? decimalBalance.toNumber() + const decimalBalanceValue = decimalBalance as any; + const value = typeof decimalBalanceValue?.toNumber === 'function' + ? decimalBalanceValue.toNumber() : typeof decimalBalance === 'number' ? decimalBalance : Number(decimalBalance); diff --git a/src/views/__tests__/ProfileXCallback.test.tsx b/src/views/__tests__/ProfileXCallback.test.tsx index 0a797ccfa..e77b426bc 100644 --- a/src/views/__tests__/ProfileXCallback.test.tsx +++ b/src/views/__tests__/ProfileXCallback.test.tsx @@ -85,4 +85,40 @@ describe('ProfileXCallback', () => { expect(mockCreateXAttestationFromCode).toHaveBeenCalledTimes(1); }); }); + + it('does not exchange the code when PKCE storage is missing', async () => { + mockGetAndClearXOAuthPKCE.mockReturnValue(null); + + render( + + + } /> + + , + ); + + await waitFor(() => { + expect(mockGetAndClearXOAuthPKCE).toHaveBeenCalledTimes(1); + }); + expect(mockCreateXAttestationFromCode).not.toHaveBeenCalled(); + expect(mockAddStaticAccount).not.toHaveBeenCalled(); + expect(mockCompleteXWithAttestation).not.toHaveBeenCalled(); + }); + + it('does not re-add the wallet when the active account already matches', async () => { + mockActiveAccount = 'ak_test_1'; + + render( + + + } /> + + , + ); + + await waitFor(() => { + expect(mockCreateXAttestationFromCode).toHaveBeenCalledTimes(1); + }); + expect(mockAddStaticAccount).not.toHaveBeenCalled(); + }); });