feat: call signs

This commit is contained in:
Danish Arora 2025-09-03 15:01:57 +05:30
parent 414747f396
commit 1216ab1774
No known key found for this signature in database
GPG Key ID: 1C6EF37CDAE1426E
22 changed files with 1039 additions and 234 deletions

View File

@ -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
<AuthorDisplay
address={item.ownerAddress}
userVerificationStatus={userVerificationStatus}
className="font-medium text-foreground/70 mx-1"
showBadge={false}
/>

View File

@ -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 (
<Badge className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Owns ENS: {currentUser.ensDetails.ensName}
Owns ENS: {displayName}
</Badge>
);
} 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 (
<Badge className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Owns Ordinal
@ -117,10 +115,7 @@ const FeedSidebar: React.FC = () => {
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div className="text-xs text-cyber-neutral">
{currentUser.address.slice(0, 8)}...
{currentUser.address.slice(-6)}
</div>
<div className="text-xs text-cyber-neutral">{displayName}</div>
{getVerificationBadge()}
</CardContent>
</Card>

View File

@ -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 = () => {
<Tooltip>
<TooltipTrigger asChild>
<span className="hidden md:flex items-center text-xs text-muted-foreground cursor-default px-2 h-7">
{currentUser?.ensDetails?.ensName ||
`${address?.slice(0, 5)}...${address?.slice(-4)}`}
{displayName}
</span>
</TooltipTrigger>
<TooltipContent className="text-sm">
<p>
{currentUser?.ensDetails?.ensName
? `${currentUser.ensDetails.ensName} (${address})`
{displayName !==
`${address?.slice(0, 5)}...${address?.slice(-4)}`
? `${displayName} (${address})`
: address}
</p>
</TooltipContent>
</Tooltip>
<CallSignSetupDialog />
<Tooltip>
<TooltipTrigger asChild>
<Button

View File

@ -14,8 +14,7 @@ interface PostCardProps {
}
const PostCard: React.FC<PostCardProps> = ({ post, commentCount = 0 }) => {
const { getCellById, votePost, isVoting, userVerificationStatus } =
useForum();
const { getCellById, votePost, isVoting } = useForum();
const { isAuthenticated, currentUser } = useAuth();
const cell = getCellById(post.cellId);
@ -99,8 +98,7 @@ const PostCard: React.FC<PostCardProps> = ({ post, commentCount = 0 }) => {
<span></span>
<span>Posted by u/</span>
<AuthorDisplay
address={post.authorAddress}
userVerificationStatus={userVerificationStatus}
address={post.author}
className="text-xs"
showBadge={false}
/>

View File

@ -35,7 +35,6 @@ const PostDetail = () => {
isVoting,
moderateComment,
moderateUser,
userVerificationStatus,
} = useForum();
const { currentUser, verificationStatus } = useAuth();
const [newComment, setNewComment] = useState('');
@ -210,8 +209,7 @@ const PostDetail = () => {
</span>
<AuthorDisplay
address={post.authorAddress}
userVerificationStatus={userVerificationStatus}
className="truncate max-w-[150px]"
className="text-sm font-medium"
/>
{post.relevanceScore !== undefined && (
<RelevanceIndicator
@ -325,13 +323,12 @@ const PostDetail = () => {
<div className="flex items-center gap-1.5">
<CypherImage
src={getIdentityImageUrl(comment.authorAddress)}
alt={comment.authorAddress.slice(0, 6)}
alt={`${comment.authorAddress.slice(0, 6)}...`}
className="rounded-sm w-5 h-5 bg-secondary"
/>
<AuthorDisplay
address={comment.authorAddress}
userVerificationStatus={userVerificationStatus}
className="text-xs"
className="text-sm font-medium"
/>
</div>
<div className="flex items-center gap-2">

View File

@ -35,7 +35,6 @@ const PostList = () => {
posts,
moderatePost,
moderateUser,
userVerificationStatus,
} = useForum();
const { isAuthenticated, currentUser, verificationStatus } = useAuth();
const [newPostTitle, setNewPostTitle] = useState('');
@ -309,8 +308,7 @@ const PostList = () => {
</span>
<span>by </span>
<AuthorDisplay
address={post.authorAddress}
userVerificationStatus={userVerificationStatus}
address={post.author}
className="text-xs"
showBadge={false}
/>

View File

@ -1,95 +1,23 @@
import React from 'react';
import { Badge } from '@/components/ui/badge';
import { Shield, Crown } from 'lucide-react';
import { UserVerificationStatus } from '@/types/forum';
import { getEnsName } from '@wagmi/core';
import { config } from '@/lib/wallet/config';
import { OrdinalAPI } from '@/lib/services/Ordinal';
import { Shield, Crown, Hash } from 'lucide-react';
import { useUserDisplay } from '@/hooks/useUserDisplay';
interface AuthorDisplayProps {
address: string;
userVerificationStatus?: UserVerificationStatus;
className?: string;
showBadge?: boolean;
}
export function AuthorDisplay({
address,
userVerificationStatus,
className = '',
showBadge = true,
}: AuthorDisplayProps) {
const userStatus = userVerificationStatus?.[address];
const [resolvedEns, setResolvedEns] = React.useState<string | undefined>(
undefined
);
const [resolvedOrdinal, setResolvedOrdinal] = React.useState<
boolean | undefined
>(undefined);
const { displayName, hasCallSign, hasENS, hasOrdinal } =
useUserDisplay(address);
// Heuristics for address types
const isEthereumAddress = address.startsWith('0x') && address.length === 42;
const isBitcoinAddress = !isEthereumAddress; // simple heuristic for our context
// Lazily resolve ENS name for Ethereum addresses if not provided
React.useEffect(() => {
let cancelled = false;
if (!userStatus?.ensName && isEthereumAddress) {
getEnsName(config, { address: address as `0x${string}` })
.then(name => {
if (!cancelled) setResolvedEns(name || undefined);
})
.catch(() => {
if (!cancelled) setResolvedEns(undefined);
});
} else {
setResolvedEns(userStatus?.ensName);
}
return () => {
cancelled = true;
};
}, [address, isEthereumAddress, userStatus?.ensName]);
// Lazily check Ordinal ownership for Bitcoin addresses if not provided
React.useEffect(() => {
let cancelled = false;
const run = async () => {
console.log({
isBitcoinAddress,
userStatus,
});
if (isBitcoinAddress) {
try {
const api = new OrdinalAPI();
const res = await api.getOperatorDetails(address);
if (!cancelled) setResolvedOrdinal(Boolean(res?.has_operators));
} catch {
if (!cancelled) setResolvedOrdinal(undefined);
}
} else {
setResolvedOrdinal(userStatus?.hasOrdinal);
}
};
run();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [address, isBitcoinAddress, userStatus?.hasOrdinal]);
const hasENS =
Boolean(userStatus?.hasENS) ||
Boolean(resolvedEns) ||
Boolean(userStatus?.ensName);
const hasOrdinal =
Boolean(userStatus?.hasOrdinal) || Boolean(resolvedOrdinal);
// Only show a badge if the author has ENS or Ordinal ownership (not for basic verification)
const shouldShowBadge = showBadge && (hasENS || hasOrdinal);
const ensName = userStatus?.ensName || resolvedEns;
const displayName =
ensName || `${address.slice(0, 6)}...${address.slice(-4)}`;
// Only show a badge if the author has ENS, Ordinal, or Call Sign
const shouldShowBadge = showBadge && (hasENS || hasOrdinal || hasCallSign);
return (
<div className={`flex items-center gap-1.5 ${className}`}>
@ -100,7 +28,12 @@ export function AuthorDisplay({
variant="secondary"
className="text-xs px-1.5 py-0.5 h-auto bg-green-900/20 border-green-500/30 text-green-400"
>
{hasENS ? (
{hasCallSign ? (
<>
<Hash className="w-3 h-3 mr-1" />
Call Sign
</>
) : hasENS ? (
<>
<Crown className="w-3 h-3 mr-1" />
ENS

View File

@ -0,0 +1,215 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Loader2, User, Hash } from 'lucide-react';
import { useAuth } from '@/contexts/useAuth';
import { useForum } from '@/contexts/useForum';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useToast } from '@/hooks/use-toast';
import { DisplayPreference } from '@/types/identity';
const formSchema = z.object({
callSign: z
.string()
.min(3, 'Call sign must be at least 3 characters')
.max(20, 'Call sign must be less than 20 characters')
.regex(
/^[a-zA-Z0-9_-]+$/,
'Only letters, numbers, hyphens, and underscores allowed'
)
.refine(val => !/[-_]{2,}/.test(val), 'No consecutive special characters'),
displayPreference: z.nativeEnum(DisplayPreference),
});
interface CallSignSetupDialogProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
export function CallSignSetupDialog({
open: externalOpen,
onOpenChange,
}: CallSignSetupDialogProps = {}) {
const { currentUser } = useAuth();
const { userIdentityService, refreshData } = useForum();
const { toast } = useToast();
const [internalOpen, setInternalOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const open = externalOpen ?? internalOpen;
const setOpen = onOpenChange ?? setInternalOpen;
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
callSign: currentUser?.callSign || '',
displayPreference:
currentUser?.displayPreference || DisplayPreference.WALLET_ADDRESS,
},
});
const onSubmit = async (values: z.infer<typeof formSchema>) => {
if (!currentUser || !userIdentityService) {
toast({
title: 'Error',
description: 'User not authenticated or identity service not available',
variant: 'destructive',
});
return;
}
setIsSubmitting(true);
try {
const success = await userIdentityService.updateUserProfile(
currentUser.address,
values.callSign,
values.displayPreference
);
if (success) {
// Refresh the forum state to get the updated profile
await refreshData();
toast({
title: 'Profile Updated',
description:
'Your call sign and display preferences have been updated successfully.',
});
setOpen(false);
form.reset();
} else {
toast({
title: 'Update Failed',
description: 'Failed to update profile. Please try again.',
variant: 'destructive',
});
}
} catch (error) {
console.error('Error updating profile:', error);
toast({
title: 'Error',
description: 'An unexpected error occurred. Please try again.',
variant: 'destructive',
});
} finally {
setIsSubmitting(false);
}
};
if (!currentUser) return null;
return (
<Dialog open={open} onOpenChange={setOpen}>
{!onOpenChange && (
<DialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="flex items-center gap-2"
>
<User className="h-4 w-4" />
Setup Call Sign
</Button>
</DialogTrigger>
)}
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Setup Call Sign & Display Preferences</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="callSign"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
<Hash className="h-4 w-4" />
Call Sign
</FormLabel>
<FormControl>
<Input
placeholder="Enter your call sign (e.g., cypherpunk_42)"
{...field}
disabled={isSubmitting}
/>
</FormControl>
<FormDescription>
Choose a unique identifier (3-20 characters, letters,
numbers, hyphens, underscores)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="displayPreference"
render={({ field }) => (
<FormItem>
<FormLabel>Display Preference</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
disabled={isSubmitting}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select display preference" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={DisplayPreference.CALL_SIGN}>
Call Sign (when available)
</SelectItem>
<SelectItem value={DisplayPreference.WALLET_ADDRESS}>
Wallet Address
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Choose how your name appears in the forum
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Update Profile
</Button>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
export default CallSignSetupDialog;

View File

@ -198,18 +198,8 @@ export function VerificationStep({
</p>
{currentUser && (
<div className="text-xs text-neutral-400">
{walletType === 'bitcoin' && currentUser.ordinalDetails && (
<p>
Ordinal ID:{' '}
{typeof currentUser.ordinalDetails === 'object'
? currentUser.ordinalDetails.ordinalId
: 'Verified'}
</p>
)}
{walletType === 'ethereum' &&
currentUser.ensDetails?.ensName && (
<p>ENS Name: {currentUser.ensDetails.ensName}</p>
)}
{walletType === 'bitcoin' && <p>Ordinal ID: Verified</p>}
{walletType === 'ethereum' && <p>ENS Name: Verified</p>}
</div>
)}
</div>

View File

@ -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({

View File

@ -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<string, string | undefined>(
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,

141
src/hooks/useCache.tsx Normal file
View File

@ -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<CacheData>({
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<string>();
// 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;
}

View File

@ -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<UserDisplayInfo>({
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;
}

View File

@ -12,6 +12,9 @@ export interface MessageResult {
export interface MessageServiceInterface {
sendMessage(message: UnsignedMessage): Promise<MessageResult>;
verifyMessage(message: OpchanMessage): Promise<boolean>;
signAndBroadcastMessage(
message: UnsignedMessage
): Promise<OpchanMessage | null>;
}
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<OpchanMessage | null> {
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
*/

View File

@ -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<UserIdentity | null> {
// 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<boolean> {
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<UserIdentity | null> {
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<string | null> {
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<void> {
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)}`;
}
}

View File

@ -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<string, { isValid: boolean; timestamp: number }>();
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<ValidationReport> {
const errors: string[] = [];
let hasRequiredFields = false;
let hasValidSignature = false;

View File

@ -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}`);
}

View File

@ -9,6 +9,7 @@ export const CONTENT_TOPICS: Record<MessageType, string> = {
[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',
};
/**

View File

@ -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

View File

@ -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 = {};
}
}

View File

@ -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;

View File

@ -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';
};
}