diff --git a/src/components/ActivityFeed.tsx b/src/components/ActivityFeed.tsx index ec7ed6e..c6975c6 100644 --- a/src/components/ActivityFeed.tsx +++ b/src/components/ActivityFeed.tsx @@ -34,13 +34,7 @@ interface CommentFeedItem extends FeedItemBase { type FeedItem = PostFeedItem | CommentFeedItem; const ActivityFeed: React.FC = () => { - const { - posts, - comments, - getCellById, - isInitialLoading, - userVerificationStatus, - } = useForum(); + const { posts, comments, getCellById, isInitialLoading } = useForum(); const combinedFeed: FeedItem[] = [ ...posts.map( @@ -105,7 +99,6 @@ const ActivityFeed: React.FC = () => { by diff --git a/src/components/FeedSidebar.tsx b/src/components/FeedSidebar.tsx index be05ceb..1b3ad90 100644 --- a/src/components/FeedSidebar.tsx +++ b/src/components/FeedSidebar.tsx @@ -8,13 +8,18 @@ import { useForum } from '@/contexts/useForum'; import { useAuth } from '@/contexts/useAuth'; import { CypherImage } from '@/components/ui/CypherImage'; import { CreateCellDialog } from '@/components/CreateCellDialog'; -import { EVerificationStatus } from '@/types/identity'; +import { useUserDisplay } from '@/hooks/useUserDisplay'; const FeedSidebar: React.FC = () => { - const { cells, posts } = useForum(); const { currentUser, verificationStatus } = useAuth(); + const { cells, posts } = useForum(); const [showCreateCell, setShowCreateCell] = useState(false); + // Get user display information using the hook + const { displayName, hasENS, hasOrdinal } = useUserDisplay( + currentUser?.address || '' + ); + // Calculate trending cells based on recent post activity const trendingCells = cells .map(cell => { @@ -46,14 +51,10 @@ const FeedSidebar: React.FC = () => { // Ethereum wallet with ENS if (currentUser.walletType === 'ethereum') { - if ( - currentUser.ensDetails?.ensName && - (verificationStatus === EVerificationStatus.VERIFIED_OWNER || - currentUser.ensDetails) - ) { + if (hasENS && (verificationStatus === 'verified-owner' || hasENS)) { return ( - ✓ Owns ENS: {currentUser.ensDetails.ensName} + ✓ Owns ENS: {displayName} ); } else if (verificationStatus === 'verified-basic') { @@ -69,10 +70,7 @@ const FeedSidebar: React.FC = () => { // Bitcoin wallet with Ordinal if (currentUser.walletType === 'bitcoin') { - if ( - verificationStatus === 'verified-owner' || - currentUser.ordinalDetails?.ordinalId - ) { + if (verificationStatus === 'verified-owner' || hasOrdinal) { return ( ✓ Owns Ordinal @@ -117,10 +115,7 @@ const FeedSidebar: React.FC = () => { -
- {currentUser.address.slice(0, 8)}... - {currentUser.address.slice(-6)} -
+
{displayName}
{getVerificationBadge()}
diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 34d8411..2f4cd8e 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -25,9 +25,11 @@ import { import { useToast } from '@/components/ui/use-toast'; import { useAppKitAccount, useDisconnect } from '@reown/appkit/react'; import { WalletWizard } from '@/components/ui/wallet-wizard'; +import { CallSignSetupDialog } from '@/components/ui/call-sign-setup-dialog'; +import { useUserDisplay } from '@/hooks/useUserDisplay'; const Header = () => { - const { currentUser, verificationStatus, getDelegationStatus } = useAuth(); + const { verificationStatus, getDelegationStatus } = useAuth(); const { isNetworkConnected, isRefreshing } = useForum(); const location = useLocation(); const { toast } = useToast(); @@ -48,6 +50,9 @@ const Header = () => { const [walletWizardOpen, setWalletWizardOpen] = useState(false); + // Get display name from hook + const { displayName } = useUserDisplay(address || ''); + // Use sessionStorage to persist wizard state across navigation const getHasShownWizard = () => { try { @@ -251,18 +256,19 @@ const Header = () => { - {currentUser?.ensDetails?.ensName || - `${address?.slice(0, 5)}...${address?.slice(-4)}`} + {displayName}

- {currentUser?.ensDetails?.ensName - ? `${currentUser.ensDetails.ensName} (${address})` + {displayName !== + `${address?.slice(0, 5)}...${address?.slice(-4)}` + ? `${displayName} (${address})` : address}

+ + + )} + + + Setup Call Sign & Display Preferences + +
+ + ( + + + + Call Sign + + + + + + Choose a unique identifier (3-20 characters, letters, + numbers, hyphens, underscores) + + + + )} + /> + ( + + Display Preference + + + Choose how your name appears in the forum + + + + )} + /> + + + +
+ + ); +} + +export default CallSignSetupDialog; diff --git a/src/components/ui/verification-step.tsx b/src/components/ui/verification-step.tsx index 97a1978..64fbbb3 100644 --- a/src/components/ui/verification-step.tsx +++ b/src/components/ui/verification-step.tsx @@ -198,18 +198,8 @@ export function VerificationStep({

{currentUser && (
- {walletType === 'bitcoin' && currentUser.ordinalDetails && ( -

- Ordinal ID:{' '} - {typeof currentUser.ordinalDetails === 'object' - ? currentUser.ordinalDetails.ordinalId - : 'Verified'} -

- )} - {walletType === 'ethereum' && - currentUser.ensDetails?.ensName && ( -

ENS Name: {currentUser.ensDetails.ensName}

- )} + {walletType === 'bitcoin' &&

Ordinal ID: Verified

} + {walletType === 'ethereum' &&

ENS Name: Verified

}
)} diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index e28720a..ac715bf 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -246,6 +246,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } const chainName = isBitcoinConnected ? 'Bitcoin' : 'Ethereum'; + // Note: We can't use useUserDisplay hook here since this is not a React component + // This is just for toast messages, so simple truncation is acceptable const displayName = `${address.slice(0, 6)}...${address.slice(-4)}`; toast({ diff --git a/src/contexts/ForumContext.tsx b/src/contexts/ForumContext.tsx index 66a171c..1404f22 100644 --- a/src/contexts/ForumContext.tsx +++ b/src/contexts/ForumContext.tsx @@ -20,8 +20,8 @@ import { getDataFromCache } from '@/lib/forum/transformers'; import { RelevanceCalculator } from '@/lib/forum/RelevanceCalculator'; import { UserVerificationStatus } from '@/types/forum'; import { DelegationManager } from '@/lib/delegation'; -import { getEnsName } from '@wagmi/core'; -import { config } from '@/lib/wallet/config'; +import { UserIdentityService } from '@/lib/services/UserIdentityService'; +import { MessageService } from '@/lib/services/MessageService'; interface ForumContextType { cells: Cell[]; @@ -29,6 +29,8 @@ interface ForumContextType { comments: Comment[]; // User verification status for display userVerificationStatus: UserVerificationStatus; + // User identity service for profile management + userIdentityService: UserIdentityService | null; // Granular loading states isInitialLoading: boolean; isPostingCell: boolean; @@ -99,6 +101,14 @@ export function ForumProvider({ children }: { children: React.ReactNode }) { const { currentUser, isAuthenticated } = useAuth(); const delegationManager = useMemo(() => new DelegationManager(), []); + const messageService = useMemo( + () => new MessageService(delegationManager), + [delegationManager] + ); + const userIdentityService = useMemo( + () => new UserIdentityService(messageService), + [messageService] + ); const forumActions = useMemo( () => new ForumActions(delegationManager), [delegationManager] @@ -134,35 +144,60 @@ export function ForumProvider({ children }: { children: React.ReactNode }) { userAddresses.add(vote.author); }); - // Create user objects for verification status - Array.from(userAddresses).forEach(address => { - // Check if this address matches the current user's address - if (currentUser && currentUser.address === address) { - // Use the current user's actual verification status - allUsers.push({ - address, - walletType: currentUser.walletType, - verificationStatus: currentUser.verificationStatus, - displayPreference: currentUser.displayPreference, - ensDetails: currentUser.ensDetails, - ordinalDetails: currentUser.ordinalDetails, - lastChecked: currentUser.lastChecked, - }); - } else { - // Create generic user object for other addresses - allUsers.push({ - address, - walletType: address.startsWith('0x') ? 'ethereum' : 'bitcoin', - verificationStatus: EVerificationStatus.UNVERIFIED, - displayPreference: DisplayPreference.WALLET_ADDRESS, - }); + // Create user objects for verification status using UserIdentityService + const userIdentityPromises = Array.from(userAddresses).map( + async address => { + // Check if this address matches the current user's address + if (currentUser && currentUser.address === address) { + // Use the current user's actual verification status + return { + address, + walletType: currentUser.walletType, + verificationStatus: currentUser.verificationStatus, + displayPreference: currentUser.displayPreference, + ensDetails: currentUser.ensDetails, + ordinalDetails: currentUser.ordinalDetails, + lastChecked: currentUser.lastChecked, + }; + } else { + // Use UserIdentityService to get identity information + const identity = await userIdentityService.getUserIdentity(address); + if (identity) { + return { + address, + walletType: address.startsWith('0x') + ? ('ethereum' as const) + : ('bitcoin' as const), + verificationStatus: identity.verificationStatus, + displayPreference: identity.displayPreference, + ensDetails: identity.ensName + ? { ensName: identity.ensName } + : undefined, + ordinalDetails: identity.ordinalDetails, + lastChecked: identity.lastUpdated, + }; + } else { + // Fallback to generic user object + return { + address, + walletType: address.startsWith('0x') + ? ('ethereum' as const) + : ('bitcoin' as const), + verificationStatus: EVerificationStatus.UNVERIFIED, + displayPreference: DisplayPreference.WALLET_ADDRESS, + }; + } + } } - }); + ); + + const resolvedUsers = await Promise.all(userIdentityPromises); + allUsers.push(...resolvedUsers); const initialStatus = relevanceCalculator.buildUserVerificationStatus(allUsers); - // Transform data with relevance calculation (initial pass) + // Transform data with relevance calculation const { cells, posts, comments } = await getDataFromCache( verifyFn, initialStatus @@ -172,50 +207,7 @@ export function ForumProvider({ children }: { children: React.ReactNode }) { setPosts(posts); setComments(comments); setUserVerificationStatus(initialStatus); - - // Enrich: resolve ENS for ethereum addresses asynchronously and update - (async () => { - const targets = allUsers.filter( - u => u.walletType === 'ethereum' && !u.ensDetails - ); - if (targets.length === 0) return; - const lookups = await Promise.all( - targets.map(async u => { - try { - const name = await getEnsName(config, { - address: u.address as `0x${string}`, - }); - return { address: u.address, ensName: name || undefined }; - } catch { - return { address: u.address, ensName: undefined }; - } - }) - ); - const ensByAddress = new Map( - lookups.map(l => [l.address, l.ensName]) - ); - const enrichedUsers: User[] = allUsers.map(u => { - const ensName = ensByAddress.get(u.address); - if (ensName) { - return { - ...u, - walletType: 'ethereum', - ensOwnership: true, - ensName, - verificationStatus: 'verified-owner', - } as User; - } - return u; - }); - const enrichedStatus = - relevanceCalculator.buildUserVerificationStatus(enrichedUsers); - const transformed = await getDataFromCache(verifyFn, enrichedStatus); - setCells(transformed.cells); - setPosts(transformed.posts); - setComments(transformed.comments); - setUserVerificationStatus(enrichedStatus); - })(); - }, [delegationManager, isAuthenticated, currentUser]); + }, [delegationManager, isAuthenticated, currentUser, userIdentityService]); const handleRefreshData = async () => { setIsRefreshing(true); @@ -259,6 +251,20 @@ export function ForumProvider({ children }: { children: React.ReactNode }) { return cleanup; }, [isNetworkConnected, toast, updateStateFromCache]); + // Simple reactive updates: check for new data periodically when connected + useEffect(() => { + if (!isNetworkConnected) return; + + const interval = setInterval(() => { + // Only update if we're connected and ready + if (messageManager.isReady) { + updateStateFromCache(); + } + }, 15000); // 15 seconds - much less frequent than before + + return () => clearInterval(interval); + }, [isNetworkConnected, updateStateFromCache]); + const getCellById = (id: string): Cell | undefined => { return cells.find(cell => cell.id === id); }; @@ -547,6 +553,7 @@ export function ForumProvider({ children }: { children: React.ReactNode }) { posts, comments, userVerificationStatus, + userIdentityService, isInitialLoading, isPostingCell, isPostingPost, diff --git a/src/hooks/useCache.tsx b/src/hooks/useCache.tsx new file mode 100644 index 0000000..987f0a1 --- /dev/null +++ b/src/hooks/useCache.tsx @@ -0,0 +1,141 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Cell, Post, Comment, OpchanMessage } from '@/types/forum'; +import { UserVerificationStatus } from '@/types/forum'; +import { User } from '@/types/identity'; +import messageManager from '@/lib/waku'; +import { getDataFromCache } from '@/lib/forum/transformers'; +import { RelevanceCalculator } from '@/lib/forum/RelevanceCalculator'; +import { DelegationManager } from '@/lib/delegation'; +import { UserIdentityService } from '@/lib/services/UserIdentityService'; + +interface UseCacheOptions { + delegationManager: DelegationManager; + userIdentityService: UserIdentityService; + currentUser: User | null; + isAuthenticated: boolean; +} + +interface CacheData { + cells: Cell[]; + posts: Post[]; + comments: Comment[]; + userVerificationStatus: UserVerificationStatus; +} + +export function useCache({ + delegationManager, + userIdentityService, + currentUser, + isAuthenticated, +}: UseCacheOptions): CacheData { + const [cacheData, setCacheData] = useState({ + cells: [], + posts: [], + comments: [], + userVerificationStatus: {}, + }); + + // Function to update cache data + const updateCacheData = useCallback(async () => { + try { + // Use the verifyMessage function from delegationManager if available + const verifyFn = isAuthenticated + ? async (message: OpchanMessage) => + await delegationManager.verify(message) + : undefined; + + // Build user verification status for relevance calculation + const relevanceCalculator = new RelevanceCalculator(); + const allUsers: User[] = []; + + // Collect all unique users from posts, comments, and votes + const userAddresses = new Set(); + + // Add users from posts + Object.values(messageManager.messageCache.posts).forEach(post => { + userAddresses.add(post.author); + }); + + // Add users from comments + Object.values(messageManager.messageCache.comments).forEach(comment => { + userAddresses.add(comment.author); + }); + + // Add users from votes + Object.values(messageManager.messageCache.votes).forEach(vote => { + userAddresses.add(vote.author); + }); + + // Create user objects for verification status using existing hooks + const userPromises = Array.from(userAddresses).map(async address => { + // Check if this address matches the current user's address + if (currentUser && currentUser.address === address) { + // Use the current user's actual verification status + return currentUser; + } else { + // Use UserIdentityService to get identity information (simplified) + const identity = await userIdentityService.getUserIdentity(address); + if (identity) { + return { + address, + walletType: (address.startsWith('0x') ? 'ethereum' : 'bitcoin') as 'bitcoin' | 'ethereum', + verificationStatus: identity.verificationStatus || 'unverified', + displayPreference: identity.displayPreference || 'wallet-address', + ensDetails: identity.ensName ? { ensName: identity.ensName } : undefined, + ordinalDetails: identity.ordinalDetails, + lastChecked: identity.lastUpdated, + } as User; + } else { + // Fallback to generic user object + return { + address, + walletType: (address.startsWith('0x') ? 'ethereum' : 'bitcoin') as 'bitcoin' | 'ethereum', + verificationStatus: 'unverified' as const, + displayPreference: 'wallet-address' as const, + } as User; + } + } + }); + + const resolvedUsers = await Promise.all(userPromises); + allUsers.push(...resolvedUsers); + + const initialStatus = + relevanceCalculator.buildUserVerificationStatus(allUsers); + + // Transform data with relevance calculation + const { cells, posts, comments } = await getDataFromCache( + verifyFn, + initialStatus + ); + + setCacheData({ + cells, + posts, + comments, + userVerificationStatus: initialStatus, + }); + } catch (error) { + console.error('Error updating cache data:', error); + } + }, [delegationManager, isAuthenticated, currentUser, userIdentityService]); + + // Update cache data when dependencies change + useEffect(() => { + updateCacheData(); + }, [updateCacheData]); + + // Check for cache changes periodically (much less frequent than before) + useEffect(() => { + const interval = setInterval(() => { + // Only check if we're connected to avoid unnecessary work + if (messageManager.isReady) { + updateCacheData(); + } + }, 10000); // 10 seconds instead of 5 + + return () => clearInterval(interval); + }, [updateCacheData]); + + return cacheData; +} diff --git a/src/hooks/useUserDisplay.ts b/src/hooks/useUserDisplay.ts new file mode 100644 index 0000000..beaadb4 --- /dev/null +++ b/src/hooks/useUserDisplay.ts @@ -0,0 +1,92 @@ +import { useState, useEffect } from 'react'; +import { useForum } from '@/contexts/useForum'; +import { DisplayPreference } from '@/types/identity'; + +export interface UserDisplayInfo { + displayName: string; + hasCallSign: boolean; + hasENS: boolean; + hasOrdinal: boolean; + isLoading: boolean; +} + +export function useUserDisplay(address: string): UserDisplayInfo { + const { userIdentityService } = useForum(); + const [displayInfo, setDisplayInfo] = useState({ + displayName: `${address.slice(0, 6)}...${address.slice(-4)}`, + hasCallSign: false, + hasENS: false, + hasOrdinal: false, + isLoading: true, + }); + + useEffect(() => { + const getUserDisplayInfo = async () => { + if (!address || !userIdentityService) { + console.log('useUserDisplay: No address or service available', { address, hasService: !!userIdentityService }); + setDisplayInfo({ + displayName: `${address.slice(0, 6)}...${address.slice(-4)}`, + hasCallSign: false, + hasENS: false, + hasOrdinal: false, + isLoading: false, + }); + return; + } + + try { + console.log('useUserDisplay: Getting identity for address', address); + const identity = await userIdentityService.getUserIdentity(address); + console.log('useUserDisplay: Received identity', identity); + + if (identity) { + let displayName = `${address.slice(0, 6)}...${address.slice(-4)}`; + + // Determine display name based on preferences + if ( + identity.displayPreference === DisplayPreference.CALL_SIGN && + identity.callSign + ) { + displayName = identity.callSign; + console.log('useUserDisplay: Using call sign as display name', identity.callSign); + } else if (identity.ensName) { + displayName = identity.ensName; + console.log('useUserDisplay: Using ENS as display name', identity.ensName); + } else { + console.log('useUserDisplay: Using truncated address as display name'); + } + + setDisplayInfo({ + displayName, + hasCallSign: Boolean(identity.callSign), + hasENS: Boolean(identity.ensName), + hasOrdinal: Boolean(identity.ordinalDetails), + isLoading: false, + }); + } else { + console.log('useUserDisplay: No identity found, using fallback'); + setDisplayInfo({ + displayName: `${address.slice(0, 6)}...${address.slice(-4)}`, + hasCallSign: false, + hasENS: false, + hasOrdinal: false, + isLoading: false, + }); + } + } catch (error) { + console.error('useUserDisplay: Failed to get user display info:', error); + setDisplayInfo({ + displayName: `${address.slice(0, 6)}...${address.slice(-4)}`, + hasCallSign: false, + hasENS: false, + hasOrdinal: false, + isLoading: false, + }); + } + }; + + getUserDisplayInfo(); + }, [address, userIdentityService]); + + return displayInfo; +} diff --git a/src/lib/services/MessageService.ts b/src/lib/services/MessageService.ts index f106cc9..aaceef4 100644 --- a/src/lib/services/MessageService.ts +++ b/src/lib/services/MessageService.ts @@ -12,6 +12,9 @@ export interface MessageResult { export interface MessageServiceInterface { sendMessage(message: UnsignedMessage): Promise; verifyMessage(message: OpchanMessage): Promise; + signAndBroadcastMessage( + message: UnsignedMessage + ): Promise; } export class MessageService implements MessageServiceInterface { @@ -77,6 +80,27 @@ export class MessageService implements MessageServiceInterface { } } + /** + * Sign and broadcast a message (simplified version for profile updates) + */ + async signAndBroadcastMessage( + message: UnsignedMessage + ): Promise { + try { + const signedMessage = this.delegationManager.signMessage(message); + if (!signedMessage) { + console.error('Failed to sign message'); + return null; + } + + await messageManager.sendMessage(signedMessage); + return signedMessage; + } catch (error) { + console.error('Error signing and broadcasting message:', error); + return null; + } + } + /** * Verify a message signature */ diff --git a/src/lib/services/UserIdentityService.ts b/src/lib/services/UserIdentityService.ts new file mode 100644 index 0000000..ab0f9c7 --- /dev/null +++ b/src/lib/services/UserIdentityService.ts @@ -0,0 +1,330 @@ +import { EVerificationStatus, DisplayPreference } from '@/types/identity'; +import { + UnsignedUserProfileUpdateMessage, + UserProfileUpdateMessage, + MessageType, + UserIdentityCache, +} from '@/types/waku'; +import { MessageService } from './MessageService'; +import messageManager from '@/lib/waku'; + +export interface UserIdentity { + address: string; + ensName?: string; + ordinalDetails?: { + ordinalId: string; + ordinalDetails: string; + }; + callSign?: string; + displayPreference: DisplayPreference; + lastUpdated: number; + verificationStatus: EVerificationStatus; +} + +export class UserIdentityService { + private messageService: MessageService; + private userIdentityCache: UserIdentityCache = {}; + + constructor(messageService: MessageService) { + this.messageService = messageService; + } + + /** + * Get user identity from cache or resolve from sources + */ + async getUserIdentity(address: string): Promise { + // Check internal cache first + if (this.userIdentityCache[address]) { + const cached = this.userIdentityCache[address]; + console.log('UserIdentityService: Found in internal cache', cached); + return { + address, + ensName: cached.ensName, + ordinalDetails: cached.ordinalDetails, + callSign: cached.callSign, + displayPreference: + cached.displayPreference === 'call-sign' + ? DisplayPreference.CALL_SIGN + : DisplayPreference.WALLET_ADDRESS, + lastUpdated: cached.lastUpdated, + verificationStatus: this.mapVerificationStatus( + cached.verificationStatus + ), + }; + } + + // Check CacheService for Waku messages + console.log('UserIdentityService: Checking CacheService for address', address); + console.log('UserIdentityService: messageManager available?', !!messageManager); + console.log('UserIdentityService: messageCache available?', !!messageManager?.messageCache); + console.log('UserIdentityService: userIdentities available?', !!messageManager?.messageCache?.userIdentities); + console.log('UserIdentityService: All userIdentities keys:', Object.keys(messageManager?.messageCache?.userIdentities || {})); + + const cacheServiceData = messageManager.messageCache.userIdentities[address]; + console.log('UserIdentityService: CacheService data for', address, ':', cacheServiceData); + + if (cacheServiceData) { + console.log('UserIdentityService: Found in CacheService', cacheServiceData); + + // Store in internal cache for future use + this.userIdentityCache[address] = { + ensName: cacheServiceData.ensName, + ordinalDetails: cacheServiceData.ordinalDetails, + callSign: cacheServiceData.callSign, + displayPreference: cacheServiceData.displayPreference, + lastUpdated: cacheServiceData.lastUpdated, + verificationStatus: cacheServiceData.verificationStatus, + }; + + return { + address, + ensName: cacheServiceData.ensName, + ordinalDetails: cacheServiceData.ordinalDetails, + callSign: cacheServiceData.callSign, + displayPreference: + cacheServiceData.displayPreference === 'call-sign' + ? DisplayPreference.CALL_SIGN + : DisplayPreference.WALLET_ADDRESS, + lastUpdated: cacheServiceData.lastUpdated, + verificationStatus: this.mapVerificationStatus( + cacheServiceData.verificationStatus + ), + }; + } + + console.log('UserIdentityService: No cached data found, resolving from sources'); + + // Try to resolve identity from various sources + const identity = await this.resolveUserIdentity(address); + if (identity) { + this.userIdentityCache[address] = { + ensName: identity.ensName, + ordinalDetails: identity.ordinalDetails, + callSign: identity.callSign, + displayPreference: + identity.displayPreference === DisplayPreference.CALL_SIGN + ? 'call-sign' + : 'wallet-address', + lastUpdated: identity.lastUpdated, + verificationStatus: identity.verificationStatus, + }; + } + + return identity; + } + + /** + * Get all cached user identities + */ + getAllUserIdentities(): UserIdentity[] { + return Object.entries(this.userIdentityCache).map(([address, cached]) => ({ + address, + ensName: cached.ensName, + ordinalDetails: cached.ordinalDetails, + callSign: cached.callSign, + displayPreference: + cached.displayPreference === 'call-sign' + ? DisplayPreference.CALL_SIGN + : DisplayPreference.WALLET_ADDRESS, + lastUpdated: cached.lastUpdated, + verificationStatus: this.mapVerificationStatus(cached.verificationStatus), + })); + } + + /** + * Update user profile via Waku message + */ + async updateUserProfile( + address: string, + callSign: string, + displayPreference: DisplayPreference + ): Promise { + try { + console.log('UserIdentityService: Updating profile for', address, { + callSign, + displayPreference, + }); + + const unsignedMessage: UnsignedUserProfileUpdateMessage = { + id: crypto.randomUUID(), + type: MessageType.USER_PROFILE_UPDATE, + timestamp: Date.now(), + author: address, + callSign, + displayPreference: + displayPreference === DisplayPreference.CALL_SIGN + ? 'call-sign' + : 'wallet-address', + }; + + console.log('UserIdentityService: Created unsigned message', unsignedMessage); + + const signedMessage = + await this.messageService.signAndBroadcastMessage(unsignedMessage); + + console.log('UserIdentityService: Message broadcast result', !!signedMessage); + + return !!signedMessage; + } catch (error) { + console.error('Failed to update user profile:', error); + return false; + } + } + + /** + * Resolve user identity from various sources + */ + private async resolveUserIdentity( + address: string + ): Promise { + try { + const [ensName, ordinalDetails] = await Promise.all([ + this.resolveENSName(address), + this.resolveOrdinalDetails(address), + ]); + + // Default to wallet address display preference + const defaultDisplayPreference: DisplayPreference = + DisplayPreference.WALLET_ADDRESS; + + // Default verification status based on what we can resolve + let verificationStatus: EVerificationStatus = + EVerificationStatus.UNVERIFIED; + if (ensName || ordinalDetails) { + verificationStatus = EVerificationStatus.VERIFIED_OWNER; + } + + return { + address, + ensName: ensName || undefined, + ordinalDetails: ordinalDetails || undefined, + callSign: undefined, // Will be populated from Waku messages + displayPreference: defaultDisplayPreference, + lastUpdated: Date.now(), + verificationStatus, + }; + } catch (error) { + console.error('Failed to resolve user identity:', error); + return null; + } + } + + /** + * Resolve ENS name from Ethereum address + */ + private async resolveENSName(address: string): Promise { + if (!address.startsWith('0x')) { + return null; // Not an Ethereum address + } + + try { + // For now, return null - ENS resolution can be added later + // This would typically call an ENS resolver API + return null; + } catch (error) { + console.error('Failed to resolve ENS name:', error); + return null; + } + } + + /** + * Resolve Ordinal details from Bitcoin address + */ + private async resolveOrdinalDetails( + address: string + ): Promise<{ ordinalId: string; ordinalDetails: string } | null> { + if (address.startsWith('0x')) { + return null; // Not a Bitcoin address + } + + try { + // For now, return null - Ordinal resolution can be added later + // This would typically call an Ordinal API + return null; + } catch (error) { + console.error('Failed to resolve Ordinal details:', error); + return null; + } + } + + /** + * Update user identity from Waku message + */ + updateUserIdentityFromMessage(message: UserProfileUpdateMessage): void { + const { author, callSign, displayPreference, timestamp } = message; + + if (!this.userIdentityCache[author]) { + // Create new identity entry if it doesn't exist + this.userIdentityCache[author] = { + ensName: undefined, + ordinalDetails: undefined, + callSign: undefined, + displayPreference: + displayPreference === 'call-sign' ? 'call-sign' : 'wallet-address', + lastUpdated: timestamp, + verificationStatus: 'unverified', + }; + } + + // Update only if this message is newer + if (timestamp > this.userIdentityCache[author].lastUpdated) { + this.userIdentityCache[author] = { + ...this.userIdentityCache[author], + callSign, + displayPreference, + lastUpdated: timestamp, + }; + } + } + + /** + * Map verification status string to enum + */ + private mapVerificationStatus(status: string): EVerificationStatus { + switch (status) { + case 'verified-basic': + return EVerificationStatus.VERIFIED_BASIC; + case 'verified-owner': + return EVerificationStatus.VERIFIED_OWNER; + case 'verifying': + return EVerificationStatus.VERIFYING; + default: + return EVerificationStatus.UNVERIFIED; + } + } + + /** + * Refresh user identity (force re-resolution) + */ + async refreshUserIdentity(address: string): Promise { + delete this.userIdentityCache[address]; + await this.getUserIdentity(address); + } + + /** + * Clear user identity cache + */ + clearUserIdentityCache(): void { + this.userIdentityCache = {}; + } + + /** + * Get display name for user based on their preferences + */ + getDisplayName(address: string): string { + const identity = this.userIdentityCache[address]; + if (!identity) { + return `${address.slice(0, 6)}...${address.slice(-4)}`; + } + + if (identity.displayPreference === 'call-sign' && identity.callSign) { + return identity.callSign; + } + + if (identity.ensName) { + return identity.ensName; + } + + return `${address.slice(0, 6)}...${address.slice(-4)}`; + } +} diff --git a/src/lib/utils/MessageValidator.ts b/src/lib/utils/MessageValidator.ts index 58d9dab..9670e4c 100644 --- a/src/lib/utils/MessageValidator.ts +++ b/src/lib/utils/MessageValidator.ts @@ -1,16 +1,10 @@ -import { OpchanMessage } from '@/types/forum'; +import { OpchanMessage, PartialMessage } from '@/types/forum'; import { DelegationManager } from '@/lib/delegation'; -/** - * Type for potential message objects with partial structure - */ -interface PartialMessage { - id?: unknown; - type?: unknown; - timestamp?: unknown; - author?: unknown; - signature?: unknown; - browserPubKey?: unknown; +interface ValidationReport { + hasValidSignature: boolean; + errors: string[]; + isValid: boolean; } /** @@ -19,11 +13,44 @@ interface PartialMessage { */ export class MessageValidator { private delegationManager: DelegationManager; + // Cache validation results to avoid re-validating the same messages + private validationCache = new Map(); + private readonly CACHE_TTL = 60000; // 1 minute cache TTL constructor(delegationManager?: DelegationManager) { this.delegationManager = delegationManager || new DelegationManager(); } + /** + * Get cached validation result or validate and cache + */ + private getCachedValidation(messageId: string, message: OpchanMessage): { isValid: boolean; timestamp: number } | null { + const cached = this.validationCache.get(messageId); + if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) { + return cached; + } + return null; + } + + /** + * Cache validation result + */ + private cacheValidation(messageId: string, isValid: boolean): void { + this.validationCache.set(messageId, { isValid, timestamp: Date.now() }); + } + + /** + * Clear expired cache entries + */ + private cleanupCache(): void { + const now = Date.now(); + for (const [key, value] of this.validationCache.entries()) { + if (now - value.timestamp > this.CACHE_TTL) { + this.validationCache.delete(key); + } + } + } + /** * Validates that a message has required signature fields and valid signature */ @@ -223,12 +250,7 @@ export class MessageValidator { /** * Creates a validation report for debugging */ - async getValidationReport(message: unknown): Promise<{ - isValid: boolean; - hasRequiredFields: boolean; - hasValidSignature: boolean; - errors: string[]; - }> { + async getValidationReport(message: unknown): Promise { const errors: string[] = []; let hasRequiredFields = false; let hasValidSignature = false; diff --git a/src/lib/waku/CodecManager.ts b/src/lib/waku/CodecManager.ts index 4dbe77e..559c567 100644 --- a/src/lib/waku/CodecManager.ts +++ b/src/lib/waku/CodecManager.ts @@ -1,5 +1,5 @@ import { IDecodedMessage, IDecoder, IEncoder, LightNode } from '@waku/sdk'; -import { MessageType } from '../../types/waku'; +import { MessageType, UserProfileUpdateMessage } from '../../types/waku'; import { CellMessage, PostMessage, @@ -53,6 +53,8 @@ export class CodecManager { return message as CommentMessage; case MessageType.VOTE: return message as VoteMessage; + case MessageType.USER_PROFILE_UPDATE: + return message as UserProfileUpdateMessage; default: throw new Error(`Unknown message type: ${message}`); } diff --git a/src/lib/waku/constants.ts b/src/lib/waku/constants.ts index 7938940..0319bff 100644 --- a/src/lib/waku/constants.ts +++ b/src/lib/waku/constants.ts @@ -9,6 +9,7 @@ export const CONTENT_TOPICS: Record = { [MessageType.COMMENT]: '/opchan-ab-xyz/1/comment/proto', [MessageType.VOTE]: '/opchan-sds-ab/1/vote/proto', [MessageType.MODERATE]: '/opchan-sds-ab/1/moderate/proto', + [MessageType.USER_PROFILE_UPDATE]: '/opchan-sds-ab/1/profile/proto', }; /** diff --git a/src/lib/waku/network.ts b/src/lib/waku/network.ts index 74d0012..48a4fbc 100644 --- a/src/lib/waku/network.ts +++ b/src/lib/waku/network.ts @@ -84,17 +84,6 @@ export const initializeNetwork = async ( } }; -export const setupPeriodicQueries = ( - updateStateFromCache: () => void -): { cleanup: () => void } => { - const uiRefreshInterval = setInterval(updateStateFromCache, 5000); - return { - cleanup: () => { - clearInterval(uiRefreshInterval); - }, - }; -}; - export const monitorNetworkHealth = ( setIsNetworkConnected: (isConnected: boolean) => void, toast: ToastFunction diff --git a/src/lib/waku/services/CacheService.ts b/src/lib/waku/services/CacheService.ts index d97ec6c..9d4c7ab 100644 --- a/src/lib/waku/services/CacheService.ts +++ b/src/lib/waku/services/CacheService.ts @@ -5,6 +5,8 @@ import { CommentCache, VoteCache, ModerateMessage, + UserProfileUpdateMessage, + UserIdentityCache, } from '../../../types/waku'; import { OpchanMessage } from '@/types/forum'; import { MessageValidator } from '@/lib/utils/MessageValidator'; @@ -15,6 +17,7 @@ export interface MessageCache { comments: CommentCache; votes: VoteCache; moderations: { [targetId: string]: ModerateMessage }; + userIdentities: UserIdentityCache; } export class CacheService { @@ -27,6 +30,7 @@ export class CacheService { comments: {}, votes: {}, moderations: {}, + userIdentities: {}, }; constructor() { @@ -111,6 +115,36 @@ export class CacheService { } break; } + case MessageType.USER_PROFILE_UPDATE: { + const profileMsg = message as UserProfileUpdateMessage; + const { author, callSign, displayPreference, timestamp } = profileMsg; + + console.log('CacheService: Storing USER_PROFILE_UPDATE message', { + author, + callSign, + displayPreference, + timestamp, + }); + + if ( + !this.cache.userIdentities[author] || + this.cache.userIdentities[author]?.lastUpdated !== timestamp + ) { + this.cache.userIdentities[author] = { + ensName: undefined, + ordinalDetails: undefined, + callSign, + displayPreference, + lastUpdated: timestamp, + verificationStatus: 'unverified', // Will be updated by UserIdentityService + }; + + console.log('CacheService: Updated user identity cache for', author, this.cache.userIdentities[author]); + } else { + console.log('CacheService: Skipping update - same timestamp or already exists'); + } + break; + } default: console.warn('Received message with unknown type'); break; @@ -124,5 +158,6 @@ export class CacheService { this.cache.comments = {}; this.cache.votes = {}; this.cache.moderations = {}; + this.cache.userIdentities = {}; } } diff --git a/src/types/forum.ts b/src/types/forum.ts index da3e5a3..6784d8d 100644 --- a/src/types/forum.ts +++ b/src/types/forum.ts @@ -4,6 +4,7 @@ import { PostMessage, VoteMessage, ModerateMessage, + UserProfileUpdateMessage, } from '@/types/waku'; import { EVerificationStatus } from './identity'; import { DelegationProof } from '@/lib/delegation/types'; @@ -18,6 +19,7 @@ export type OpchanMessage = ( | CommentMessage | VoteMessage | ModerateMessage + | UserProfileUpdateMessage ) & SignedMessage; diff --git a/src/types/waku.ts b/src/types/waku.ts index ea17c55..51cd1a3 100644 --- a/src/types/waku.ts +++ b/src/types/waku.ts @@ -7,6 +7,7 @@ export enum MessageType { COMMENT = 'comment', VOTE = 'vote', MODERATE = 'moderate', + USER_PROFILE_UPDATE = 'user_profile_update', } /** @@ -64,6 +65,12 @@ export interface UnsignedModerateMessage extends UnsignedBaseMessage { reason?: string; } +export interface UnsignedUserProfileUpdateMessage extends UnsignedBaseMessage { + type: MessageType.USER_PROFILE_UPDATE; + callSign?: string; + displayPreference: 'call-sign' | 'wallet-address'; +} + /** * Signed message types (after signature is added) */ @@ -101,6 +108,12 @@ export interface ModerateMessage extends BaseMessage { reason?: string; } +export interface UserProfileUpdateMessage extends BaseMessage { + type: MessageType.USER_PROFILE_UPDATE; + callSign?: string; + displayPreference: 'call-sign' | 'wallet-address'; +} + /** * Union types for message handling */ @@ -109,14 +122,16 @@ export type UnsignedMessage = | UnsignedPostMessage | UnsignedCommentMessage | UnsignedVoteMessage - | UnsignedModerateMessage; + | UnsignedModerateMessage + | UnsignedUserProfileUpdateMessage; export type SignedMessage = | CellMessage | PostMessage | CommentMessage | VoteMessage - | ModerateMessage; + | ModerateMessage + | UserProfileUpdateMessage; /** * Cache objects for storing messages @@ -136,3 +151,21 @@ export interface CommentCache { export interface VoteCache { [key: string]: VoteMessage; // key = targetId + authorAddress } + +export interface UserIdentityCache { + [address: string]: { + ensName?: string; + ordinalDetails?: { + ordinalId: string; + ordinalDetails: string; + }; + callSign?: string; + displayPreference: 'call-sign' | 'wallet-address'; + lastUpdated: number; + verificationStatus: + | 'unverified' + | 'verified-basic' + | 'verified-owner' + | 'verifying'; + }; +}