mirror of
https://github.com/logos-messaging/OpChan.git
synced 2026-01-03 21:33: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';
|
import { CypherImage } from '@/components/ui/CypherImage';
|
||||||
|
|
||||||
const FeedSidebar: React.FC = () => {
|
const FeedSidebar: React.FC = () => {
|
||||||
// ✅ Use reactive hooks for data
|
|
||||||
const {cells, posts, comments, cellsWithStats, userVerificationStatus} = useContent();
|
const {cells, posts, comments, cellsWithStats, userVerificationStatus} = useContent();
|
||||||
const { currentUser, verificationStatus } = useAuth();
|
const { currentUser, verificationStatus } = useAuth();
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
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 { EVerificationStatus } from '@opchan/core';
|
||||||
import { localDatabase } from '@opchan/core';
|
import { localDatabase } from '@opchan/core';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@ -64,36 +64,16 @@ const Header = () => {
|
|||||||
const [walletWizardOpen, setWalletWizardOpen] = useState(false);
|
const [walletWizardOpen, setWalletWizardOpen] = useState(false);
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
// Use centralized UI state instead of direct LocalDatabase access
|
||||||
// Use LocalDatabase to persist wizard state across navigation
|
const [hasShownWizard, setHasShownWizard] = useUIState('hasShownWalletWizard', false);
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Auto-open wizard when wallet connects for the first time
|
// Auto-open wizard when wallet connects for the first time
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (isConnected) {
|
if (isConnected && !hasShownWizard) {
|
||||||
getHasShownWizard().then(hasShown => {
|
setWalletWizardOpen(true);
|
||||||
if (!hasShown) {
|
setHasShownWizard(true);
|
||||||
setWalletWizardOpen(true);
|
|
||||||
setHasShownWizard(true).catch(console.error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, [isConnected]);
|
}, [isConnected, hasShownWizard, setHasShownWizard]);
|
||||||
|
|
||||||
const handleConnect = async () => {
|
const handleConnect = async () => {
|
||||||
setWalletWizardOpen(true);
|
setWalletWizardOpen(true);
|
||||||
@ -105,7 +85,7 @@ const Header = () => {
|
|||||||
|
|
||||||
const handleDisconnect = async () => {
|
const handleDisconnect = async () => {
|
||||||
await disconnect();
|
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({
|
toast({
|
||||||
title: 'Wallet Disconnected',
|
title: 'Wallet Disconnected',
|
||||||
description: 'Your wallet has been disconnected successfully.',
|
description: 'Your wallet has been disconnected successfully.',
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { LinkRenderer } from '@/components/ui/link-renderer';
|
import { LinkRenderer } from '@/components/ui/link-renderer';
|
||||||
|
import { RelevanceIndicator } from '@/components/ui/relevance-indicator';
|
||||||
import { ShareButton } from '@/components/ui/ShareButton';
|
import { ShareButton } from '@/components/ui/ShareButton';
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@ -322,6 +323,17 @@ const PostList = () => {
|
|||||||
<MessageSquare className="inline w-3 h-3 mr-1" />
|
<MessageSquare className="inline w-3 h-3 mr-1" />
|
||||||
{commentsByPost[post.id]?.length || 0} comments
|
{commentsByPost[post.id]?.length || 0} comments
|
||||||
</span>
|
</span>
|
||||||
|
{typeof post.relevanceScore === 'number' && (
|
||||||
|
<>
|
||||||
|
<span>•</span>
|
||||||
|
<RelevanceIndicator
|
||||||
|
score={post.relevanceScore}
|
||||||
|
details={post.relevanceDetails}
|
||||||
|
type="post"
|
||||||
|
showTooltip={true}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<ShareButton
|
<ShareButton
|
||||||
url={`${window.location.origin}/post/${post.id}`}
|
url={`${window.location.origin}/post/${post.id}`}
|
||||||
title={post.title}
|
title={post.title}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Shield, Crown, Hash } from 'lucide-react';
|
import { Shield, Crown, Hash } from 'lucide-react';
|
||||||
import { useUserDisplay } from '@opchan/react';
|
import { useUserDisplay } from '@opchan/react';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
interface AuthorDisplayProps {
|
interface AuthorDisplayProps {
|
||||||
address: string;
|
address: string;
|
||||||
@ -13,8 +14,11 @@ export function AuthorDisplay({
|
|||||||
className = '',
|
className = '',
|
||||||
showBadge = true,
|
showBadge = true,
|
||||||
}: AuthorDisplayProps) {
|
}: AuthorDisplayProps) {
|
||||||
const { displayName, callSign, ensName, ordinalDetails } =
|
const { ensName, ordinalDetails, callSign, displayName } = useUserDisplay(address);
|
||||||
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
|
// Only show a badge if the author has ENS, Ordinal, or Call Sign
|
||||||
const shouldShowBadge = showBadge && (ensName || ordinalDetails || callSign);
|
const shouldShowBadge = showBadge && (ensName || ordinalDetails || callSign);
|
||||||
|
|||||||
@ -6,5 +6,6 @@ export {
|
|||||||
usePermissions,
|
usePermissions,
|
||||||
useContent,
|
useContent,
|
||||||
useUIState,
|
useUIState,
|
||||||
|
useUserDisplay,
|
||||||
} from '@opchan/react';
|
} from '@opchan/react';
|
||||||
|
|
||||||
|
|||||||
@ -29,7 +29,7 @@ export * from './lib/forum/transformers';
|
|||||||
// Export services
|
// Export services
|
||||||
export { BookmarkService } from './lib/services/BookmarkService';
|
export { BookmarkService } from './lib/services/BookmarkService';
|
||||||
export { MessageService } from './lib/services/MessageService';
|
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 { ordinals } from './lib/services/Ordinals';
|
||||||
|
|
||||||
// Export utilities
|
// Export utilities
|
||||||
@ -47,4 +47,5 @@ export * from './lib/wallet/config';
|
|||||||
export * from './lib/wallet/types';
|
export * from './lib/wallet/types';
|
||||||
|
|
||||||
// Primary client API
|
// 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,
|
ensName: undefined,
|
||||||
ordinalDetails: undefined,
|
ordinalDetails: undefined,
|
||||||
callSign: undefined,
|
callSign: undefined,
|
||||||
displayPreference: EDisplayPreference.WALLET_ADDRESS,
|
displayPreference: EDisplayPreference.WALLET_ADDRESS,
|
||||||
lastUpdated: 0,
|
lastUpdated: 0,
|
||||||
verificationStatus: EVerificationStatus.WALLET_UNCONNECTED,
|
verificationStatus: EVerificationStatus.WALLET_UNCONNECTED,
|
||||||
displayName: address.slice(0, 6) + '...' + address.slice(-4),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const merged: UserIdentityCache[string] = {
|
const merged: UserIdentityCache[string] = {
|
||||||
|
|||||||
@ -286,8 +286,8 @@ export class DelegationManager {
|
|||||||
!proof?.walletAddress ||
|
!proof?.walletAddress ||
|
||||||
!proof?.authMessage ||
|
!proof?.authMessage ||
|
||||||
proof?.expiryTimestamp === undefined ||
|
proof?.expiryTimestamp === undefined ||
|
||||||
proof.walletAddress !== expectedWalletAddress ||
|
proof.walletAddress !== expectedWalletAddress
|
||||||
Date.now() >= proof.expiryTimestamp
|
// Date.now() >= proof.expiryTimestamp
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -329,9 +329,9 @@ export class DelegationManager {
|
|||||||
if (proof.walletAddress !== expectedWalletAddress) {
|
if (proof.walletAddress !== expectedWalletAddress) {
|
||||||
reasons.push('Delegation wallet address does not match author');
|
reasons.push('Delegation wallet address does not match author');
|
||||||
}
|
}
|
||||||
if (Date.now() >= proof.expiryTimestamp) {
|
// if (Date.now() >= proof.expiryTimestamp) {
|
||||||
reasons.push('Delegation has expired');
|
// reasons.push('Delegation has expired');
|
||||||
}
|
// }
|
||||||
if (
|
if (
|
||||||
!proof.authMessage.includes(expectedWalletAddress) ||
|
!proof.authMessage.includes(expectedWalletAddress) ||
|
||||||
!proof.authMessage.includes(expectedBrowserKey) ||
|
!proof.authMessage.includes(expectedBrowserKey) ||
|
||||||
|
|||||||
@ -7,12 +7,10 @@ import {
|
|||||||
ModerateMessage,
|
ModerateMessage,
|
||||||
EModerationAction,
|
EModerationAction,
|
||||||
} from '../../types/waku';
|
} from '../../types/waku';
|
||||||
import messageManager from '../waku';
|
|
||||||
import { localDatabase } from '../database/LocalDatabase';
|
import { localDatabase } from '../database/LocalDatabase';
|
||||||
import { RelevanceCalculator } from './RelevanceCalculator';
|
import { RelevanceCalculator } from './RelevanceCalculator';
|
||||||
import { UserVerificationStatus } from '../../types/forum';
|
import { UserVerificationStatus } from '../../types/forum';
|
||||||
// Validation is enforced at ingestion time by LocalDatabase. Transformers assume
|
import { EVerificationStatus } from '../../types/identity';
|
||||||
// cache contains only valid, verified messages.
|
|
||||||
|
|
||||||
export const transformCell = async (
|
export const transformCell = async (
|
||||||
cellMessage: CellMessage,
|
cellMessage: CellMessage,
|
||||||
@ -287,10 +285,20 @@ export const transformVote = async (
|
|||||||
|
|
||||||
export const getDataFromCache = async (
|
export const getDataFromCache = async (
|
||||||
_verifyMessage?: unknown, // Deprecated parameter, kept for compatibility
|
_verifyMessage?: unknown, // Deprecated parameter, kept for compatibility
|
||||||
userVerificationStatus?: UserVerificationStatus
|
|
||||||
): Promise<{ cells: Cell[]; posts: Post[]; comments: Comment[] }> => {
|
): Promise<{ cells: Cell[]; posts: Post[]; comments: Comment[] }> => {
|
||||||
// Use LocalDatabase cache for immediate hydration, avoiding messageManager race conditions
|
const userIdentities = localDatabase.cache.userIdentities;
|
||||||
// All validation is now handled internally by the transform functions
|
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(
|
const posts = await Promise.all(
|
||||||
Object.values(localDatabase.cache.posts)
|
Object.values(localDatabase.cache.posts)
|
||||||
.filter((post): post is PostMessage => post !== null)
|
.filter((post): post is PostMessage => post !== null)
|
||||||
|
|||||||
@ -41,6 +41,7 @@ export class UserIdentityService {
|
|||||||
address: string,
|
address: string,
|
||||||
opts?: { fresh?: boolean }
|
opts?: { fresh?: boolean }
|
||||||
): Promise<UserIdentity | null> {
|
): Promise<UserIdentity | null> {
|
||||||
|
console.log('getIdentity', address, opts);
|
||||||
if (opts?.fresh) {
|
if (opts?.fresh) {
|
||||||
return this.getUserIdentityFresh(address);
|
return this.getUserIdentityFresh(address);
|
||||||
}
|
}
|
||||||
@ -187,9 +188,6 @@ export class UserIdentityService {
|
|||||||
*/
|
*/
|
||||||
getDisplayName(address: string): string {
|
getDisplayName(address: string): string {
|
||||||
const identity = localDatabase.cache.userIdentities[address];
|
const identity = localDatabase.cache.userIdentities[address];
|
||||||
if (!identity) {
|
|
||||||
return `${address.slice(0, 6)}...${address.slice(-4)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
identity.displayPreference === EDisplayPreference.CALL_SIGN &&
|
identity.displayPreference === EDisplayPreference.CALL_SIGN &&
|
||||||
@ -211,7 +209,7 @@ export class UserIdentityService {
|
|||||||
* Internal method to get user identity without debouncing
|
* Internal method to get user identity without debouncing
|
||||||
*/
|
*/
|
||||||
private async getUserIdentityInternal(address: string): Promise<UserIdentity | null> {
|
private async getUserIdentityInternal(address: string): Promise<UserIdentity | null> {
|
||||||
const record = this.getCachedRecord(address);
|
const record = localDatabase.cache.userIdentities[address];
|
||||||
if (record) {
|
if (record) {
|
||||||
let identity = this.buildUserIdentityFromRecord(address, record);
|
let identity = this.buildUserIdentityFromRecord(address, record);
|
||||||
identity = await this.ensureEnsEnriched(address, identity);
|
identity = await this.ensureEnsEnriched(address, identity);
|
||||||
@ -237,6 +235,7 @@ export class UserIdentityService {
|
|||||||
address: string
|
address: string
|
||||||
): Promise<UserIdentity | null> {
|
): Promise<UserIdentity | null> {
|
||||||
try {
|
try {
|
||||||
|
console.log('resolveUserIdentity', address);
|
||||||
const [ensName, ordinalDetails] = await Promise.all([
|
const [ensName, ordinalDetails] = await Promise.all([
|
||||||
this.resolveENSName(address),
|
this.resolveENSName(address),
|
||||||
this.resolveOrdinalDetails(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.
|
* Ensure ENS is enriched if missing. Persists updates and keeps caches in sync.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -19,7 +19,7 @@
|
|||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npm run clean && npm run build:esm && npm run build:types",
|
"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",
|
"build:types": "tsc -p tsconfig.types.json",
|
||||||
"clean": "rm -rf dist",
|
"clean": "rm -rf dist",
|
||||||
"dev": "tsc -w -p tsconfig.build.json",
|
"dev": "tsc -w -p tsconfig.build.json",
|
||||||
|
|||||||
@ -2,32 +2,34 @@ import React from 'react';
|
|||||||
import { useClient } from '../context/ClientContext';
|
import { useClient } from '../context/ClientContext';
|
||||||
import { useOpchanStore, setOpchanState } from '../store/opchanStore';
|
import { useOpchanStore, setOpchanState } from '../store/opchanStore';
|
||||||
import {
|
import {
|
||||||
PostMessage,
|
|
||||||
CommentMessage,
|
|
||||||
Post,
|
Post,
|
||||||
Comment,
|
Comment,
|
||||||
Cell,
|
Cell,
|
||||||
EVerificationStatus,
|
EVerificationStatus,
|
||||||
UserVerificationStatus,
|
UserVerificationStatus,
|
||||||
BookmarkType,
|
BookmarkType,
|
||||||
|
getDataFromCache,
|
||||||
} from '@opchan/core';
|
} from '@opchan/core';
|
||||||
import { BookmarkService } from '@opchan/core';
|
import { BookmarkService } from '@opchan/core';
|
||||||
|
|
||||||
function reflectCache(client: ReturnType<typeof useClient>): void {
|
function reflectCache(client: ReturnType<typeof useClient>): void {
|
||||||
const cache = client.database.cache;
|
getDataFromCache().then(({ cells, posts, comments }) => {
|
||||||
setOpchanState(prev => ({
|
setOpchanState(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
content: {
|
content: {
|
||||||
...prev.content,
|
...prev.content,
|
||||||
cells: Object.values(cache.cells),
|
cells,
|
||||||
posts: Object.values(cache.posts),
|
posts,
|
||||||
comments: Object.values(cache.comments),
|
comments,
|
||||||
bookmarks: Object.values(cache.bookmarks),
|
bookmarks: Object.values(client.database.cache.bookmarks),
|
||||||
lastSync: client.database.getSyncState().lastSync,
|
lastSync: client.database.getSyncState().lastSync,
|
||||||
pendingIds: prev.content.pendingIds,
|
pendingIds: prev.content.pendingIds,
|
||||||
pendingVotes: prev.content.pendingVotes,
|
pendingVotes: prev.content.pendingVotes,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('reflectCache failed', err);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useContent() {
|
export function useContent() {
|
||||||
@ -52,7 +54,7 @@ export function useContent() {
|
|||||||
|
|
||||||
// Derived maps
|
// Derived maps
|
||||||
const postsByCell = React.useMemo(() => {
|
const postsByCell = React.useMemo(() => {
|
||||||
const map: Record<string, PostMessage[]> = {};
|
const map: Record<string, Post[]> = {};
|
||||||
for (const p of content.posts) {
|
for (const p of content.posts) {
|
||||||
(map[p.cellId] ||= []).push(p);
|
(map[p.cellId] ||= []).push(p);
|
||||||
}
|
}
|
||||||
@ -60,10 +62,13 @@ export function useContent() {
|
|||||||
}, [content.posts]);
|
}, [content.posts]);
|
||||||
|
|
||||||
const commentsByPost = React.useMemo(() => {
|
const commentsByPost = React.useMemo(() => {
|
||||||
const map: Record<string, CommentMessage[]> = {};
|
const map: Record<string, Comment[]> = {};
|
||||||
for (const c of content.comments) {
|
for (const c of content.comments) {
|
||||||
(map[c.postId] ||= []).push(c);
|
(map[c.postId] ||= []).push(c);
|
||||||
}
|
}
|
||||||
|
for (const postId in map) {
|
||||||
|
map[postId].sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [content.comments]);
|
}, [content.comments]);
|
||||||
|
|
||||||
@ -230,19 +235,19 @@ export function useContent() {
|
|||||||
},
|
},
|
||||||
}), [client, session.currentUser, content.cells]);
|
}), [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;
|
const address = session.currentUser?.address;
|
||||||
if (!address) return false;
|
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);
|
const updated = await client.database.getUserBookmarks(address);
|
||||||
setOpchanState(prev => ({ ...prev, content: { ...prev.content, bookmarks: updated } }));
|
setOpchanState(prev => ({ ...prev, content: { ...prev.content, bookmarks: updated } }));
|
||||||
return added;
|
return added;
|
||||||
}, [client, session.currentUser?.address]);
|
}, [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;
|
const address = session.currentUser?.address;
|
||||||
if (!address) return false;
|
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);
|
const updated = await client.database.getUserBookmarks(address);
|
||||||
setOpchanState(prev => ({ ...prev, content: { ...prev.content, bookmarks: updated } }));
|
setOpchanState(prev => ({ ...prev, content: { ...prev.content, bookmarks: updated } }));
|
||||||
return added;
|
return added;
|
||||||
|
|||||||
@ -1,39 +1,68 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useClient } from '../context/ClientContext';
|
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 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 [loading, setLoading] = React.useState<boolean>(true);
|
||||||
const [error, setError] = React.useState<string | undefined>(undefined);
|
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(() => {
|
React.useEffect(() => {
|
||||||
|
if (hasHydrated) return;
|
||||||
|
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
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 (mounted) {
|
||||||
if (value !== undefined) setState(value as T);
|
if (value !== undefined) {
|
||||||
|
opchanStore.setUIState(key, value as T, category);
|
||||||
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
setHasHydrated(true);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setError((e as Error).message);
|
setError((e as Error).message);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
setHasHydrated(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
mounted = false;
|
mounted = false;
|
||||||
};
|
};
|
||||||
}, [client, key]);
|
}, [client, key, category, storeValue, hasHydrated]);
|
||||||
|
|
||||||
const set = React.useCallback((value: T) => {
|
const set = React.useCallback((value: T) => {
|
||||||
setState(value);
|
// Update store (will auto-persist via StoreWiring)
|
||||||
client.database.storeUIState(key, value).catch(() => {});
|
opchanStore.setUIState(key, value, category);
|
||||||
}, [client, key]);
|
}, [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 React from 'react';
|
||||||
import { useClient } from '../context/ClientContext';
|
import { useClient } from '../context/ClientContext';
|
||||||
|
import { useOpchanStore, opchanStore } from '../store/opchanStore';
|
||||||
import { EDisplayPreference, EVerificationStatus } from '@opchan/core';
|
import { EDisplayPreference, EVerificationStatus } from '@opchan/core';
|
||||||
import { UserIdentity } from '@opchan/core/dist/lib/services/UserIdentityService';
|
import { UserIdentity } from '@opchan/core/dist/lib/services/UserIdentityService';
|
||||||
|
|
||||||
export interface UserDisplayInfo extends UserIdentity {
|
export type UserDisplayInfo = UserIdentity & {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
@ -15,85 +16,82 @@ export interface UserDisplayInfo extends UserIdentity {
|
|||||||
export function useUserDisplay(address: string): UserDisplayInfo {
|
export function useUserDisplay(address: string): UserDisplayInfo {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
|
|
||||||
const [displayInfo, setDisplayInfo] = React.useState<UserDisplayInfo>({
|
// Get identity from central store
|
||||||
address,
|
const storeIdentity = useOpchanStore(s => s.identity.identitiesByAddress[address]);
|
||||||
displayName: `${address.slice(0, 6)}...${address.slice(-4)}`,
|
|
||||||
lastUpdated: 0,
|
const [isLoading, setIsLoading] = React.useState<boolean>(true);
|
||||||
callSign: undefined,
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
ensName: undefined,
|
const [hasInitialized, setHasInitialized] = React.useState<boolean>(false);
|
||||||
ordinalDetails: undefined,
|
|
||||||
verificationStatus: EVerificationStatus.WALLET_UNCONNECTED,
|
|
||||||
displayPreference: EDisplayPreference.WALLET_ADDRESS,
|
|
||||||
isLoading: true,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initial load and refresh listener
|
// Initialize from store or load from service
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!address) return;
|
if (!address || hasInitialized) return;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
const loadUserDisplay = async () => {
|
const initializeUserDisplay = async () => {
|
||||||
try {
|
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 (cancelled) return;
|
||||||
|
|
||||||
if (identity) {
|
if (identity) {
|
||||||
setDisplayInfo({
|
opchanStore.setIdentity(address, identity);
|
||||||
...identity,
|
setError(null);
|
||||||
isLoading: false,
|
} else {
|
||||||
error: null,
|
setError(null);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
setHasInitialized(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
setDisplayInfo(prev => ({
|
setIsLoading(false);
|
||||||
...prev,
|
setError(error instanceof Error ? error.message : 'Unknown error');
|
||||||
isLoading: false,
|
setHasInitialized(true);
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadUserDisplay();
|
initializeUserDisplay();
|
||||||
|
|
||||||
// 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',
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
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;
|
return displayInfo;
|
||||||
}
|
}
|
||||||
@ -41,7 +41,7 @@ export const OpChanProvider: React.FC<NewOpChanProviderProps> = ({ config, walle
|
|||||||
walletType: account.walletType,
|
walletType: account.walletType,
|
||||||
displayPreference: 'wallet-address' as EDisplayPreference,
|
displayPreference: 'wallet-address' as EDisplayPreference,
|
||||||
verificationStatus: EVerificationStatus.WALLET_CONNECTED,
|
verificationStatus: EVerificationStatus.WALLET_CONNECTED,
|
||||||
displayName: account.address,
|
displayName: account.address.slice(0, 6) + '...' + account.address.slice(-4),
|
||||||
lastChecked: Date.now(),
|
lastChecked: Date.now(),
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useClient } from '../context/ClientContext';
|
import { useClient } from '../context/ClientContext';
|
||||||
import { setOpchanState, getOpchanState } from '../store/opchanStore';
|
import { setOpchanState, getOpchanState, opchanStore } from '../store/opchanStore';
|
||||||
import type { OpchanMessage, User } from '@opchan/core';
|
import type { OpchanMessage, User, UserIdentity } from '@opchan/core';
|
||||||
import { EVerificationStatus } from '@opchan/core';
|
import { EVerificationStatus, getDataFromCache } from '@opchan/core';
|
||||||
|
|
||||||
export const StoreWiring: React.FC = () => {
|
export const StoreWiring: React.FC = () => {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
@ -12,27 +12,60 @@ export const StoreWiring: React.FC = () => {
|
|||||||
let unsubHealth: (() => void) | null = null;
|
let unsubHealth: (() => void) | null = null;
|
||||||
let unsubMessages: (() => void) | null = null;
|
let unsubMessages: (() => void) | null = null;
|
||||||
let unsubIdentity: (() => void) | null = null;
|
let unsubIdentity: (() => void) | null = null;
|
||||||
|
let unsubPersistence: (() => void) | null = null;
|
||||||
|
|
||||||
const hydrate = async () => {
|
const hydrate = async () => {
|
||||||
try {
|
try {
|
||||||
await client.database.open();
|
await client.database.open();
|
||||||
const cache = client.database.cache;
|
const { cells, posts, comments } = await getDataFromCache();
|
||||||
|
|
||||||
// Reflect content cache
|
// Reflect transformed content
|
||||||
setOpchanState(prev => ({
|
setOpchanState(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
content: {
|
content: {
|
||||||
...prev.content,
|
...prev.content,
|
||||||
cells: Object.values(cache.cells),
|
cells,
|
||||||
posts: Object.values(cache.posts),
|
posts,
|
||||||
comments: Object.values(cache.comments),
|
comments,
|
||||||
bookmarks: Object.values(cache.bookmarks),
|
bookmarks: Object.values(client.database.cache.bookmarks),
|
||||||
lastSync: client.database.getSyncState().lastSync,
|
lastSync: client.database.getSyncState().lastSync,
|
||||||
pendingIds: new Set<string>(),
|
pendingIds: new Set<string>(),
|
||||||
pendingVotes: 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
|
// Hydrate session (user + delegation) from LocalDatabase
|
||||||
try {
|
try {
|
||||||
const loadedUser = await client.database.loadUser();
|
const loadedUser = await client.database.loadUser();
|
||||||
@ -75,15 +108,15 @@ export const StoreWiring: React.FC = () => {
|
|||||||
// Persist, then reflect cache in store
|
// Persist, then reflect cache in store
|
||||||
try {
|
try {
|
||||||
await client.database.updateCache(message);
|
await client.database.updateCache(message);
|
||||||
const cache = client.database.cache;
|
const { cells, posts, comments } = await getDataFromCache();
|
||||||
setOpchanState(prev => ({
|
setOpchanState(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
content: {
|
content: {
|
||||||
...prev.content,
|
...prev.content,
|
||||||
cells: Object.values(cache.cells),
|
cells,
|
||||||
posts: Object.values(cache.posts),
|
posts,
|
||||||
comments: Object.values(cache.comments),
|
comments,
|
||||||
bookmarks: Object.values(cache.bookmarks),
|
bookmarks: Object.values(client.database.cache.bookmarks),
|
||||||
lastSync: Date.now(),
|
lastSync: Date.now(),
|
||||||
pendingIds: prev.content.pendingIds,
|
pendingIds: prev.content.pendingIds,
|
||||||
pendingVotes: prev.content.pendingVotes,
|
pendingVotes: prev.content.pendingVotes,
|
||||||
@ -94,37 +127,57 @@ export const StoreWiring: React.FC = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reactively update session.currentUser when identity refreshes for the active user
|
// Reactively update ALL identities when they refresh (not just current user)
|
||||||
unsubIdentity = client.userIdentityService.subscribe(async (address: string) => {
|
unsubIdentity = client.userIdentityService.subscribe(async (address: string, identity) => {
|
||||||
try {
|
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 { session } = getOpchanState();
|
||||||
const active = session.currentUser;
|
const active = session.currentUser;
|
||||||
if (!active || active.address !== address) {
|
if (active && active.address === address) {
|
||||||
return;
|
const updated: User = {
|
||||||
}
|
...active,
|
||||||
|
...identity,
|
||||||
const identity = await client.userIdentityService.getIdentity(address);
|
};
|
||||||
if (!identity) {
|
|
||||||
return;
|
try {
|
||||||
}
|
await client.database.storeUser(updated);
|
||||||
|
} catch (persistErr) {
|
||||||
const updated: User = {
|
console.warn('[StoreWiring] Failed to persist updated user after identity refresh:', persistErr);
|
||||||
...active,
|
}
|
||||||
...identity,
|
|
||||||
};
|
setOpchanState(prev => ({
|
||||||
|
...prev,
|
||||||
try {
|
session: {
|
||||||
await client.database.storeUser(updated);
|
...prev.session,
|
||||||
} catch (persistErr) {
|
currentUser: updated,
|
||||||
console.warn('[StoreWiring] Failed to persist updated user after identity refresh:', persistErr);
|
verificationStatus: updated.verificationStatus,
|
||||||
|
},
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { cells, posts, comments } = await getDataFromCache();
|
||||||
setOpchanState(prev => ({
|
setOpchanState(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
session: {
|
content: {
|
||||||
...prev.session,
|
...prev.content,
|
||||||
currentUser: updated,
|
cells,
|
||||||
verificationStatus: updated.verificationStatus,
|
posts,
|
||||||
|
comments,
|
||||||
|
bookmarks: Object.values(client.database.cache.bookmarks),
|
||||||
|
lastSync: Date.now(),
|
||||||
|
pendingIds: prev.content.pendingIds,
|
||||||
|
pendingVotes: prev.content.pendingVotes,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -135,12 +188,39 @@ export const StoreWiring: React.FC = () => {
|
|||||||
|
|
||||||
hydrate().then(() => {
|
hydrate().then(() => {
|
||||||
wire();
|
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 () => {
|
return () => {
|
||||||
unsubHealth?.();
|
unsubHealth?.();
|
||||||
unsubMessages?.();
|
unsubMessages?.();
|
||||||
unsubIdentity?.();
|
unsubIdentity?.();
|
||||||
|
unsubPersistence?.();
|
||||||
};
|
};
|
||||||
}, [client]);
|
}, [client]);
|
||||||
|
|
||||||
|
|||||||
@ -1,13 +1,14 @@
|
|||||||
import React, { useSyncExternalStore } from 'react';
|
import React, { useSyncExternalStore } from 'react';
|
||||||
import type {
|
import type {
|
||||||
CellMessage,
|
Cell,
|
||||||
PostMessage,
|
Post,
|
||||||
CommentMessage,
|
Comment,
|
||||||
Bookmark,
|
Bookmark,
|
||||||
User,
|
User,
|
||||||
EVerificationStatus,
|
EVerificationStatus,
|
||||||
DelegationFullStatus,
|
DelegationFullStatus,
|
||||||
} from '@opchan/core';
|
} from '@opchan/core';
|
||||||
|
import type { UserIdentity } from '@opchan/core/dist/lib/services/UserIdentityService';
|
||||||
|
|
||||||
type Listener = () => void;
|
type Listener = () => void;
|
||||||
|
|
||||||
@ -18,9 +19,9 @@ export interface SessionSlice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ContentSlice {
|
export interface ContentSlice {
|
||||||
cells: CellMessage[];
|
cells: Cell[];
|
||||||
posts: PostMessage[];
|
posts: Post[];
|
||||||
comments: CommentMessage[];
|
comments: Comment[];
|
||||||
bookmarks: Bookmark[];
|
bookmarks: Bookmark[];
|
||||||
lastSync: number | null;
|
lastSync: number | null;
|
||||||
pendingIds: Set<string>;
|
pendingIds: Set<string>;
|
||||||
@ -28,8 +29,17 @@ export interface ContentSlice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface IdentitySlice {
|
export interface IdentitySlice {
|
||||||
// minimal identity cache; full logic lives in UserIdentityService
|
// Enhanced identity cache with full UserIdentity data
|
||||||
displayNameByAddress: Record<string, string>;
|
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 {
|
export interface NetworkSlice {
|
||||||
@ -42,6 +52,7 @@ export interface OpchanState {
|
|||||||
session: SessionSlice;
|
session: SessionSlice;
|
||||||
content: ContentSlice;
|
content: ContentSlice;
|
||||||
identity: IdentitySlice;
|
identity: IdentitySlice;
|
||||||
|
uiState: UIStateSlice;
|
||||||
network: NetworkSlice;
|
network: NetworkSlice;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -62,6 +73,13 @@ const defaultState: OpchanState = {
|
|||||||
},
|
},
|
||||||
identity: {
|
identity: {
|
||||||
displayNameByAddress: {},
|
displayNameByAddress: {},
|
||||||
|
identitiesByAddress: {},
|
||||||
|
lastUpdatedByAddress: {},
|
||||||
|
},
|
||||||
|
uiState: {
|
||||||
|
wizardStates: {},
|
||||||
|
preferences: {},
|
||||||
|
temporaryStates: {},
|
||||||
},
|
},
|
||||||
network: {
|
network: {
|
||||||
isConnected: false,
|
isConnected: false,
|
||||||
@ -73,6 +91,7 @@ const defaultState: OpchanState = {
|
|||||||
class OpchanStoreImpl {
|
class OpchanStoreImpl {
|
||||||
private state: OpchanState = defaultState;
|
private state: OpchanState = defaultState;
|
||||||
private listeners: Set<Listener> = new Set();
|
private listeners: Set<Listener> = new Set();
|
||||||
|
private persistenceCallbacks: Set<(state: OpchanState) => Promise<void>> = new Set();
|
||||||
|
|
||||||
subscribe(listener: Listener): () => void {
|
subscribe(listener: Listener): () => void {
|
||||||
this.listeners.add(listener);
|
this.listeners.add(listener);
|
||||||
@ -92,8 +111,67 @@ class OpchanStoreImpl {
|
|||||||
if (next !== this.state) {
|
if (next !== this.state) {
|
||||||
this.state = next;
|
this.state = next;
|
||||||
this.notify();
|
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();
|
export const opchanStore = new OpchanStoreImpl();
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.json",
|
"extends": "../../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"declaration": false,
|
"declaration": true,
|
||||||
"emitDeclarationOnly": false,
|
"emitDeclarationOnly": false,
|
||||||
"declarationMap": false,
|
"declarationMap": false,
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user