state consistentcy

This commit is contained in:
Danish Arora 2025-09-25 19:45:08 +05:30
parent 66802d7d78
commit e4192f8d20
No known key found for this signature in database
GPG Key ID: 1C6EF37CDAE1426E
18 changed files with 383 additions and 199 deletions

View File

@ -9,7 +9,6 @@ import { EVerificationStatus } from '@opchan/core';
import { CypherImage } from '@/components/ui/CypherImage';
const FeedSidebar: React.FC = () => {
// ✅ Use reactive hooks for data
const {cells, posts, comments, cellsWithStats, userVerificationStatus} = useContent();
const { currentUser, verificationStatus } = useAuth();

View File

@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useAuth, useForum, useNetwork } from '@/hooks';
import { useAuth, useForum, useNetwork, useUIState } from '@/hooks';
import { EVerificationStatus } from '@opchan/core';
import { localDatabase } from '@opchan/core';
import { Button } from '@/components/ui/button';
@ -64,36 +64,16 @@ const Header = () => {
const [walletWizardOpen, setWalletWizardOpen] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
// Use LocalDatabase to persist wizard state across navigation
const getHasShownWizard = async (): Promise<boolean> => {
try {
const value = await localDatabase.loadUIState('hasShownWalletWizard');
return value === true;
} catch {
return false;
}
};
const setHasShownWizard = async (value: boolean): Promise<void> => {
try {
await localDatabase.storeUIState('hasShownWalletWizard', value);
} catch (e) {
console.error('Failed to store wizard state', e);
}
};
// Use centralized UI state instead of direct LocalDatabase access
const [hasShownWizard, setHasShownWizard] = useUIState('hasShownWalletWizard', false);
// Auto-open wizard when wallet connects for the first time
React.useEffect(() => {
if (isConnected) {
getHasShownWizard().then(hasShown => {
if (!hasShown) {
setWalletWizardOpen(true);
setHasShownWizard(true).catch(console.error);
}
});
if (isConnected && !hasShownWizard) {
setWalletWizardOpen(true);
setHasShownWizard(true);
}
}, [isConnected]);
}, [isConnected, hasShownWizard, setHasShownWizard]);
const handleConnect = async () => {
setWalletWizardOpen(true);
@ -105,7 +85,7 @@ const Header = () => {
const handleDisconnect = async () => {
await disconnect();
await setHasShownWizard(false); // Reset so wizard can show again on next connection
setHasShownWizard(false); // Reset so wizard can show again on next connection
toast({
title: 'Wallet Disconnected',
description: 'Your wallet has been disconnected successfully.',

View File

@ -7,6 +7,7 @@ import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Skeleton } from '@/components/ui/skeleton';
import { LinkRenderer } from '@/components/ui/link-renderer';
import { RelevanceIndicator } from '@/components/ui/relevance-indicator';
import { ShareButton } from '@/components/ui/ShareButton';
import {
ArrowLeft,
@ -322,6 +323,17 @@ const PostList = () => {
<MessageSquare className="inline w-3 h-3 mr-1" />
{commentsByPost[post.id]?.length || 0} comments
</span>
{typeof post.relevanceScore === 'number' && (
<>
<span></span>
<RelevanceIndicator
score={post.relevanceScore}
details={post.relevanceDetails}
type="post"
showTooltip={true}
/>
</>
)}
<ShareButton
url={`${window.location.origin}/post/${post.id}`}
title={post.title}

View File

@ -1,6 +1,7 @@
import { Badge } from '@/components/ui/badge';
import { Shield, Crown, Hash } from 'lucide-react';
import { useUserDisplay } from '@opchan/react';
import { useEffect } from 'react';
interface AuthorDisplayProps {
address: string;
@ -13,8 +14,11 @@ export function AuthorDisplay({
className = '',
showBadge = true,
}: AuthorDisplayProps) {
const { displayName, callSign, ensName, ordinalDetails } =
useUserDisplay(address);
const { ensName, ordinalDetails, callSign, displayName } = useUserDisplay(address);
useEffect(()=> {
console.log({ensName, ordinalDetails, callSign, displayName, address})
}, [address, ensName, ordinalDetails, callSign, displayName])
// Only show a badge if the author has ENS, Ordinal, or Call Sign
const shouldShowBadge = showBadge && (ensName || ordinalDetails || callSign);

View File

@ -6,5 +6,6 @@ export {
usePermissions,
useContent,
useUIState,
useUserDisplay,
} from '@opchan/react';

View File

@ -29,7 +29,7 @@ export * from './lib/forum/transformers';
// Export services
export { BookmarkService } from './lib/services/BookmarkService';
export { MessageService } from './lib/services/MessageService';
export { UserIdentityService } from './lib/services/UserIdentityService';
export { UserIdentityService, UserIdentity } from './lib/services/UserIdentityService';
export { ordinals } from './lib/services/Ordinals';
// Export utilities
@ -47,4 +47,5 @@ export * from './lib/wallet/config';
export * from './lib/wallet/types';
// Primary client API
export { OpChanClient, type OpChanClientConfig } from './client/OpChanClient';
export { OpChanClient, type OpChanClientConfig } from './client/OpChanClient';

View File

@ -654,10 +654,9 @@ export class LocalDatabase {
ensName: undefined,
ordinalDetails: undefined,
callSign: undefined,
displayPreference: EDisplayPreference.WALLET_ADDRESS,
displayPreference: EDisplayPreference.WALLET_ADDRESS,
lastUpdated: 0,
verificationStatus: EVerificationStatus.WALLET_UNCONNECTED,
displayName: address.slice(0, 6) + '...' + address.slice(-4),
};
const merged: UserIdentityCache[string] = {

View File

@ -286,8 +286,8 @@ export class DelegationManager {
!proof?.walletAddress ||
!proof?.authMessage ||
proof?.expiryTimestamp === undefined ||
proof.walletAddress !== expectedWalletAddress ||
Date.now() >= proof.expiryTimestamp
proof.walletAddress !== expectedWalletAddress
// Date.now() >= proof.expiryTimestamp
) {
return false;
}
@ -329,9 +329,9 @@ export class DelegationManager {
if (proof.walletAddress !== expectedWalletAddress) {
reasons.push('Delegation wallet address does not match author');
}
if (Date.now() >= proof.expiryTimestamp) {
reasons.push('Delegation has expired');
}
// if (Date.now() >= proof.expiryTimestamp) {
// reasons.push('Delegation has expired');
// }
if (
!proof.authMessage.includes(expectedWalletAddress) ||
!proof.authMessage.includes(expectedBrowserKey) ||

View File

@ -7,12 +7,10 @@ import {
ModerateMessage,
EModerationAction,
} from '../../types/waku';
import messageManager from '../waku';
import { localDatabase } from '../database/LocalDatabase';
import { RelevanceCalculator } from './RelevanceCalculator';
import { UserVerificationStatus } from '../../types/forum';
// Validation is enforced at ingestion time by LocalDatabase. Transformers assume
// cache contains only valid, verified messages.
import { EVerificationStatus } from '../../types/identity';
export const transformCell = async (
cellMessage: CellMessage,
@ -287,10 +285,20 @@ export const transformVote = async (
export const getDataFromCache = async (
_verifyMessage?: unknown, // Deprecated parameter, kept for compatibility
userVerificationStatus?: UserVerificationStatus
): Promise<{ cells: Cell[]; posts: Post[]; comments: Comment[] }> => {
// Use LocalDatabase cache for immediate hydration, avoiding messageManager race conditions
// All validation is now handled internally by the transform functions
const userIdentities = localDatabase.cache.userIdentities;
const userVerificationStatus: UserVerificationStatus = {};
for (const [address, rec] of Object.entries(userIdentities)) {
userVerificationStatus[address] = {
isVerified: rec.verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED,
hasENS: Boolean(rec.ensName),
hasOrdinal: Boolean(rec.ordinalDetails),
ensName: rec.ensName,
verificationStatus: rec.verificationStatus,
};
}
const posts = await Promise.all(
Object.values(localDatabase.cache.posts)
.filter((post): post is PostMessage => post !== null)

View File

@ -41,6 +41,7 @@ export class UserIdentityService {
address: string,
opts?: { fresh?: boolean }
): Promise<UserIdentity | null> {
console.log('getIdentity', address, opts);
if (opts?.fresh) {
return this.getUserIdentityFresh(address);
}
@ -187,9 +188,6 @@ export class UserIdentityService {
*/
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 &&
@ -211,7 +209,7 @@ export class UserIdentityService {
* Internal method to get user identity without debouncing
*/
private async getUserIdentityInternal(address: string): Promise<UserIdentity | null> {
const record = this.getCachedRecord(address);
const record = localDatabase.cache.userIdentities[address];
if (record) {
let identity = this.buildUserIdentityFromRecord(address, record);
identity = await this.ensureEnsEnriched(address, identity);
@ -237,6 +235,7 @@ export class UserIdentityService {
address: string
): Promise<UserIdentity | null> {
try {
console.log('resolveUserIdentity', address);
const [ensName, ordinalDetails] = await Promise.all([
this.resolveENSName(address),
this.resolveOrdinalDetails(address),
@ -345,16 +344,6 @@ export class UserIdentityService {
};
}
/**
* 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.
*/

View File

@ -19,7 +19,7 @@
],
"scripts": {
"build": "npm run clean && npm run build:esm && npm run build:types",
"build:esm": "tsc -p tsconfig.build.json && cp dist/index.js dist/index.esm.js",
"build:esm": "tsc -b --force tsconfig.build.json && cp dist/index.js dist/index.esm.js",
"build:types": "tsc -p tsconfig.types.json",
"clean": "rm -rf dist",
"dev": "tsc -w -p tsconfig.build.json",

View File

@ -2,32 +2,34 @@ import React from 'react';
import { useClient } from '../context/ClientContext';
import { useOpchanStore, setOpchanState } from '../store/opchanStore';
import {
PostMessage,
CommentMessage,
Post,
Comment,
Cell,
EVerificationStatus,
UserVerificationStatus,
BookmarkType,
getDataFromCache,
} from '@opchan/core';
import { BookmarkService } from '@opchan/core';
function reflectCache(client: ReturnType<typeof useClient>): void {
const cache = client.database.cache;
setOpchanState(prev => ({
...prev,
content: {
...prev.content,
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: prev.content.pendingIds,
pendingVotes: prev.content.pendingVotes,
},
}));
getDataFromCache().then(({ cells, posts, comments }) => {
setOpchanState(prev => ({
...prev,
content: {
...prev.content,
cells,
posts,
comments,
bookmarks: Object.values(client.database.cache.bookmarks),
lastSync: client.database.getSyncState().lastSync,
pendingIds: prev.content.pendingIds,
pendingVotes: prev.content.pendingVotes,
},
}));
}).catch(err => {
console.error('reflectCache failed', err);
});
}
export function useContent() {
@ -52,7 +54,7 @@ export function useContent() {
// Derived maps
const postsByCell = React.useMemo(() => {
const map: Record<string, PostMessage[]> = {};
const map: Record<string, Post[]> = {};
for (const p of content.posts) {
(map[p.cellId] ||= []).push(p);
}
@ -60,10 +62,13 @@ export function useContent() {
}, [content.posts]);
const commentsByPost = React.useMemo(() => {
const map: Record<string, CommentMessage[]> = {};
const map: Record<string, Comment[]> = {};
for (const c of content.comments) {
(map[c.postId] ||= []).push(c);
}
for (const postId in map) {
map[postId].sort((a, b) => a.timestamp - b.timestamp);
}
return map;
}, [content.comments]);
@ -230,19 +235,19 @@ export function useContent() {
},
}), [client, session.currentUser, content.cells]);
const togglePostBookmark = React.useCallback(async (post: Post | PostMessage, cellId?: string): Promise<boolean> => {
const togglePostBookmark = React.useCallback(async (post: Post, cellId?: string): Promise<boolean> => {
const address = session.currentUser?.address;
if (!address) return false;
const added = await BookmarkService.togglePostBookmark(post as Post, address, cellId);
const added = await BookmarkService.togglePostBookmark(post, address, cellId);
const updated = await client.database.getUserBookmarks(address);
setOpchanState(prev => ({ ...prev, content: { ...prev.content, bookmarks: updated } }));
return added;
}, [client, session.currentUser?.address]);
const toggleCommentBookmark = React.useCallback(async (comment: Comment | CommentMessage, postId?: string): Promise<boolean> => {
const toggleCommentBookmark = React.useCallback(async (comment: Comment, postId?: string): Promise<boolean> => {
const address = session.currentUser?.address;
if (!address) return false;
const added = await BookmarkService.toggleCommentBookmark(comment as Comment, address, postId);
const added = await BookmarkService.toggleCommentBookmark(comment, address, postId);
const updated = await client.database.getUserBookmarks(address);
setOpchanState(prev => ({ ...prev, content: { ...prev.content, bookmarks: updated } }));
return added;

View File

@ -1,39 +1,68 @@
import React from 'react';
import { useClient } from '../context/ClientContext';
import { useOpchanStore, opchanStore } from '../store/opchanStore';
export function useUIState<T>(key: string, defaultValue: T): [T, (value: T) => void, { loading: boolean; error?: string }] {
export function useUIState<T>(key: string, defaultValue: T, category: 'wizardStates' | 'preferences' | 'temporaryStates' = 'preferences'): [T, (value: T) => void, { loading: boolean; error?: string }] {
const client = useClient();
const [state, setState] = React.useState<T>(defaultValue);
// Get value from central store
const storeValue = useOpchanStore(s => s.uiState[category][key] as T | undefined);
const [loading, setLoading] = React.useState<boolean>(true);
const [error, setError] = React.useState<string | undefined>(undefined);
const [hasHydrated, setHasHydrated] = React.useState<boolean>(false);
// Hydrate from LocalDatabase on first load (if not already in store)
React.useEffect(() => {
if (hasHydrated) return;
let mounted = true;
(async () => {
try {
const value = await client.database.loadUIState(key);
// Check if already in store
if (storeValue !== undefined) {
if (mounted) {
setLoading(false);
setHasHydrated(true);
}
return;
}
// Load from LocalDatabase and populate store
const dbKey = category === 'wizardStates' ? `wizard_${key}` :
category === 'preferences' ? `pref_${key}` : key;
const value = await client.database.loadUIState(dbKey);
if (mounted) {
if (value !== undefined) setState(value as T);
if (value !== undefined) {
opchanStore.setUIState(key, value as T, category);
}
setLoading(false);
setHasHydrated(true);
}
} catch (e) {
if (mounted) {
setError((e as Error).message);
setLoading(false);
setHasHydrated(true);
}
}
})();
return () => {
mounted = false;
};
}, [client, key]);
}, [client, key, category, storeValue, hasHydrated]);
const set = React.useCallback((value: T) => {
setState(value);
client.database.storeUIState(key, value).catch(() => {});
}, [client, key]);
// Update store (will auto-persist via StoreWiring)
opchanStore.setUIState(key, value, category);
}, [key, category]);
return [state, set, { loading, error }];
// Use store value if available, otherwise default
const currentValue = storeValue !== undefined ? storeValue : defaultValue;
return [currentValue, set, { loading, error }];
}

View File

@ -1,9 +1,10 @@
import React from 'react';
import { useClient } from '../context/ClientContext';
import { useOpchanStore, opchanStore } from '../store/opchanStore';
import { EDisplayPreference, EVerificationStatus } from '@opchan/core';
import { UserIdentity } from '@opchan/core/dist/lib/services/UserIdentityService';
export interface UserDisplayInfo extends UserIdentity {
export type UserDisplayInfo = UserIdentity & {
isLoading: boolean;
error: string | null;
}
@ -15,85 +16,82 @@ export interface UserDisplayInfo extends UserIdentity {
export function useUserDisplay(address: string): UserDisplayInfo {
const client = useClient();
const [displayInfo, setDisplayInfo] = React.useState<UserDisplayInfo>({
address,
displayName: `${address.slice(0, 6)}...${address.slice(-4)}`,
lastUpdated: 0,
callSign: undefined,
ensName: undefined,
ordinalDetails: undefined,
verificationStatus: EVerificationStatus.WALLET_UNCONNECTED,
displayPreference: EDisplayPreference.WALLET_ADDRESS,
isLoading: true,
error: null,
});
// Get identity from central store
const storeIdentity = useOpchanStore(s => s.identity.identitiesByAddress[address]);
const [isLoading, setIsLoading] = React.useState<boolean>(true);
const [error, setError] = React.useState<string | null>(null);
const [hasInitialized, setHasInitialized] = React.useState<boolean>(false);
// Initial load and refresh listener
// Initialize from store or load from service
React.useEffect(() => {
if (!address) return;
if (!address || hasInitialized) return;
let cancelled = false;
const loadUserDisplay = async () => {
const initializeUserDisplay = async () => {
try {
const identity = await client.userIdentityService.getIdentity(address);
// If already in store, use that
if (storeIdentity) {
setIsLoading(false);
setError(null);
setHasInitialized(true);
return;
}
const identity = await client.userIdentityService.getIdentity(address, {fresh: true});
console.log({identity})
if (cancelled) return;
if (identity) {
setDisplayInfo({
...identity,
isLoading: false,
error: null,
});
opchanStore.setIdentity(address, identity);
setError(null);
} else {
setError(null);
}
setIsLoading(false);
setHasInitialized(true);
} catch (error) {
if (cancelled) return;
setDisplayInfo(prev => ({
...prev,
isLoading: false,
error: error instanceof Error ? error.message : 'Unknown error',
}));
setIsLoading(false);
setError(error instanceof Error ? error.message : 'Unknown error');
setHasInitialized(true);
}
};
loadUserDisplay();
// Subscribe to identity service refresh events
const unsubscribe = client.userIdentityService.subscribe(async (changedAddress) => {
if (changedAddress !== address || cancelled) return;
try {
const identity = await client.userIdentityService.getIdentity(address);
if (!identity || cancelled) return;
setDisplayInfo(prev => ({
...prev,
...identity,
isLoading: false,
error: null,
}));
} catch (error) {
if (cancelled) return;
setDisplayInfo(prev => ({
...prev,
isLoading: false,
error: error instanceof Error ? error.message : 'Unknown error',
}));
}
});
initializeUserDisplay();
return () => {
cancelled = true;
try {
unsubscribe();
} catch {
// Ignore unsubscribe errors
}
};
}, [address, client]);
}, [address, client.userIdentityService, storeIdentity, hasInitialized]);
const displayInfo: UserDisplayInfo = React.useMemo(() => {
if (storeIdentity) {
console.log({storeIdentity})
return {
...storeIdentity,
isLoading,
error,
};
}
return {
address,
displayName: `${address.slice(0, 6)}...${address.slice(-4)}`,
lastUpdated: 0,
callSign: undefined,
ensName: undefined,
ordinalDetails: undefined,
verificationStatus: EVerificationStatus.WALLET_UNCONNECTED,
displayPreference: EDisplayPreference.WALLET_ADDRESS,
isLoading,
error,
};
}, [storeIdentity, isLoading, error, address]);
return displayInfo;
}

View File

@ -41,7 +41,7 @@ export const OpChanProvider: React.FC<NewOpChanProviderProps> = ({ config, walle
walletType: account.walletType,
displayPreference: 'wallet-address' as EDisplayPreference,
verificationStatus: EVerificationStatus.WALLET_CONNECTED,
displayName: account.address,
displayName: account.address.slice(0, 6) + '...' + account.address.slice(-4),
lastChecked: Date.now(),
};
try {

View File

@ -1,8 +1,8 @@
import React from 'react';
import { useClient } from '../context/ClientContext';
import { setOpchanState, getOpchanState } from '../store/opchanStore';
import type { OpchanMessage, User } from '@opchan/core';
import { EVerificationStatus } from '@opchan/core';
import { setOpchanState, getOpchanState, opchanStore } from '../store/opchanStore';
import type { OpchanMessage, User, UserIdentity } from '@opchan/core';
import { EVerificationStatus, getDataFromCache } from '@opchan/core';
export const StoreWiring: React.FC = () => {
const client = useClient();
@ -12,27 +12,60 @@ export const StoreWiring: React.FC = () => {
let unsubHealth: (() => void) | null = null;
let unsubMessages: (() => void) | null = null;
let unsubIdentity: (() => void) | null = null;
let unsubPersistence: (() => void) | null = null;
const hydrate = async () => {
try {
await client.database.open();
const cache = client.database.cache;
// Reflect content cache
const { cells, posts, comments } = await getDataFromCache();
// Reflect transformed content
setOpchanState(prev => ({
...prev,
content: {
...prev.content,
cells: Object.values(cache.cells),
posts: Object.values(cache.posts),
comments: Object.values(cache.comments),
bookmarks: Object.values(cache.bookmarks),
cells,
posts,
comments,
bookmarks: Object.values(client.database.cache.bookmarks),
lastSync: client.database.getSyncState().lastSync,
pendingIds: new Set<string>(),
pendingVotes: new Set<string>(),
},
}));
// Hydrate identity cache from LocalDatabase
const identityCache = client.database.cache.userIdentities;
const identityUpdates: Record<string, UserIdentity> = {};
const displayNameUpdates: Record<string, string> = {};
const lastUpdatedMap: Record<string, number> = {};
for (const address of Object.keys(identityCache)) {
const identity = identityCache[address]!;
identityUpdates[address] = {
address,
ensName: identity.ensName,
ordinalDetails: identity.ordinalDetails,
callSign: identity.callSign,
displayPreference: identity.displayPreference,
displayName: client.userIdentityService.getDisplayName(address),
lastUpdated: identity.lastUpdated,
verificationStatus: identity.verificationStatus as EVerificationStatus,
};
displayNameUpdates[address] = client.userIdentityService.getDisplayName(address);
lastUpdatedMap[address] = identity.lastUpdated;
}
setOpchanState(prev => ({
...prev,
identity: {
...prev.identity,
identitiesByAddress: identityUpdates,
displayNameByAddress: displayNameUpdates,
lastUpdatedByAddress: lastUpdatedMap,
},
}));
// Hydrate session (user + delegation) from LocalDatabase
try {
const loadedUser = await client.database.loadUser();
@ -75,15 +108,15 @@ export const StoreWiring: React.FC = () => {
// Persist, then reflect cache in store
try {
await client.database.updateCache(message);
const cache = client.database.cache;
const { cells, posts, comments } = await getDataFromCache();
setOpchanState(prev => ({
...prev,
content: {
...prev.content,
cells: Object.values(cache.cells),
posts: Object.values(cache.posts),
comments: Object.values(cache.comments),
bookmarks: Object.values(cache.bookmarks),
cells,
posts,
comments,
bookmarks: Object.values(client.database.cache.bookmarks),
lastSync: Date.now(),
pendingIds: prev.content.pendingIds,
pendingVotes: prev.content.pendingVotes,
@ -94,37 +127,57 @@ export const StoreWiring: React.FC = () => {
}
});
// Reactively update session.currentUser when identity refreshes for the active user
unsubIdentity = client.userIdentityService.subscribe(async (address: string) => {
// Reactively update ALL identities when they refresh (not just current user)
unsubIdentity = client.userIdentityService.subscribe(async (address: string, identity) => {
try {
if (!identity) {
// Try to fetch if not provided
identity = await client.userIdentityService.getIdentity(address);
if (!identity) {
return;
}
}
// Update identity store for ALL users
opchanStore.setIdentity(address, identity);
// Special handling for current user - also update session
const { session } = getOpchanState();
const active = session.currentUser;
if (!active || active.address !== address) {
return;
}
const identity = await client.userIdentityService.getIdentity(address);
if (!identity) {
return;
}
const updated: User = {
...active,
...identity,
};
try {
await client.database.storeUser(updated);
} catch (persistErr) {
console.warn('[StoreWiring] Failed to persist updated user after identity refresh:', persistErr);
if (active && active.address === address) {
const updated: User = {
...active,
...identity,
};
try {
await client.database.storeUser(updated);
} catch (persistErr) {
console.warn('[StoreWiring] Failed to persist updated user after identity refresh:', persistErr);
}
setOpchanState(prev => ({
...prev,
session: {
...prev.session,
currentUser: updated,
verificationStatus: updated.verificationStatus,
},
}));
}
const { cells, posts, comments } = await getDataFromCache();
setOpchanState(prev => ({
...prev,
session: {
...prev.session,
currentUser: updated,
verificationStatus: updated.verificationStatus,
content: {
...prev.content,
cells,
posts,
comments,
bookmarks: Object.values(client.database.cache.bookmarks),
lastSync: Date.now(),
pendingIds: prev.content.pendingIds,
pendingVotes: prev.content.pendingVotes,
},
}));
} catch (err) {
@ -135,12 +188,39 @@ export const StoreWiring: React.FC = () => {
hydrate().then(() => {
wire();
// Set up bidirectional persistence: Store → LocalDatabase
unsubPersistence = opchanStore.onPersistence(async (state) => {
try {
// Persist current user changes
if (state.session.currentUser) {
await client.database.storeUser(state.session.currentUser);
}
// Persist UI state changes (wizard flags, preferences)
for (const [key, value] of Object.entries(state.uiState.wizardStates)) {
await client.database.storeUIState(`wizard_${key}`, value);
}
for (const [key, value] of Object.entries(state.uiState.preferences)) {
await client.database.storeUIState(`pref_${key}`, value);
}
// Persist identity updates back to LocalDatabase
for (const [address, identity] of Object.entries(state.identity.identitiesByAddress)) {
await client.database.upsertUserIdentity(address, identity);
}
} catch (error) {
console.warn('[StoreWiring] Persistence failed:', error);
}
});
});
return () => {
unsubHealth?.();
unsubMessages?.();
unsubIdentity?.();
unsubPersistence?.();
};
}, [client]);

View File

@ -1,13 +1,14 @@
import React, { useSyncExternalStore } from 'react';
import type {
CellMessage,
PostMessage,
CommentMessage,
Cell,
Post,
Comment,
Bookmark,
User,
EVerificationStatus,
DelegationFullStatus,
} from '@opchan/core';
import type { UserIdentity } from '@opchan/core/dist/lib/services/UserIdentityService';
type Listener = () => void;
@ -18,9 +19,9 @@ export interface SessionSlice {
}
export interface ContentSlice {
cells: CellMessage[];
posts: PostMessage[];
comments: CommentMessage[];
cells: Cell[];
posts: Post[];
comments: Comment[];
bookmarks: Bookmark[];
lastSync: number | null;
pendingIds: Set<string>;
@ -28,8 +29,17 @@ export interface ContentSlice {
}
export interface IdentitySlice {
// minimal identity cache; full logic lives in UserIdentityService
// Enhanced identity cache with full UserIdentity data
displayNameByAddress: Record<string, string>;
identitiesByAddress: Record<string, UserIdentity>;
lastUpdatedByAddress: Record<string, number>;
}
export interface UIStateSlice {
// Centralized UI state to replace direct LocalDatabase access
wizardStates: Record<string, boolean>;
preferences: Record<string, unknown>;
temporaryStates: Record<string, unknown>;
}
export interface NetworkSlice {
@ -42,6 +52,7 @@ export interface OpchanState {
session: SessionSlice;
content: ContentSlice;
identity: IdentitySlice;
uiState: UIStateSlice;
network: NetworkSlice;
}
@ -62,6 +73,13 @@ const defaultState: OpchanState = {
},
identity: {
displayNameByAddress: {},
identitiesByAddress: {},
lastUpdatedByAddress: {},
},
uiState: {
wizardStates: {},
preferences: {},
temporaryStates: {},
},
network: {
isConnected: false,
@ -73,6 +91,7 @@ const defaultState: OpchanState = {
class OpchanStoreImpl {
private state: OpchanState = defaultState;
private listeners: Set<Listener> = new Set();
private persistenceCallbacks: Set<(state: OpchanState) => Promise<void>> = new Set();
subscribe(listener: Listener): () => void {
this.listeners.add(listener);
@ -92,8 +111,67 @@ class OpchanStoreImpl {
if (next !== this.state) {
this.state = next;
this.notify();
this.triggerPersistence(next);
}
}
// Enhanced methods for identity and UI state management
setIdentity(address: string, identity: UserIdentity): void {
this.setState(prev => ({
...prev,
identity: {
...prev.identity,
displayNameByAddress: {
...prev.identity.displayNameByAddress,
[address]: identity.displayName,
},
identitiesByAddress: {
...prev.identity.identitiesByAddress,
[address]: identity,
},
lastUpdatedByAddress: {
...prev.identity.lastUpdatedByAddress,
[address]: Date.now(),
},
},
}));
}
setUIState<T>(key: string, value: T, category: 'wizardStates' | 'preferences' | 'temporaryStates' = 'preferences'): void {
this.setState(prev => ({
...prev,
uiState: {
...prev.uiState,
[category]: {
...prev.uiState[category],
[key]: value,
},
},
}));
}
getUIState<T>(key: string, category: 'wizardStates' | 'preferences' | 'temporaryStates' = 'preferences'): T | undefined {
return this.state.uiState[category][key] as T;
}
// Register persistence callbacks
onPersistence(callback: (state: OpchanState) => Promise<void>): () => void {
this.persistenceCallbacks.add(callback);
return () => this.persistenceCallbacks.delete(callback);
}
private async triggerPersistence(state: OpchanState): Promise<void> {
// Run persistence callbacks asynchronously to avoid blocking UI
setTimeout(async () => {
for (const callback of this.persistenceCallbacks) {
try {
await callback(state);
} catch (error) {
console.warn('Store persistence callback failed:', error);
}
}
}, 0);
}
}
export const opchanStore = new OpchanStoreImpl();

View File

@ -1,11 +1,12 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "dist",
"module": "esnext",
"jsx": "react-jsx",
"declaration": false,
"declaration": true,
"emitDeclarationOnly": false,
"declarationMap": false,
"skipLibCheck": true