fix: hydration interface

This commit is contained in:
Danish Arora 2025-09-25 15:39:54 +05:30
parent de4acee1c1
commit 66802d7d78
No known key found for this signature in database
GPG Key ID: 1C6EF37CDAE1426E
19 changed files with 483 additions and 720 deletions

View File

@ -15,11 +15,11 @@
"test:ui": "vitest --ui"
},
"dependencies": {
"@opchan/react": "file:../packages/react",
"@opchan/core": "file:../packages/core",
"@hookform/resolvers": "^3.9.0",
"@noble/ed25519": "^2.2.3",
"@noble/hashes": "^1.8.0",
"@opchan/core": "file:../packages/core",
"@opchan/react": "file:../packages/react",
"@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-alert-dialog": "^1.1.1",
"@radix-ui/react-aspect-ratio": "^1.1.0",
@ -91,7 +91,7 @@
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"@vitejs/plugin-react-swc": "^3.11.0",
"@vitest/ui": "^3.2.4",
"autoprefixer": "^10.4.20",
"eslint": "^9.9.0",

View File

@ -61,22 +61,9 @@ const Header = () => {
const isConnected = bitcoinAccount.isConnected || ethereumAccount.isConnected;
// Use currentUser address (which has ENS details) instead of raw AppKit address
const address = currentUser?.address || (isConnected
? bitcoinAccount.isConnected
? bitcoinAccount.address
: ethereumAccount.address
: undefined);
const [walletWizardOpen, setWalletWizardOpen] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
useEffect(() => {
console.log({currentUser})
}, [currentUser])
// Use LocalDatabase to persist wizard state across navigation
const getHasShownWizard = async (): Promise<boolean> => {
@ -142,6 +129,11 @@ const Header = () => {
}
};
useEffect(() => {
console.log('currentUser', currentUser)
}, [currentUser])
const getStatusIcon = () => {
if (!isConnected) return <CircleSlash className="w-4 h-4" />;
@ -251,15 +243,6 @@ const Header = () => {
align="end"
className="w-56 bg-black/95 border-cyber-muted/30"
>
<div className="px-3 py-2 border-b border-cyber-muted/30">
<div className="text-sm font-medium text-white">
{currentUser?.displayName}
</div>
<div className="text-xs text-cyber-neutral">
{address?.slice(0, 8)}...{address?.slice(-4)}
</div>
</div>
<DropdownMenuItem asChild>
<Link
to="/profile"

View File

@ -194,7 +194,7 @@ export function DelegationStep({
{/* User Address */}
{currentUser && (
<div className="text-xs text-neutral-400">
<div className="font-mono break-all">{currentUser.address}</div>
<div className="font-mono break-all">{currentUser.displayName}</div>
</div>
)}

View File

@ -10,7 +10,6 @@ import {
} from 'lucide-react';
import { useAuth } from '@/hooks';
import { EVerificationStatus } from '@opchan/core';
import { useAppKitAccount } from '@reown/appkit/react';
import { OrdinalDetails, EnsDetails } from '@opchan/core';
interface VerificationStepProps {
@ -26,19 +25,7 @@ export function VerificationStep({
isLoading,
setIsLoading,
}: VerificationStepProps) {
const { currentUser, verificationStatus, isAuthenticating, verifyOwnership } = useAuth();
// Get account info to determine wallet type
const bitcoinAccount = useAppKitAccount({ namespace: 'bip122' });
const ethereumAccount = useAppKitAccount({ namespace: 'eip155' });
const isBitcoinConnected = bitcoinAccount.isConnected;
const isEthereumConnected = ethereumAccount.isConnected;
const walletType = isBitcoinConnected
? 'bitcoin'
: isEthereumConnected
? 'ethereum'
: undefined;
const { currentUser, verifyOwnership } = useAuth();
const [verificationResult, setVerificationResult] = React.useState<{
success: boolean;
@ -52,24 +39,17 @@ export function VerificationStep({
verificationResult?.success &&
verificationResult.message.includes('Checking ownership')
) {
// Check if actual ownership was verified
// Treat centralized verification status as source of truth
const isOwnerVerified =
verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED;
const hasOwnership =
walletType === 'bitcoin'
? isOwnerVerified && !!currentUser?.ordinalDetails
: isOwnerVerified && !!currentUser?.ensDetails;
const hasOwnership = currentUser?.verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED;
if (hasOwnership) {
setVerificationResult({
success: true,
message:
walletType === 'bitcoin'
currentUser?.walletType === 'bitcoin'
? 'Ordinal ownership verified successfully!'
: 'ENS ownership verified successfully!',
details:
walletType === 'bitcoin'
currentUser?.walletType === 'bitcoin'
? currentUser?.ordinalDetails
: currentUser?.ensDetails,
});
@ -77,13 +57,13 @@ export function VerificationStep({
setVerificationResult({
success: false,
message:
walletType === 'bitcoin'
currentUser?.walletType === 'bitcoin'
? 'No Ordinal ownership found. You can still participate in the forum with your connected wallet!'
: 'No ENS ownership found. You can still participate in the forum with your connected wallet!',
});
}
}
}, [currentUser, verificationResult, walletType, verificationStatus]);
}, [currentUser, verificationResult]);
const handleVerify = async () => {
console.log('🔘 Verify button clicked, currentUser:', currentUser);
@ -98,17 +78,15 @@ export function VerificationStep({
try {
console.log('📞 Calling verifyWallet()...');
const success = await verifyOwnership();
console.log('📊 verifyWallet returned:', success);
if (success) {
await verifyOwnership();
if (currentUser?.verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED) {
// For now, just show success - the actual ownership check will be done
// by the useEffect when the user state updates
console.log('✅ Verification successful, setting result');
setVerificationResult({
success: true,
message:
walletType === 'bitcoin'
currentUser?.walletType === 'bitcoin'
? 'Verification process completed. Checking ownership...'
: 'Verification process completed. Checking ownership...',
details: undefined,
@ -118,7 +96,7 @@ export function VerificationStep({
setVerificationResult({
success: false,
message:
walletType === 'bitcoin'
currentUser?.walletType === 'bitcoin'
? 'No Ordinal ownership found. You can still participate in the forum with your connected wallet!'
: 'No ENS ownership found. You can still participate in the forum with your connected wallet!',
});
@ -140,19 +118,19 @@ export function VerificationStep({
};
const getVerificationType = () => {
return walletType === 'bitcoin' ? 'Bitcoin Ordinal' : 'Ethereum ENS';
return currentUser?.walletType === 'bitcoin' ? 'Bitcoin Ordinal' : 'Ethereum ENS';
};
const getVerificationIcon = () => {
return walletType === 'bitcoin' ? Bitcoin : Coins;
return currentUser?.walletType === 'bitcoin' ? Bitcoin : Coins;
};
const getVerificationColor = () => {
return walletType === 'bitcoin' ? 'text-orange-500' : 'text-blue-500';
return currentUser?.walletType === 'bitcoin' ? 'text-orange-500' : 'text-blue-500';
};
const getVerificationDescription = () => {
if (walletType === 'bitcoin') {
if (currentUser?.walletType === 'bitcoin') {
return "Verify your Bitcoin Ordinal ownership to unlock premium features. If you don't own any Ordinals, you can still participate in the forum with your connected wallet.";
} else {
return "Verify your Ethereum ENS ownership to unlock premium features. If you don't own any ENS, you can still participate in the forum with your connected wallet.";
@ -194,7 +172,7 @@ export function VerificationStep({
</p>
{verificationResult.details && (
<div className="text-xs text-neutral-400">
{walletType === 'bitcoin' ? (
{currentUser?.walletType === 'bitcoin' ? (
<p>
Ordinal ID:{' '}
{typeof verificationResult.details === 'object' &&
@ -231,7 +209,7 @@ export function VerificationStep({
}
// Show verification status
if (verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED) {
if (currentUser?.verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED) {
return (
<div className="flex flex-col h-full">
<div className="flex-1 space-y-4">
@ -247,8 +225,8 @@ export function VerificationStep({
</p>
{currentUser && (
<div className="text-xs text-neutral-400">
{walletType === 'bitcoin' && <p>Ordinal ID: Verified</p>}
{walletType === 'ethereum' && <p>ENS Name: Verified</p>}
{currentUser?.walletType === 'bitcoin' && <p>Ordinal ID: Verified</p>}
{currentUser?.walletType === 'ethereum' && <p>ENS Name: Verified</p>}
</div>
)}
</div>
@ -294,7 +272,7 @@ export function VerificationStep({
</span>
</div>
<ul className="text-xs text-neutral-400 space-y-1">
{walletType === 'bitcoin' ? (
{currentUser?.walletType === 'bitcoin' ? (
<>
<li> We'll check your wallet for Bitcoin Ordinal ownership</li>
<li> If found, you'll get full posting and voting access</li>
@ -319,10 +297,10 @@ export function VerificationStep({
<div className="mt-auto space-y-3">
<Button
onClick={handleVerify}
disabled={isLoading || isAuthenticating}
disabled={isLoading}
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
>
{isLoading || isAuthenticating ? (
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Verifying...
@ -336,7 +314,7 @@ export function VerificationStep({
onClick={onBack}
variant="outline"
className="w-full border-neutral-600 text-neutral-400 hover:bg-neutral-800"
disabled={isLoading || isAuthenticating}
disabled={isLoading}
>
Back
</Button>

View File

@ -194,7 +194,7 @@ export function WalletConnectionDialog({
</p>
<p className="text-sm text-neutral-300 mb-2">Address:</p>
<p className="text-xs font-mono text-neutral-400 break-all">
{activeAddress}
{activeAddress ? `${activeAddress.slice(0, 6)}...${activeAddress.slice(-4)}` : ''}
</p>
</div>

View File

@ -41,20 +41,7 @@ export default function ProfilePage() {
// Get current user from auth context for the address
const { currentUser, delegationInfo } = useAuth();
const address = currentUser?.address;
// Debug current user ENS info
console.log('📋 Profile page debug:', {
address,
currentUser: currentUser
? {
address: currentUser.address,
callSign: currentUser.callSign,
ensDetails: currentUser.ensDetails,
verificationStatus: currentUser.verificationStatus,
}
: null
});
const [isEditing, setIsEditing] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);

2
package-lock.json generated
View File

@ -103,7 +103,7 @@
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"@vitejs/plugin-react-swc": "^3.11.0",
"@vitest/ui": "^3.2.4",
"autoprefixer": "^10.4.20",
"eslint": "^9.9.0",

View File

@ -235,8 +235,7 @@ export class LocalDatabase {
displayPreference,
lastUpdated: timestamp,
verificationStatus:
existing?.verificationStatus ??
EVerificationStatus.WALLET_UNCONNECTED,
existing?.verificationStatus
} as UserIdentityCache[string];
this.cache.userIdentities[author] = nextRecord;
@ -658,6 +657,7 @@ export class LocalDatabase {
displayPreference: EDisplayPreference.WALLET_ADDRESS,
lastUpdated: 0,
verificationStatus: EVerificationStatus.WALLET_UNCONNECTED,
displayName: address.slice(0, 6) + '...' + address.slice(-4),
};
const merged: UserIdentityCache[string] = {

View File

@ -24,6 +24,8 @@ export class MessageService implements MessageServiceInterface {
this.delegationManager = delegationManager;
}
// ===== PUBLIC METHODS =====
/**
* Sign and send a message to the Waku network
*/

View File

@ -11,6 +11,8 @@ class Ordinals {
this.ordiscan = ordiscan;
}
// ===== PUBLIC STATIC METHODS =====
static getInstance(): Ordinals {
if (!Ordinals.instance) {
const apiKey = environment.ordiscanApiKey;
@ -22,6 +24,8 @@ class Ordinals {
return Ordinals.instance;
}
// ===== PUBLIC INSTANCE METHODS =====
/**
* Get Ordinal details for a Bitcoin address
*/

View File

@ -6,9 +6,8 @@ import {
UserIdentityCache,
} from '../../types/waku';
import { MessageService } from './MessageService';
import messageManager from '../waku';
import { localDatabase } from '../database/LocalDatabase';
import { WalletManager } from '../wallet';
import { walletManager, WalletManager } from '../wallet';
export interface UserIdentity {
address: string;
@ -19,224 +18,58 @@ export interface UserIdentity {
};
callSign?: string;
displayPreference: EDisplayPreference;
displayName: string;
lastUpdated: number;
verificationStatus: EVerificationStatus;
}
export class UserIdentityService {
private messageService: MessageService;
private userIdentityCache: UserIdentityCache = {};
private refreshListeners: Set<(address: string) => void> = new Set();
private ensResolutionCache: Map<string, Promise<string | null>> = new Map();
private debounceTimers: Map<string, NodeJS.Timeout> = new Map();
constructor(messageService: MessageService) {
this.messageService = messageService;
}
// ===== PUBLIC METHODS =====
/**
* Get user identity from cache or resolve from sources with debouncing
* Unified identity getter. When opts.fresh === true, bypass caches.
*/
async getUserIdentity(address: string): Promise<UserIdentity | null> {
// Debounce rapid calls to the same address
async getIdentity(
address: string,
opts?: { fresh?: boolean }
): Promise<UserIdentity | null> {
if (opts?.fresh) {
return this.getUserIdentityFresh(address);
}
// Debounce rapid calls for non-fresh path
if (this.debounceTimers.has(address)) {
clearTimeout(this.debounceTimers.get(address)!);
}
return new Promise((resolve) => {
const timer = setTimeout(async () => {
this.debounceTimers.delete(address);
const result = await this.getUserIdentityInternal(address);
resolve(result);
}, 100); // 100ms debounce
}, 100);
this.debounceTimers.set(address, timer);
});
}
/**
* Internal method to get user identity without debouncing
*/
private async getUserIdentityInternal(address: string): Promise<UserIdentity | null> {
// Check internal cache first
if (this.userIdentityCache[address]) {
const cached = this.userIdentityCache[address];
// Enrich with ENS name if missing and ETH address
if (!cached.ensName && address.startsWith('0x')) {
const ensName = await this.resolveENSName(address);
if (ensName) {
cached.ensName = ensName;
// Update verification status if ENS is found
if (cached.verificationStatus !== EVerificationStatus.ENS_ORDINAL_VERIFIED) {
cached.verificationStatus = EVerificationStatus.ENS_ORDINAL_VERIFIED;
// Persist the updated verification status to LocalDatabase
await localDatabase.upsertUserIdentity(address, {
ensName,
verificationStatus: EVerificationStatus.ENS_ORDINAL_VERIFIED,
lastUpdated: Date.now(),
});
}
}
}
return {
address,
ensName: cached.ensName,
ordinalDetails: cached.ordinalDetails,
callSign: cached.callSign,
displayPreference: cached.displayPreference,
lastUpdated: cached.lastUpdated,
verificationStatus: this.mapVerificationStatus(
cached.verificationStatus
),
};
}
// Check LocalDatabase first for persisted identities (warm start)
const persisted = localDatabase.cache.userIdentities[address];
if (persisted) {
this.userIdentityCache[address] = {
ensName: persisted.ensName,
ordinalDetails: persisted.ordinalDetails,
callSign: persisted.callSign,
displayPreference: persisted.displayPreference,
lastUpdated: persisted.lastUpdated,
verificationStatus: persisted.verificationStatus,
};
const result = {
address,
ensName: persisted.ensName,
ordinalDetails: persisted.ordinalDetails,
callSign: persisted.callSign,
displayPreference: persisted.displayPreference,
lastUpdated: persisted.lastUpdated,
verificationStatus: this.mapVerificationStatus(
persisted.verificationStatus
),
} as UserIdentity;
// Enrich with ENS name if missing and ETH address
if (!result.ensName && address.startsWith('0x')) {
const ensName = await this.resolveENSName(address);
if (ensName) {
result.ensName = ensName;
this.userIdentityCache[address].ensName = ensName;
// Update verification status if ENS is found
if (result.verificationStatus !== EVerificationStatus.ENS_ORDINAL_VERIFIED) {
result.verificationStatus = EVerificationStatus.ENS_ORDINAL_VERIFIED;
this.userIdentityCache[address].verificationStatus = EVerificationStatus.ENS_ORDINAL_VERIFIED;
// Persist the updated verification status to LocalDatabase
await localDatabase.upsertUserIdentity(address, {
ensName,
verificationStatus: EVerificationStatus.ENS_ORDINAL_VERIFIED,
lastUpdated: Date.now(),
});
}
}
}
return result;
}
// Fallback: Check Waku message cache
const cacheServiceData =
messageManager.messageCache.userIdentities[address];
if (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,
};
const result = {
address,
ensName: cacheServiceData.ensName,
ordinalDetails: cacheServiceData.ordinalDetails,
callSign: cacheServiceData.callSign,
displayPreference: cacheServiceData.displayPreference,
lastUpdated: cacheServiceData.lastUpdated,
verificationStatus: this.mapVerificationStatus(
cacheServiceData.verificationStatus
),
} as UserIdentity;
// Enrich with ENS name if missing and ETH address
if (!result.ensName && address.startsWith('0x')) {
const ensName = await this.resolveENSName(address);
if (ensName) {
result.ensName = ensName;
this.userIdentityCache[address].ensName = ensName;
// Update verification status if ENS is found
if (result.verificationStatus !== EVerificationStatus.ENS_ORDINAL_VERIFIED) {
result.verificationStatus = EVerificationStatus.ENS_ORDINAL_VERIFIED;
this.userIdentityCache[address].verificationStatus = EVerificationStatus.ENS_ORDINAL_VERIFIED;
// Persist the updated verification status to LocalDatabase
await localDatabase.upsertUserIdentity(address, {
ensName,
verificationStatus: EVerificationStatus.ENS_ORDINAL_VERIFIED,
lastUpdated: Date.now(),
});
}
}
}
return result;
}
// 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,
lastUpdated: identity.lastUpdated,
verificationStatus: identity.verificationStatus,
};
// Persist the resolved identity to LocalDatabase for future use
await localDatabase.upsertUserIdentity(address, {
ensName: identity.ensName,
ordinalDetails: identity.ordinalDetails,
callSign: identity.callSign,
displayPreference: identity.displayPreference,
verificationStatus: identity.verificationStatus,
lastUpdated: identity.lastUpdated,
});
}
return identity;
}
/**
* Force a fresh identity resolution bypassing caches and LocalDatabase.
* Useful for explicit verification flows where we must hit upstream resolvers.
*/
async getUserIdentityFresh(address: string): Promise<UserIdentity | null> {
const identity = await this.resolveUserIdentity(address);
if (identity) {
// Update in-memory cache to reflect the fresh result
this.userIdentityCache[address] = {
ensName: identity.ensName,
ordinalDetails: identity.ordinalDetails,
callSign: identity.callSign,
displayPreference: identity.displayPreference,
lastUpdated: identity.lastUpdated,
verificationStatus: identity.verificationStatus,
};
// Persist the fresh identity to LocalDatabase
await localDatabase.upsertUserIdentity(address, {
ensName: identity.ensName,
ordinalDetails: identity.ordinalDetails,
callSign: identity.callSign,
displayPreference: identity.displayPreference,
verificationStatus: identity.verificationStatus,
lastUpdated: identity.lastUpdated,
});
await localDatabase.upsertUserIdentity(address, identity);
}
return identity;
}
@ -244,27 +77,32 @@ export class UserIdentityService {
/**
* Get all cached user identities
*/
getAllUserIdentities(): UserIdentity[] {
return Object.entries(this.userIdentityCache).map(([address, cached]) => ({
getAll(): UserIdentity[] {
return Object.entries(localDatabase.cache.userIdentities).map(([address, cached]) => ({
address,
ensName: cached.ensName,
ordinalDetails: cached.ordinalDetails,
callSign: cached.callSign,
displayPreference: cached.displayPreference,
displayName: this.getDisplayName(address),
lastUpdated: cached.lastUpdated,
verificationStatus: this.mapVerificationStatus(cached.verificationStatus),
}));
}
/**
* Update user profile via Waku message
* New contract: return result and updated identity.
*/
async updateUserProfile(
async updateProfile(
address: string,
callSign: string | undefined,
displayPreference: EDisplayPreference
): Promise<boolean> {
updates: { callSign?: string; displayPreference?: EDisplayPreference }
): Promise<{ ok: true; identity: UserIdentity } | { ok: false; error: Error }>{
try {
const callSign = updates.callSign?.trim() || undefined;
const displayPreference =
updates.displayPreference ??
localDatabase.cache.userIdentities[address]?.displayPreference ??
EDisplayPreference.WALLET_ADDRESS;
const timestamp = Date.now();
const unsignedMessage: UnsignedUserProfileUpdateMessage = {
@ -274,66 +112,124 @@ export class UserIdentityService {
author: address,
displayPreference,
};
// Only include callSign if provided and non-empty
if (callSign && callSign.trim()) {
unsignedMessage.callSign = callSign.trim();
}
if (callSign) unsignedMessage.callSign = callSign;
const signedMessage =
await this.messageService.signAndBroadcastMessage(unsignedMessage);
const signedMessage = await this.messageService.signAndBroadcastMessage(unsignedMessage);
if (!signedMessage) return { ok: false, error: new Error('Broadcast failed') };
const profileMessage: UserProfileUpdateMessage = {
id: unsignedMessage.id,
type: MessageType.USER_PROFILE_UPDATE,
timestamp,
author: address,
displayPreference,
signature: signedMessage.signature,
browserPubKey: signedMessage.browserPubKey,
delegationProof: signedMessage.delegationProof,
...(callSign ? { callSign } : {}),
};
// If broadcast was successful, immediately update local cache
if (signedMessage) {
this.updateUserIdentityFromMessage(
signedMessage as UserProfileUpdateMessage
);
// Persist, notify
await localDatabase.applyMessage(profileMessage);
this.notifyRefreshListeners(address);
// Also update the local database cache immediately
if (this.userIdentityCache[address]) {
const updatedIdentity = {
...this.userIdentityCache[address],
callSign:
callSign && callSign.trim()
? callSign.trim()
: this.userIdentityCache[address].callSign,
displayPreference,
lastUpdated: timestamp,
};
localDatabase.cache.userIdentities[address] = updatedIdentity;
// Persist to IndexedDB using the storeMessage method
const profileMessage: UserProfileUpdateMessage = {
id: unsignedMessage.id,
type: MessageType.USER_PROFILE_UPDATE,
timestamp,
author: address,
displayPreference,
signature: signedMessage.signature,
browserPubKey: signedMessage.browserPubKey,
delegationProof: signedMessage.delegationProof,
};
if (callSign && callSign.trim()) {
profileMessage.callSign = callSign.trim();
}
// Apply the message to update the database
await localDatabase.applyMessage(profileMessage);
// Notify listeners that the user identity has been updated
this.notifyRefreshListeners(address);
}
}
return !!signedMessage;
const identity = await this.getIdentity(address);
if (!identity) return { ok: false, error: new Error('Identity unavailable') };
return { ok: true, identity };
} catch (error) {
console.error('Failed to update user profile:', error);
return false;
return { ok: false, error: error as Error };
}
}
/**
* Update user identity from Waku message
*/
updateUserIdentityFromMessage(message: UserProfileUpdateMessage): void {
// No-op: LocalDatabase.applyMessage mutates the canonical cache.
// We only need to notify listeners to refresh their local views.
this.notifyRefreshListeners(message.author);
}
/**
* Refresh user identity (force re-resolution)
*/
async refreshIdentity(address: string): Promise<void> {
await this.getIdentity(address, { fresh: true });
}
/**
* Clear user identity cache
*/
clearCache(): void {
this.debounceTimers.forEach(timer => clearTimeout(timer));
this.debounceTimers.clear();
}
/**
* Subscribe with identity payload
*/
subscribe(
listener: (address: string, identity: UserIdentity | null) => void
): () => void {
const wrapped = async (address: string) => {
const record = localDatabase.cache.userIdentities[address];
const identity = record
? this.buildUserIdentityFromRecord(address, record)
: await this.getIdentity(address);
listener(address, identity);
};
this.refreshListeners.add(wrapped);
return () => this.refreshListeners.delete(wrapped);
}
/**
* Get display name for user based on their preferences
*/
getDisplayName(address: string): string {
const identity = localDatabase.cache.userIdentities[address];
if (!identity) {
return `${address.slice(0, 6)}...${address.slice(-4)}`;
}
if (
identity.displayPreference === EDisplayPreference.CALL_SIGN &&
identity.callSign
) {
return identity.callSign;
}
if (identity.ensName) {
return identity.ensName;
}
return `${address.slice(0, 6)}...${address.slice(-4)}`;
}
// ===== PRIVATE METHODS =====
/**
* Internal method to get user identity without debouncing
*/
private async getUserIdentityInternal(address: string): Promise<UserIdentity | null> {
const record = this.getCachedRecord(address);
if (record) {
let identity = this.buildUserIdentityFromRecord(address, record);
identity = await this.ensureEnsEnriched(address, identity);
return identity;
}
// Try to resolve identity from various sources
const resolved = await this.resolveUserIdentity(address);
if (resolved) {
// Persist the resolved identity to LocalDatabase for future use
await localDatabase.upsertUserIdentity(address, resolved);
return resolved;
}
return null;
}
/**
* Resolve user identity from various sources
*/
@ -350,19 +246,24 @@ export class UserIdentityService {
const defaultDisplayPreference: EDisplayPreference =
EDisplayPreference.WALLET_ADDRESS;
// Default verification status based on what we can resolve
let verificationStatus: EVerificationStatus =
EVerificationStatus.WALLET_UNCONNECTED;
const isWalletConnected = WalletManager.hasInstance()
? walletManager.getInstance().isConnected()
: false;
let verificationStatus: EVerificationStatus;
if (ensName || ordinalDetails) {
verificationStatus = EVerificationStatus.ENS_ORDINAL_VERIFIED;
} else {
verificationStatus = isWalletConnected ? EVerificationStatus.WALLET_CONNECTED : EVerificationStatus.WALLET_UNCONNECTED;
}
return {
address,
ensName: ensName || undefined,
ordinalDetails: ordinalDetails || undefined,
callSign: undefined, // Will be populated from Waku messages
displayPreference: defaultDisplayPreference,
displayName: this.getDisplayName(address),
lastUpdated: Date.now(),
verificationStatus,
};
@ -380,47 +281,13 @@ export class UserIdentityService {
return null; // Not an Ethereum address
}
// Check if we already have a pending resolution for this address
if (this.ensResolutionCache.has(address)) {
return this.ensResolutionCache.get(address)!;
}
// Check if we already have this resolved in the cache and it's recent
const cached = this.userIdentityCache[address];
if (cached?.ensName && cached.lastUpdated > Date.now() - 300000) { // 5 minutes cache
// Prefer previously persisted ENS if recent
const cached = localDatabase.cache.userIdentities[address];
if (cached?.ensName && cached.lastUpdated > Date.now() - 300000) {
return cached.ensName;
}
// Create and cache the promise
const resolutionPromise = this.doResolveENSName(address);
this.ensResolutionCache.set(address, resolutionPromise);
// Clean up the cache after resolution (successful or failed)
resolutionPromise.finally(() => {
// Remove from cache after 60 seconds to allow for re-resolution if needed
setTimeout(() => {
this.ensResolutionCache.delete(address);
}, 60000);
});
return resolutionPromise;
}
private async doResolveENSName(address: string): Promise<string | null> {
try {
// Import the ENS resolver from wagmi
const { getEnsName } = await import('@wagmi/core');
const { config } = await import('../wallet/config');
const ensName = await getEnsName(config, {
address: address as `0x${string}`,
});
return ensName || null;
} catch (error) {
console.error('Failed to resolve ENS name:', error);
return null;
}
return this.doResolveENSName(address);
}
/**
@ -451,34 +318,80 @@ export class UserIdentityService {
}
/**
* Update user identity from Waku message
* Notify all listeners that user identity data has changed
*/
updateUserIdentityFromMessage(message: UserProfileUpdateMessage): void {
const { author, callSign, displayPreference, timestamp } = message;
private notifyRefreshListeners(address: string): void {
this.refreshListeners.forEach(listener => listener(address));
}
if (!this.userIdentityCache[author]) {
// Create new identity entry if it doesn't exist
this.userIdentityCache[author] = {
ensName: undefined,
ordinalDetails: undefined,
callSign: undefined,
displayPreference,
lastUpdated: timestamp,
verificationStatus: EVerificationStatus.WALLET_UNCONNECTED,
};
// ===== HELPER METHODS =====
/**
* Normalize a cached identity record into a strongly-typed UserIdentity
*/
private buildUserIdentityFromRecord(
address: string,
record: UserIdentityCache[string]
): UserIdentity {
return {
address,
ensName: record.ensName,
ordinalDetails: record.ordinalDetails,
callSign: record.callSign,
displayPreference: record.displayPreference,
displayName: this.getDisplayName(address),
lastUpdated: record.lastUpdated,
verificationStatus: this.mapVerificationStatus(record.verificationStatus),
};
}
/**
* Retrieve a cached identity record from memory, LocalDatabase, or Waku cache
* and hydrate in-memory cache for subsequent accesses.
*/
private getCachedRecord(
address: string
): UserIdentityCache[string] | null {
return localDatabase.cache.userIdentities[address] || null;
}
/**
* Ensure ENS is enriched if missing. Persists updates and keeps caches in sync.
*/
private async ensureEnsEnriched(
address: string,
identity: UserIdentity
): Promise<UserIdentity> {
if (!identity.ensName && address.startsWith('0x')) {
const ensName = await this.resolveENSName(address);
if (ensName) {
const updated: UserIdentity = {
...identity,
ensName,
verificationStatus: EVerificationStatus.ENS_ORDINAL_VERIFIED,
lastUpdated: Date.now(),
};
await localDatabase.upsertUserIdentity(address, {
ensName,
verificationStatus: EVerificationStatus.ENS_ORDINAL_VERIFIED,
lastUpdated: updated.lastUpdated,
});
return updated;
}
}
return identity;
}
// Update only if this message is newer
if (timestamp > this.userIdentityCache[author].lastUpdated) {
this.userIdentityCache[author] = {
...this.userIdentityCache[author],
callSign,
displayPreference,
lastUpdated: timestamp,
};
// Notify listeners that the user identity has been updated
this.notifyRefreshListeners(author);
private async doResolveENSName(address: string): Promise<string | null> {
try {
// Resolve ENS via centralized WalletManager helper
const ensName = await WalletManager.resolveENS(address);
return ensName || null;
} catch (error) {
console.error('Failed to resolve ENS name:', error);
return null;
}
}
@ -507,61 +420,4 @@ export class UserIdentityService {
return EVerificationStatus.WALLET_UNCONNECTED;
}
}
/**
* 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 = {};
this.ensResolutionCache.clear();
// Clear all debounce timers
this.debounceTimers.forEach(timer => clearTimeout(timer));
this.debounceTimers.clear();
}
/**
* Add a refresh listener for when user identity data changes
*/
addRefreshListener(listener: (address: string) => void): () => void {
this.refreshListeners.add(listener);
return () => this.refreshListeners.delete(listener);
}
/**
* Notify all listeners that user identity data has changed
*/
private notifyRefreshListeners(address: string): void {
this.refreshListeners.forEach(listener => listener(address));
}
/**
* 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 === EDisplayPreference.CALL_SIGN &&
identity.callSign
) {
return identity.callSign;
}
if (identity.ensName) {
return identity.ensName;
}
return `${address.slice(0, 6)}...${address.slice(-4)}`;
}
}

View File

@ -27,6 +27,44 @@ export class ReliableMessaging {
this.initializeChannel(node);
}
// ===== PUBLIC METHODS =====
public async sendMessage(
message: OpchanMessage,
statusCallback?: MessageStatusCallback
) {
if (!this.channel) {
throw new Error('Reliable channel not initialized');
}
const encodedMessage = this.codecManager.encodeMessage(message);
const messageId = ReliableChannel.getMessageId(encodedMessage);
if (statusCallback) {
this.messageCallbacks.set(messageId, statusCallback);
}
try {
return this.channel.send(encodedMessage);
} catch (error) {
this.messageCallbacks.delete(messageId);
throw error;
}
}
public onMessage(callback: IncomingMessageCallback): () => void {
this.incomingMessageCallbacks.add(callback);
return () => this.incomingMessageCallbacks.delete(callback);
}
public cleanup(): void {
this.messageCallbacks.clear();
this.incomingMessageCallbacks.clear();
this.channel = null;
}
// ===== PRIVATE METHODS =====
private async initializeChannel(node: LightNode): Promise<void> {
const encoder = this.codecManager.getEncoder();
const decoder = this.codecManager.getDecoder();
@ -53,7 +91,6 @@ export class ReliableMessaging {
): void {
channel.addEventListener("message-received", event => {
try {
console.log("received a message, processing...", event.detail);
const wakuMessage = event.detail;
if (wakuMessage.payload) {
const opchanMessage = this.codecManager.decodeMessage(
@ -96,38 +133,4 @@ export class ReliableMessaging {
}
);
}
public async sendMessage(
message: OpchanMessage,
statusCallback?: MessageStatusCallback
) {
if (!this.channel) {
throw new Error('Reliable channel not initialized');
}
const encodedMessage = this.codecManager.encodeMessage(message);
const messageId = ReliableChannel.getMessageId(encodedMessage);
if (statusCallback) {
this.messageCallbacks.set(messageId, statusCallback);
}
try {
return this.channel.send(encodedMessage);
} catch (error) {
this.messageCallbacks.delete(messageId);
throw error;
}
}
public onMessage(callback: IncomingMessageCallback): () => void {
this.incomingMessageCallbacks.add(callback);
return () => this.incomingMessageCallbacks.delete(callback);
}
public cleanup(): void {
this.messageCallbacks.clear();
this.incomingMessageCallbacks.clear();
this.channel = null;
}
}

View File

@ -11,18 +11,64 @@ export type { HealthChangeCallback, MessageStatusCallback };
class MessageManager {
private nodeManager: WakuNodeManager | null = null;
// LocalDatabase eliminates the need for CacheService
private messageService: MessageService | null = null;
private reliableMessaging: ReliableMessaging | null = null;
constructor() {}
// ===== PUBLIC STATIC METHODS =====
public static async create(): Promise<MessageManager> {
const manager = new MessageManager();
await manager.initialize();
return manager;
}
// ===== PUBLIC INSTANCE METHODS =====
public async stop(): Promise<void> {
this.cleanupReliableMessaging();
this.messageService?.cleanup();
await this.nodeManager?.stop();
}
public get isReady(): boolean {
return this.nodeManager?.isReady ?? false;
}
public get currentHealth(): HealthStatus {
return this.nodeManager?.currentHealth ?? HealthStatus.Unhealthy;
}
public onHealthChange(callback: HealthChangeCallback): () => void {
if (!this.nodeManager) {
throw new Error('Node manager not initialized');
}
return this.nodeManager.onHealthChange(callback);
}
//TODO: return event handlers?
public async sendMessage(
message: OpchanMessage,
statusCallback?: MessageStatusCallback
): Promise<void> {
if (!this.messageService) {
throw new Error('MessageManager not fully initialized');
}
this.messageService.sendMessage(message, statusCallback);
}
public onMessageReceived(
callback: (message: OpchanMessage) => void
): () => void {
if (!this.messageService) {
throw new Error('MessageManager not fully initialized');
}
return this.messageService.onMessageReceived(callback);
}
// ===== PRIVATE METHODS =====
private async initialize(): Promise<void> {
try {
this.nodeManager = await WakuNodeManager.create();
@ -72,54 +118,6 @@ class MessageManager {
this.messageService?.updateReliableMessaging(null);
}
}
public async stop(): Promise<void> {
this.cleanupReliableMessaging();
this.messageService?.cleanup();
await this.nodeManager?.stop();
}
public get isReady(): boolean {
return this.nodeManager?.isReady ?? false;
}
public get currentHealth(): HealthStatus {
return this.nodeManager?.currentHealth ?? HealthStatus.Unhealthy;
}
public onHealthChange(callback: HealthChangeCallback): () => void {
if (!this.nodeManager) {
throw new Error('Node manager not initialized');
}
return this.nodeManager.onHealthChange(callback);
}
//TODO: return event handlers?
public async sendMessage(
message: OpchanMessage,
statusCallback?: MessageStatusCallback
): Promise<void> {
if (!this.messageService) {
throw new Error('MessageManager not fully initialized');
}
this.messageService.sendMessage(message, statusCallback);
}
public onMessageReceived(
callback: (message: OpchanMessage) => void
): () => void {
if (!this.messageService) {
throw new Error('MessageManager not fully initialized');
}
return this.messageService.onMessageReceived(callback);
}
public get messageCache() {
if (!this.messageService) {
throw new Error('MessageManager not fully initialized');
}
return this.messageService.messageCache;
}
}
// Create a default instance that can be used synchronously but initialized asynchronously
@ -129,6 +127,8 @@ export class DefaultMessageManager {
private _pendingHealthSubscriptions: HealthChangeCallback[] = [];
private _pendingMessageSubscriptions: ((message: any) => void)[] = [];
// ===== PUBLIC METHODS =====
// Initialize the manager asynchronously
async initialize(): Promise<void> {
if (!this._initPromise) {
@ -149,23 +149,6 @@ export class DefaultMessageManager {
this._pendingMessageSubscriptions = [];
}
// Get the messageCache (most common usage)
get messageCache() {
if (!this._instance) {
// Return empty cache structure for compatibility during initialization
return {
cells: {},
posts: {},
comments: {},
votes: {},
moderations: {},
userIdentities: {},
bookmarks: {},
};
}
return this._instance.messageCache;
}
// Proxy other common methods
get isReady(): boolean {
return this._instance?.isReady ?? false;

View File

@ -19,18 +19,7 @@ export class MessageService {
this.setupMessageHandling();
}
private setupMessageHandling(): void {
if (this.reliableMessaging) {
this.reliableMessaging.onMessage(async message => {
localDatabase.setSyncing(true);
const isNew = await localDatabase.updateCache(message);
// Defensive: clear pending on inbound message to avoid stuck state
localDatabase.clearPending(message.id);
localDatabase.setSyncing(false);
if (isNew) this.messageReceivedCallbacks.forEach(cb => cb(message));
});
}
}
// ===== PUBLIC METHODS =====
public async sendMessage(
message: OpchanMessage,
@ -94,12 +83,23 @@ export class MessageService {
this.setupMessageHandling();
}
public get messageCache() {
return localDatabase.cache;
}
public cleanup(): void {
this.messageReceivedCallbacks.clear();
this.reliableMessaging?.cleanup();
}
// ===== PRIVATE METHODS =====
private setupMessageHandling(): void {
if (this.reliableMessaging) {
this.reliableMessaging.onMessage(async message => {
localDatabase.setSyncing(true);
const isNew = await localDatabase.updateCache(message);
// Defensive: clear pending on inbound message to avoid stuck state
localDatabase.clearPending(message.id);
localDatabase.setSyncing(false);
if (isNew) this.messageReceivedCallbacks.forEach(cb => cb(message));
});
}
}
}

View File

@ -39,6 +39,8 @@ export class WalletManager {
}
}
// ===== PUBLIC STATIC METHODS =====
/**
* Create or get the singleton instance
*/
@ -53,6 +55,7 @@ export class WalletManager {
bitcoinAccount,
ethereumAccount
);
return WalletManager.instance;
}
@ -65,6 +68,9 @@ export class WalletManager {
'WalletManager not initialized. Call WalletManager.create() first.'
);
}
return WalletManager.instance;
}
@ -111,6 +117,48 @@ export class WalletManager {
}
}
/**
* Verify a message signature against a wallet address
* @param message - The original message that was signed
* @param signature - The signature to verify
* @param walletAddress - The expected signer's address
* @param walletType - The type of wallet (bitcoin/ethereum)
* @returns Promise<boolean> - True if signature is valid
*/
static async verifySignature(
message: string,
signature: string,
walletAddress: string,
walletType: 'bitcoin' | 'ethereum'
): Promise<boolean> {
try {
if (walletType === 'ethereum') {
return await verifyEthereumMessage(config, {
address: walletAddress as `0x${string}`,
message,
signature: signature as `0x${string}`,
});
} else if (walletType === 'bitcoin') {
//TODO: implement bitcoin signature verification
return true;
}
console.error(
'WalletManager.verifySignature - unknown wallet type:',
walletType
);
return false;
} catch (error) {
console.error(
'WalletManager.verifySignature - error verifying signature:',
error
);
return false;
}
}
// ===== PUBLIC INSTANCE METHODS =====
/**
* Get the currently active wallet
*/
@ -185,46 +233,6 @@ export class WalletManager {
}
}
/**
* Verify a message signature against a wallet address
* @param message - The original message that was signed
* @param signature - The signature to verify
* @param walletAddress - The expected signer's address
* @param walletType - The type of wallet (bitcoin/ethereum)
* @returns Promise<boolean> - True if signature is valid
*/
static async verifySignature(
message: string,
signature: string,
walletAddress: string,
walletType: 'bitcoin' | 'ethereum'
): Promise<boolean> {
try {
if (walletType === 'ethereum') {
return await verifyEthereumMessage(config, {
address: walletAddress as `0x${string}`,
message,
signature: signature as `0x${string}`,
});
} else if (walletType === 'bitcoin') {
//TODO: implement bitcoin signature verification
return true;
}
console.error(
'WalletManager.verifySignature - unknown wallet type:',
walletType
);
return false;
} catch (error) {
console.error(
'WalletManager.verifySignature - error verifying signature:',
error
);
return false;
}
}
/**
* Get comprehensive wallet info including ENS resolution for Ethereum
*/

View File

@ -177,5 +177,6 @@ export interface UserIdentityCache {
displayPreference: EDisplayPreference;
lastUpdated: number;
verificationStatus: EVerificationStatus;
displayName: string;
};
}

View File

@ -21,11 +21,12 @@ export function useAuth() {
const verificationStatus = useOpchanStore(s => s.session.verificationStatus);
const delegation = useOpchanStore(s => s.session.delegation);
const connect = React.useCallback(async (input: ConnectInput): Promise<boolean> => {
const baseUser: User = {
address: input.address,
walletType: input.walletType,
displayName: input.address,
displayName: input.address.slice(0, 6) + '...' + input.address.slice(-4),
displayPreference: EDisplayPreference.WALLET_ADDRESS,
verificationStatus: EVerificationStatus.WALLET_CONNECTED,
lastChecked: Date.now(),
@ -34,15 +35,16 @@ export function useAuth() {
try {
await client.database.storeUser(baseUser);
// Prime identity service so display name/ens are cached
await client.userIdentityService.getUserIdentity(baseUser.address);
const identity = await client.userIdentityService.getIdentity(baseUser.address);
if (!identity) return false;
setOpchanState(prev => ({
...prev,
session: {
...prev.session,
currentUser: baseUser,
verificationStatus: baseUser.verificationStatus,
delegation: prev.session.delegation,
currentUser: {
...baseUser,
...identity,
},
},
}));
return true;
@ -68,33 +70,37 @@ export function useAuth() {
}, [client]);
const verifyOwnership = React.useCallback(async (): Promise<boolean> => {
console.log('verifyOwnership')
const user = currentUser;
if (!user) return false;
try {
const identity = await client.userIdentityService.getUserIdentityFresh(user.address);
const nextStatus = identity?.verificationStatus ?? EVerificationStatus.WALLET_CONNECTED;
const identity = await client.userIdentityService.getIdentity(user.address, { fresh: true });
if (!identity) {
console.error('verifyOwnership failed', 'identity not found');
return false;
}
console.log({user, identity})
const updated: User = {
...user,
verificationStatus: nextStatus,
displayName: identity?.displayPreference === EDisplayPreference.CALL_SIGN ? identity.callSign! : identity!.ensName!,
ensDetails: identity?.ensName ? { ensName: identity.ensName } : undefined,
ordinalDetails: identity?.ordinalDetails,
...identity,
};
await client.database.storeUser(updated);
await client.database.upsertUserIdentity(user.address, {
displayName: identity.displayName,
ensName: identity?.ensName || undefined,
ordinalDetails: identity?.ordinalDetails,
verificationStatus: nextStatus,
verificationStatus: identity.verificationStatus,
lastUpdated: Date.now(),
});
setOpchanState(prev => ({
...prev,
session: { ...prev.session, currentUser: updated, verificationStatus: nextStatus },
session: { ...prev.session, currentUser: updated, verificationStatus: identity.verificationStatus },
}));
return nextStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED;
return identity.verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED;
} catch (e) {
console.error('verifyOwnership failed', e);
return false;
@ -151,19 +157,19 @@ export function useAuth() {
const user = currentUser;
if (!user) return false;
try {
const ok = await client.userIdentityService.updateUserProfile(
const res = await client.userIdentityService.updateProfile(
user.address,
updates.callSign,
updates.displayPreference ?? user.displayPreference,
{
callSign: updates.callSign,
displayPreference: updates.displayPreference ?? user.displayPreference,
}
);
if (!ok) return false;
if (!res.ok) return false;
await client.userIdentityService.refreshUserIdentity(user.address);
const fresh = await client.userIdentityService.getUserIdentity(user.address);
const identity = res.identity;
const updated: User = {
...user,
callSign: fresh?.callSign ?? user.callSign,
displayPreference: fresh?.displayPreference ?? user.displayPreference,
...identity,
};
await client.database.storeUser(updated);
setOpchanState(prev => ({ ...prev, session: { ...prev.session, currentUser: updated } }));

View File

@ -1,14 +1,9 @@
import React from 'react';
import { useClient } from '../context/ClientContext';
import { EDisplayPreference, EVerificationStatus } from '@opchan/core';
import { UserIdentity } from '@opchan/core/dist/lib/services/UserIdentityService';
export interface UserDisplayInfo {
displayName: string;
callSign: string | null;
ensName: string | null;
ordinalDetails: string | null;
verificationLevel: EVerificationStatus;
displayPreference: EDisplayPreference | null;
export interface UserDisplayInfo extends UserIdentity {
isLoading: boolean;
error: string | null;
}
@ -21,20 +16,18 @@ export function useUserDisplay(address: string): UserDisplayInfo {
const client = useClient();
const [displayInfo, setDisplayInfo] = React.useState<UserDisplayInfo>({
address,
displayName: `${address.slice(0, 6)}...${address.slice(-4)}`,
callSign: null,
ensName: null,
ordinalDetails: null,
verificationLevel: EVerificationStatus.WALLET_UNCONNECTED,
displayPreference: null,
lastUpdated: 0,
callSign: undefined,
ensName: undefined,
ordinalDetails: undefined,
verificationStatus: EVerificationStatus.WALLET_UNCONNECTED,
displayPreference: EDisplayPreference.WALLET_ADDRESS,
isLoading: true,
error: null,
});
const getDisplayName = React.useCallback((addr: string) => {
return client.userIdentityService.getDisplayName(addr);
}, [client]);
// Initial load and refresh listener
React.useEffect(() => {
if (!address) return;
@ -43,28 +36,16 @@ export function useUserDisplay(address: string): UserDisplayInfo {
const loadUserDisplay = async () => {
try {
const identity = await client.userIdentityService.getUserIdentity(address);
const identity = await client.userIdentityService.getIdentity(address);
if (cancelled) return;
if (identity) {
setDisplayInfo({
displayName: getDisplayName(address),
callSign: identity.callSign || null,
ensName: identity.ensName || null,
ordinalDetails: identity.ordinalDetails?.ordinalDetails || null,
verificationLevel: identity.verificationStatus,
displayPreference: identity.displayPreference || null,
...identity,
isLoading: false,
error: null,
});
} else {
setDisplayInfo(prev => ({
...prev,
displayName: getDisplayName(address),
isLoading: false,
error: null,
}));
}
} catch (error) {
if (cancelled) return;
@ -80,21 +61,16 @@ export function useUserDisplay(address: string): UserDisplayInfo {
loadUserDisplay();
// Subscribe to identity service refresh events
const unsubscribe = client.userIdentityService.addRefreshListener(async (changedAddress) => {
const unsubscribe = client.userIdentityService.subscribe(async (changedAddress) => {
if (changedAddress !== address || cancelled) return;
try {
const identity = await client.userIdentityService.getUserIdentity(address);
const identity = await client.userIdentityService.getIdentity(address);
if (!identity || cancelled) return;
setDisplayInfo(prev => ({
...prev,
displayName: getDisplayName(address),
callSign: identity.callSign || null,
ensName: identity.ensName || null,
ordinalDetails: identity.ordinalDetails?.ordinalDetails || null,
verificationLevel: identity.verificationStatus,
displayPreference: identity.displayPreference || null,
...identity,
isLoading: false,
error: null,
}));
@ -117,7 +93,7 @@ export function useUserDisplay(address: string): UserDisplayInfo {
// Ignore unsubscribe errors
}
};
}, [address, client, getDisplayName]);
}, [address, client]);
return displayInfo;
}

View File

@ -2,7 +2,7 @@ import React from 'react';
import { useClient } from '../context/ClientContext';
import { setOpchanState, getOpchanState } from '../store/opchanStore';
import type { OpchanMessage, User } from '@opchan/core';
import { EVerificationStatus, EDisplayPreference } from '@opchan/core';
import { EVerificationStatus } from '@opchan/core';
export const StoreWiring: React.FC = () => {
const client = useClient();
@ -23,9 +23,9 @@ export const StoreWiring: React.FC = () => {
...prev,
content: {
...prev.content,
cells: Object.values(cache.cells),
posts: Object.values(cache.posts),
comments: Object.values(cache.comments),
cells: Object.values(cache.cells),
posts: Object.values(cache.posts),
comments: Object.values(cache.comments),
bookmarks: Object.values(cache.bookmarks),
lastSync: client.database.getSyncState().lastSync,
pendingIds: new Set<string>(),
@ -41,35 +41,12 @@ export const StoreWiring: React.FC = () => {
loadedUser?.walletType,
);
// If we have a loaded user, enrich it with latest identity for display fields
let enrichedUser: User | null = loadedUser ?? null;
if (loadedUser) {
try {
const identity = await client.userIdentityService.getUserIdentity(loadedUser.address);
if (identity) {
const displayName = identity.displayPreference === EDisplayPreference.CALL_SIGN
? (identity.callSign || loadedUser.displayName)
: (identity.ensName || loadedUser.displayName);
enrichedUser = {
...loadedUser,
callSign: identity.callSign ?? loadedUser.callSign,
displayPreference: identity.displayPreference ?? loadedUser.displayPreference,
displayName,
ensDetails: identity.ensName ? { ensName: identity.ensName } : loadedUser.ensDetails,
ordinalDetails: identity.ordinalDetails ?? loadedUser.ordinalDetails,
verificationStatus: identity.verificationStatus ?? loadedUser.verificationStatus,
};
try { await client.database.storeUser(enrichedUser); } catch { /* ignore persist error */ }
}
} catch { /* ignore identity enrich error */ }
}
setOpchanState(prev => ({
...prev,
session: {
currentUser: enrichedUser,
currentUser: loadedUser,
verificationStatus:
enrichedUser?.verificationStatus ?? EVerificationStatus.WALLET_UNCONNECTED,
loadedUser?.verificationStatus ?? EVerificationStatus.WALLET_UNCONNECTED,
delegation: delegationStatus ?? null,
},
}));
@ -118,30 +95,29 @@ export const StoreWiring: React.FC = () => {
});
// Reactively update session.currentUser when identity refreshes for the active user
unsubIdentity = client.userIdentityService.addRefreshListener(async (address: string) => {
unsubIdentity = client.userIdentityService.subscribe(async (address: string) => {
try {
const { session } = getOpchanState();
const active = session.currentUser;
if (!active || active.address !== address) return;
if (!active || active.address !== address) {
return;
}
const identity = await client.userIdentityService.getUserIdentity(address);
if (!identity) return;
const displayName = identity.displayPreference === EDisplayPreference.CALL_SIGN
? (identity.callSign || active.displayName)
: (identity.ensName || active.displayName);
const identity = await client.userIdentityService.getIdentity(address);
if (!identity) {
return;
}
const updated: User = {
...active,
callSign: identity.callSign ?? active.callSign,
displayPreference: identity.displayPreference ?? active.displayPreference,
displayName,
ensDetails: identity.ensName ? { ensName: identity.ensName } : active.ensDetails,
ordinalDetails: identity.ordinalDetails ?? active.ordinalDetails,
verificationStatus: identity.verificationStatus ?? active.verificationStatus,
...identity,
};
try { await client.database.storeUser(updated); } catch { /* ignore persist error */ }
try {
await client.database.storeUser(updated);
} catch (persistErr) {
console.warn('[StoreWiring] Failed to persist updated user after identity refresh:', persistErr);
}
setOpchanState(prev => ({
...prev,
@ -157,7 +133,9 @@ export const StoreWiring: React.FC = () => {
});
};
hydrate().then(wire);
hydrate().then(() => {
wire();
});
return () => {
unsubHealth?.();
@ -167,6 +145,4 @@ export const StoreWiring: React.FC = () => {
}, [client]);
return null;
};
};