mirror of
https://github.com/logos-messaging/OpChan.git
synced 2026-01-02 21:03:09 +00:00
state consistentcy
This commit is contained in:
parent
66802d7d78
commit
e4192f8d20
@ -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();
|
||||
|
||||
|
||||
@ -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.',
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -6,5 +6,6 @@ export {
|
||||
usePermissions,
|
||||
useContent,
|
||||
useUIState,
|
||||
useUserDisplay,
|
||||
} from '@opchan/react';
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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] = {
|
||||
|
||||
@ -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) ||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 }];
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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]);
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user