feat: add sync status for messages

This commit is contained in:
Danish Arora 2025-11-14 14:37:00 -05:00
parent 78ff8b537b
commit bf7b3f20a1
No known key found for this signature in database
GPG Key ID: 1C6EF37CDAE1426E
6 changed files with 214 additions and 18 deletions

View File

@ -22,6 +22,7 @@ import {
X, X,
Clock, Clock,
Trash2, Trash2,
Loader2,
} from 'lucide-react'; } from 'lucide-react';
import { import {
DropdownMenu, DropdownMenu,
@ -30,6 +31,12 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -50,7 +57,7 @@ import { WakuHealthDot } from '@/components/ui/waku-health-indicator';
const Header = () => { const Header = () => {
const { currentUser, delegationInfo } = useAuth(); const { currentUser, delegationInfo } = useAuth();
const { statusMessage } = useNetwork(); const { statusMessage, syncStatus, syncDetail } = useNetwork();
const location = useLocation(); const location = useLocation();
const { toast } = useToast(); const { toast } = useToast();
@ -166,16 +173,52 @@ const Header = () => {
<span className="text-[10px] text-muted-foreground"> <span className="text-[10px] text-muted-foreground">
{statusMessage} {statusMessage}
</span> </span>
{syncStatus === 'syncing' && syncDetail && syncDetail.missing > 0 && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center space-x-1 text-[10px] text-yellow-400 cursor-help">
<Loader2 className="w-3 h-3 animate-spin" />
<span>SYNCING ({syncDetail.missing})</span>
</div>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">
<strong>Syncing messages</strong>
<br />
Pending: {syncDetail.missing}
<br />
Received: {syncDetail.received}
{syncDetail.lost > 0 && (
<>
<br />
Lost: {syncDetail.lost}
</>
)}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{content.lastSync && ( {content.lastSync && (
<div className="flex items-center space-x-1 text-[10px] text-muted-foreground"> <TooltipProvider delayDuration={200}>
<Clock className="w-3 h-3" /> <Tooltip>
<span> <TooltipTrigger asChild>
{new Date(content.lastSync).toLocaleTimeString([], { <div className="flex items-center space-x-1 text-[10px] text-muted-foreground cursor-help">
hour: '2-digit', <Clock className="w-3 h-3" />
minute: '2-digit', <span>
})} {new Date(content.lastSync).toLocaleTimeString([], {
</span> hour: '2-digit',
</div> minute: '2-digit',
})}
</span>
</div>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">Last message sync time</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)} )}
</div> </div>
</div> </div>
@ -183,9 +226,26 @@ const Header = () => {
{/* Right: User Actions */} {/* Right: User Actions */}
<div className="flex items-center space-x-2 sm:space-x-3 flex-shrink-0"> <div className="flex items-center space-x-2 sm:space-x-3 flex-shrink-0">
{/* Network Status (Mobile) */} {/* Network Status (Mobile) */}
<div className="lg:hidden"> <TooltipProvider delayDuration={200}>
<WakuHealthDot /> <Tooltip>
</div> <TooltipTrigger asChild>
<div className="lg:hidden flex items-center space-x-1 cursor-help">
{syncStatus === 'syncing' && syncDetail && syncDetail.missing > 0 ? (
<Loader2 className="w-4 h-4 text-yellow-400 animate-spin" />
) : (
<WakuHealthDot />
)}
</div>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">
{syncStatus === 'syncing' && syncDetail && syncDetail.missing > 0
? `Syncing ${syncDetail.missing} messages...`
: 'Network connected'}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* User Status & Actions */} {/* User Status & Actions */}
{isConnected || currentUser?.verificationStatus === EVerificationStatus.ANONYMOUS ? ( {isConnected || currentUser?.verificationStatus === EVerificationStatus.ANONYMOUS ? (
@ -466,8 +526,14 @@ const Header = () => {
<div className="flex items-center space-x-2 text-[10px] uppercase tracking-[0.2em] text-muted-foreground"> <div className="flex items-center space-x-2 text-[10px] uppercase tracking-[0.2em] text-muted-foreground">
<WakuHealthDot /> <WakuHealthDot />
<span>{statusMessage}</span> <span>{statusMessage}</span>
{syncStatus === 'syncing' && syncDetail && syncDetail.missing > 0 && (
<span className="text-yellow-400 flex items-center space-x-1">
<Loader2 className="w-3 h-3 animate-spin" />
<span>SYNCING ({syncDetail.missing})</span>
</span>
)}
{content.lastSync && ( {content.lastSync && (
<span className="ml-auto"> <span className="ml-auto" title="Last message sync">
{new Date(content.lastSync).toLocaleTimeString([], { {new Date(content.lastSync).toLocaleTimeString([], {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',

View File

@ -16,16 +16,19 @@ export interface MessageStatusCallback {
} }
export type IncomingMessageCallback = (message: OpchanMessage) => void; export type IncomingMessageCallback = (message: OpchanMessage) => void;
export type SyncStatusCallback = (status: 'syncing' | 'synced', detail: { received: number; missing: number; lost: number }) => void;
export class ReliableMessaging { export class ReliableMessaging {
private channel: ReliableChannel<IDecodedMessage> | null = null; private channel: ReliableChannel<IDecodedMessage> | null = null;
private messageCallbacks: Map<string, MessageStatusCallback> = new Map(); private messageCallbacks: Map<string, MessageStatusCallback> = new Map();
private incomingMessageCallbacks: Set<IncomingMessageCallback> = new Set(); private incomingMessageCallbacks: Set<IncomingMessageCallback> = new Set();
private syncStatusCallbacks: Set<SyncStatusCallback> = new Set();
private codecManager: CodecManager; private codecManager: CodecManager;
constructor(node: LightNode, config: WakuConfig) { constructor(node: LightNode, config: WakuConfig) {
this.codecManager = new CodecManager(node, config); this.codecManager = new CodecManager(node, config);
this.initializeChannel(node, config); this.initializeChannel(node, config);
} }
// ===== PUBLIC METHODS ===== // ===== PUBLIC METHODS =====
@ -58,9 +61,15 @@ export class ReliableMessaging {
return () => this.incomingMessageCallbacks.delete(callback); return () => this.incomingMessageCallbacks.delete(callback);
} }
public onSyncStatus(callback: SyncStatusCallback): () => void {
this.syncStatusCallbacks.add(callback);
return () => this.syncStatusCallbacks.delete(callback);
}
public cleanup(): void { public cleanup(): void {
this.messageCallbacks.clear(); this.messageCallbacks.clear();
this.incomingMessageCallbacks.clear(); this.incomingMessageCallbacks.clear();
this.syncStatusCallbacks.clear();
this.channel = null; this.channel = null;
} }
@ -81,11 +90,30 @@ export class ReliableMessaging {
decoder decoder
); );
this.setupChannelListeners(this.channel); this.setupChannelListeners(this.channel);
this.setupSyncStatusListeners(this.channel);
} catch (error) { } catch (error) {
console.error('Failed to create reliable channel:', error); console.error('Failed to create reliable channel:', error);
} }
} }
private setupSyncStatusListeners(channel: ReliableChannel<IDecodedMessage>): void {
// Check if syncStatus API is available
if (!channel.syncStatus) {
console.warn('ReliableChannel.syncStatus is not available in this SDK version');
return;
}
channel.syncStatus.addEventListener('syncing', (event) => {
const detail = event.detail;
this.syncStatusCallbacks.forEach(cb => cb('syncing', detail));
});
channel.syncStatus.addEventListener('synced', (event) => {
const detail = event.detail;
this.syncStatusCallbacks.forEach(cb => cb('synced', detail));
});
}
private setupChannelListeners( private setupChannelListeners(
channel: ReliableChannel<IDecodedMessage> channel: ReliableChannel<IDecodedMessage>
): void { ): void {

View File

@ -5,10 +5,10 @@ import {
MessageService, MessageService,
MessageStatusCallback, MessageStatusCallback,
} from './services/MessageService'; } from './services/MessageService';
import { ReliableMessaging } from './core/ReliableMessaging'; import { ReliableMessaging, SyncStatusCallback } from './core/ReliableMessaging';
import { WakuConfig } from '../../types'; import { WakuConfig } from '../../types';
export type { HealthChangeCallback, MessageStatusCallback }; export type { HealthChangeCallback, MessageStatusCallback, SyncStatusCallback };
class MessageManager { class MessageManager {
private nodeManager: WakuNodeManager | null = null; private nodeManager: WakuNodeManager | null = null;
@ -71,6 +71,13 @@ class MessageManager {
return this.messageService.onMessageReceived(callback); return this.messageService.onMessageReceived(callback);
} }
public onSyncStatus(callback: SyncStatusCallback): () => void {
if (!this.reliableMessaging) {
throw new Error('Reliable messaging not initialized');
}
return this.reliableMessaging.onSyncStatus(callback);
}
// ===== PRIVATE METHODS ===== // ===== PRIVATE METHODS =====
private async initialize(): Promise<void> { private async initialize(): Promise<void> {
@ -84,9 +91,9 @@ class MessageManager {
); );
// Set up health-based reliable messaging initialization // Set up health-based reliable messaging initialization
this.nodeManager.onHealthChange(isReady => { this.nodeManager.onHealthChange(async (isReady) => {
if (isReady && !this.reliableMessaging) { if (isReady && !this.reliableMessaging) {
this.initializeReliableMessaging(); await this.initializeReliableMessaging();
} else if (!isReady && this.reliableMessaging) { } else if (!isReady && this.reliableMessaging) {
this.cleanupReliableMessaging(); this.cleanupReliableMessaging();
} }
@ -115,6 +122,10 @@ class MessageManager {
} }
} }
public getReliableMessaging(): ReliableMessaging | null {
return this.reliableMessaging;
}
private cleanupReliableMessaging(): void { private cleanupReliableMessaging(): void {
if (this.reliableMessaging) { if (this.reliableMessaging) {
console.log('Cleaning up reliable messaging due to health status'); console.log('Cleaning up reliable messaging due to health status');
@ -131,6 +142,7 @@ export class DefaultMessageManager {
private _initPromise: Promise<MessageManager> | null = null; private _initPromise: Promise<MessageManager> | null = null;
private _pendingHealthSubscriptions: HealthChangeCallback[] = []; private _pendingHealthSubscriptions: HealthChangeCallback[] = [];
private _pendingMessageSubscriptions: ((message: any) => void)[] = []; private _pendingMessageSubscriptions: ((message: any) => void)[] = [];
private _pendingSyncStatusSubscriptions: SyncStatusCallback[] = [];
private _wakuConfig: WakuConfig | null = null; private _wakuConfig: WakuConfig | null = null;
// ===== PUBLIC METHODS ===== // ===== PUBLIC METHODS =====
@ -157,6 +169,31 @@ export class DefaultMessageManager {
this._instance!.onMessageReceived(callback); this._instance!.onMessageReceived(callback);
}); });
this._pendingMessageSubscriptions = []; this._pendingMessageSubscriptions = [];
// Establish all pending sync status subscriptions
this._pendingSyncStatusSubscriptions.forEach(callback => {
try {
this._instance!.onSyncStatus(callback);
} catch (e) {
// Reliable messaging might not be ready yet, keep in pending
}
});
// Set up a listener to retry sync subscriptions when reliable messaging becomes available
const reliableMessaging = this._instance.getReliableMessaging();
if (!reliableMessaging) {
// Watch for when it becomes available
const checkInterval = setInterval(() => {
const rm = this._instance?.getReliableMessaging();
if (rm && this._pendingSyncStatusSubscriptions.length > 0) {
this.retryPendingSyncSubscriptions();
clearInterval(checkInterval);
}
}, 1000);
// Clean up after 30 seconds
setTimeout(() => clearInterval(checkInterval), 30000);
}
} }
// Proxy other common methods // Proxy other common methods
@ -209,6 +246,49 @@ export class DefaultMessageManager {
} }
return this._instance.onMessageReceived(callback); return this._instance.onMessageReceived(callback);
} }
onSyncStatus(callback: SyncStatusCallback) {
if (!this._instance) {
// Queue the callback for when we're initialized
this._pendingSyncStatusSubscriptions.push(callback);
return () => {
const index = this._pendingSyncStatusSubscriptions.indexOf(callback);
if (index !== -1) {
this._pendingSyncStatusSubscriptions.splice(index, 1);
}
};
}
try {
return this._instance.onSyncStatus(callback);
} catch (e) {
// Reliable messaging not ready, queue it
this._pendingSyncStatusSubscriptions.push(callback);
return () => {
const index = this._pendingSyncStatusSubscriptions.indexOf(callback);
if (index !== -1) {
this._pendingSyncStatusSubscriptions.splice(index, 1);
}
};
}
}
// Helper to retry pending sync subscriptions when reliable messaging becomes available
private retryPendingSyncSubscriptions() {
if (!this._instance) return;
const pending = [...this._pendingSyncStatusSubscriptions];
this._pendingSyncStatusSubscriptions = [];
pending.forEach(callback => {
try {
this._instance!.onSyncStatus(callback);
} catch (e) {
// Still not ready, put it back
this._pendingSyncStatusSubscriptions.push(callback);
}
});
}
} }
const messageManager = new DefaultMessageManager(); const messageManager = new DefaultMessageManager();

View File

@ -20,6 +20,8 @@ export function useNetwork() {
statusMessage: network.statusMessage, statusMessage: network.statusMessage,
issues: network.issues, issues: network.issues,
isHydrated: network.isHydrated, isHydrated: network.isHydrated,
syncStatus: network.syncStatus,
syncDetail: network.syncDetail,
canRefresh: true, canRefresh: true,
refresh, refresh,
} as const; } as const;

View File

@ -103,6 +103,22 @@ export const StoreWiring: React.FC = () => {
})); }));
}); });
// Wire sync status
try {
client.messageManager.onSyncStatus((status, detail) => {
setOpchanState(prev => ({
...prev,
network: {
...prev.network,
syncStatus: status,
syncDetail: detail,
},
}));
});
} catch (e) {
// Reliable messaging not ready yet
}
unsubMessages = client.messageManager.onMessageReceived(async (message: OpchanMessage) => { unsubMessages = client.messageManager.onMessageReceived(async (message: OpchanMessage) => {
// Persist, then reflect cache in store // Persist, then reflect cache in store
try { try {

View File

@ -47,6 +47,8 @@ export interface NetworkSlice {
statusMessage: string; statusMessage: string;
issues: string[]; issues: string[];
isHydrated: boolean; isHydrated: boolean;
syncStatus: 'syncing' | 'synced' | 'unknown';
syncDetail: { received: number; missing: number; lost: number } | null;
} }
export interface OpchanState { export interface OpchanState {
@ -87,6 +89,8 @@ const defaultState: OpchanState = {
statusMessage: 'connecting…', statusMessage: 'connecting…',
issues: [], issues: [],
isHydrated: false, isHydrated: false,
syncStatus: 'unknown',
syncDetail: null,
}, },
}; };