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 && !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();
+ });
});