mirror of
https://github.com/logos-messaging/OpChan.git
synced 2026-01-04 05:43:10 +00:00
feat: add sync status for messages
This commit is contained in:
parent
78ff8b537b
commit
bf7b3f20a1
@ -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',
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user