diff --git a/packages/react/src/contexts/IdentityContext.tsx b/packages/react/src/contexts/IdentityContext.tsx new file mode 100644 index 0000000..fcae5ac --- /dev/null +++ b/packages/react/src/contexts/IdentityContext.tsx @@ -0,0 +1,95 @@ +import React, { createContext, useContext, useEffect, useMemo, useState } from 'react'; +import { OpChanClient, type EVerificationStatus } from '@opchan/core'; + +export interface IdentityRecord { + address: string; + displayName: string; + callSign: string | null; + ensName: string | null; + ordinalDetails: string | null; + verificationStatus: EVerificationStatus; + lastUpdated: number; +} + +export interface IdentityContextValue { + getIdentity: (address: string) => IdentityRecord | null; + getDisplayName: (address: string) => string; +} + +const IdentityContext = createContext(null); + +export const IdentityProvider: React.FC<{ client: OpChanClient; children: React.ReactNode }> = ({ client, children }) => { + const [cache, setCache] = useState>({}); + + useEffect(() => { + let mounted = true; + const seedFromService = async () => { + try { + // Warm snapshot of any already-cached identities + const identities = client.userIdentityService.getAllUserIdentities(); + if (!mounted) return; + const next: Record = {}; + identities.forEach(id => { + next[id.address] = { + address: id.address, + displayName: client.userIdentityService.getDisplayName(id.address), + callSign: id.callSign ?? null, + ensName: id.ensName ?? null, + ordinalDetails: id.ordinalDetails?.ordinalDetails ?? null, + verificationStatus: id.verificationStatus, + lastUpdated: id.lastUpdated, + }; + }); + setCache(next); + } catch {} + }; + seedFromService(); + + // Subscribe to identity refresh events for live updates + const off = client.userIdentityService.addRefreshListener(async (address: string) => { + try { + const fresh = await client.userIdentityService.getUserIdentity(address); + if (!fresh) return; + setCache(prev => ({ + ...prev, + [address]: { + address, + displayName: client.userIdentityService.getDisplayName(address), + callSign: fresh.callSign ?? null, + ensName: fresh.ensName ?? null, + ordinalDetails: fresh.ordinalDetails?.ordinalDetails ?? null, + verificationStatus: fresh.verificationStatus, + lastUpdated: fresh.lastUpdated, + }, + })); + } catch {} + }); + + return () => { + try { off && off(); } catch {} + mounted = false; + }; + }, [client]); + + const getIdentity = useMemo(() => { + return (address: string): IdentityRecord | null => cache[address] ?? null; + }, [cache]); + + const getDisplayName = useMemo(() => { + return (address: string): string => client.userIdentityService.getDisplayName(address); + }, [client]); + + const value: IdentityContextValue = useMemo(() => ({ getIdentity, getDisplayName }), [getIdentity, getDisplayName]); + + return {children}; +}; + +export function useIdentity(): IdentityContextValue { + const ctx = useContext(IdentityContext); + if (!ctx) throw new Error('useIdentity must be used within OpChanProvider'); + return ctx; +} + +export { IdentityContext }; + + diff --git a/packages/react/src/hooks/core/useUserDisplay.ts b/packages/react/src/hooks/core/useUserDisplay.ts index 29a1c15..2a7fc15 100644 --- a/packages/react/src/hooks/core/useUserDisplay.ts +++ b/packages/react/src/hooks/core/useUserDisplay.ts @@ -1,7 +1,7 @@ import { EDisplayPreference, EVerificationStatus } from '@opchan/core'; -import { useState, useEffect, useMemo } from 'react'; -import { useForumData } from './useForumData'; +import { useEffect, useState } from 'react'; import { useClient } from '../../contexts/ClientContext'; +import { useIdentity } from '../../contexts/IdentityContext'; export interface UserDisplayInfo { displayName: string; @@ -19,7 +19,7 @@ export interface UserDisplayInfo { */ export function useUserDisplay(address: string): UserDisplayInfo { const client = useClient(); - const { userVerificationStatus } = useForumData(); + const { getIdentity, getDisplayName } = useIdentity(); const [displayInfo, setDisplayInfo] = useState({ displayName: `${address.slice(0, 6)}...${address.slice(-4)}`, callSign: null, @@ -30,73 +30,17 @@ export function useUserDisplay(address: string): UserDisplayInfo { isLoading: true, error: null, }); - const [refreshTrigger, setRefreshTrigger] = useState(0); - - // Get verification status from forum context for reactive updates - const verificationInfo = useMemo(() => { - return ( - userVerificationStatus[address] || { - isVerified: false, - ensName: null, - verificationStatus: EVerificationStatus.WALLET_UNCONNECTED, - } - ); - }, [userVerificationStatus, address]); - - // Set up refresh listener for user identity changes + // Subscribe via IdentityContext by relying on its internal listener useEffect(() => { - if (!client.userIdentityService || !address) return; - - const unsubscribe = client.userIdentityService.addRefreshListener( - updatedAddress => { - if (updatedAddress === address) { - setRefreshTrigger(prev => prev + 1); - } - } - ); - - return unsubscribe; - }, [client.userIdentityService, address]); - - useEffect(() => { - const getUserDisplayInfo = async () => { - if (!address) { - setDisplayInfo(prev => ({ - ...prev, - isLoading: false, - error: 'No address provided', - })); - return; - } - - if (!client.userIdentityService) { - console.log( - 'useEnhancedUserDisplay: No service available, using fallback', - { address } - ); - setDisplayInfo({ - displayName: `${address.slice(0, 6)}...${address.slice(-4)}`, - callSign: null, - ensName: verificationInfo.ensName || null, - ordinalDetails: null, - verificationLevel: - verificationInfo.verificationStatus || - EVerificationStatus.WALLET_UNCONNECTED, - displayPreference: null, - isLoading: false, - error: null, - }); - return; - } - + let cancelled = false; + const prime = async () => { + if (!address) return; try { const identity = await client.userIdentityService.getUserIdentity(address); - + if (cancelled) return; if (identity) { - const displayName = client.userIdentityService.getDisplayName(address); - setDisplayInfo({ - displayName, + displayName: getDisplayName(address), callSign: identity.callSign || null, ensName: identity.ensName || null, ordinalDetails: identity.ordinalDetails @@ -108,56 +52,41 @@ export function useUserDisplay(address: string): UserDisplayInfo { error: null, }); } else { - setDisplayInfo({ - displayName: client.userIdentityService.getDisplayName(address), - callSign: null, - ensName: verificationInfo.ensName || null, - ordinalDetails: null, - verificationLevel: - verificationInfo.verificationStatus || - EVerificationStatus.WALLET_UNCONNECTED, - displayPreference: null, + setDisplayInfo(prev => ({ + ...prev, + displayName: getDisplayName(address), isLoading: false, error: null, - }); + })); } } catch (error) { - console.error( - 'useEnhancedUserDisplay: Failed to get user display info:', - error - ); - setDisplayInfo({ - displayName: `${address.slice(0, 6)}...${address.slice(-4)}`, - callSign: null, - ensName: null, - ordinalDetails: null, - verificationLevel: EVerificationStatus.WALLET_UNCONNECTED, - displayPreference: null, + setDisplayInfo(prev => ({ + ...prev, isLoading: false, error: error instanceof Error ? error.message : 'Unknown error', - }); + })); } }; + prime(); + return () => { cancelled = true; }; + }, [address, client.userIdentityService, getDisplayName]); - getUserDisplayInfo(); - }, [address, client.userIdentityService, verificationInfo, refreshTrigger]); - - // Update display info when verification status changes reactively + // Reactively reflect IdentityContext cache changes useEffect(() => { - if (!displayInfo.isLoading && verificationInfo) { - setDisplayInfo(prev => ({ - ...prev, - ensName: verificationInfo.ensName || prev.ensName, - verificationLevel: - verificationInfo.verificationStatus || prev.verificationLevel, - })); - } - }, [ - verificationInfo.ensName, - verificationInfo.verificationStatus, - displayInfo.isLoading, - verificationInfo, - ]); + if (!address) return; + const id = getIdentity(address); + if (!id) return; + setDisplayInfo(prev => ({ + ...prev, + displayName: getDisplayName(address), + callSign: id.callSign, + ensName: id.ensName, + ordinalDetails: id.ordinalDetails, + verificationLevel: id.verificationStatus, + isLoading: false, + error: null, + })); + }, [address, getIdentity, getDisplayName]); return displayInfo; } diff --git a/packages/react/src/provider/OpChanProvider.tsx b/packages/react/src/provider/OpChanProvider.tsx index c27ac5a..f954131 100644 --- a/packages/react/src/provider/OpChanProvider.tsx +++ b/packages/react/src/provider/OpChanProvider.tsx @@ -5,6 +5,7 @@ import { ClientProvider } from '../contexts/ClientContext'; import { AuthProvider } from '../contexts/AuthContext'; import { ForumProvider } from '../contexts/ForumContext'; import { ModerationProvider } from '../contexts/ModerationContext'; +import { IdentityProvider } from '../contexts/IdentityContext'; export interface OpChanProviderProps { ordiscanApiKey: string; @@ -57,11 +58,13 @@ export const OpChanProvider: React.FC = ({ if (!isReady || !clientRef.current) return null; return ( - - - {children} - - + + + + {children} + + + ); }, [isReady, children]);