mirror of
https://github.com/logos-messaging/OpChan.git
synced 2026-01-03 13:23:08 +00:00
feat: call signs
This commit is contained in:
parent
414747f396
commit
1216ab1774
@ -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}
|
||||
/>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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
|
||||
|
||||
215
src/components/ui/call-sign-setup-dialog.tsx
Normal file
215
src/components/ui/call-sign-setup-dialog.tsx
Normal 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;
|
||||
@ -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>
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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
141
src/hooks/useCache.tsx
Normal 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;
|
||||
}
|
||||
92
src/hooks/useUserDisplay.ts
Normal file
92
src/hooks/useUserDisplay.ts
Normal 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;
|
||||
}
|
||||
@ -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
|
||||
*/
|
||||
|
||||
330
src/lib/services/UserIdentityService.ts
Normal file
330
src/lib/services/UserIdentityService.ts
Normal 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)}`;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
|
||||
@ -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',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 = {};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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';
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user