Implement robust reconnection system, updates, optimize typing

This commit is contained in:
2025-11-25 16:29:37 +03:00
Unverified
parent 7d254b5658
commit 857365361d
10 changed files with 892 additions and 204 deletions
+126
View File
@@ -0,0 +1,126 @@
/**
* @fileoverview Update Manager for Telegram-like update system
* @description Handles update sequence numbers, batching, and gap detection
* @author Cursor
* @version 1.0.0
*/
import { openDB, type IDBPDatabase } from "idb";
import type { WebSocketCredentials, WebSocketMessage } from "./types";
interface UpdateMessage<T = any> {
type: string;
data: T;
}
interface BatchedUpdatesMessage {
type: "updates";
seq: number;
updates: UpdateMessage[];
}
const DB_NAME = "fromchat-updates";
const DB_VERSION = 1;
const STORE_NAME = "lastSequence";
let db: IDBPDatabase | null = null;
/**
* Initialize IndexedDB for storing last sequence number
*/
async function initDB(): Promise<IDBPDatabase> {
if (db) return db;
db = await openDB(DB_NAME, DB_VERSION, {
upgrade(database) {
if (!database.objectStoreNames.contains(STORE_NAME)) {
database.createObjectStore(STORE_NAME);
}
}
});
return db;
}
/**
* Get the last received sequence number from IndexedDB
*/
export async function getLastSequence(): Promise<number> {
try {
return (await initDB())
.transaction(STORE_NAME, "readonly")
.objectStore(STORE_NAME)
.get("lastSeq") || 0;
} catch (error) {
console.error("Failed to get last sequence:", error);
return 0;
}
}
/**
* Store the last received sequence number in IndexedDB
*/
export async function setLastSequence(seq: number): Promise<void> {
try {
(await initDB()).transaction(STORE_NAME, "readwrite").objectStore(STORE_NAME).put(seq, "lastSeq");
} catch (error) {
console.error("Failed to set last sequence:", error);
}
}
/**
* Process a batched updates message
* @param message - The batched updates message from the server
* @param handler - Function to handle individual updates
* @param requestMissedFn - Optional function to request missed updates (for gap detection)
*/
export async function processBatchedUpdates(
message: BatchedUpdatesMessage,
handler: (update: UpdateMessage) => void,
requestMissedFn?: (lastSeq: number) => Promise<void>
): Promise<void> {
const { seq, updates } = message;
const lastSeq = await getLastSequence();
// Check for gap
if (seq !== lastSeq + 1 && lastSeq > 0) {
console.warn(`Update gap detected: expected ${lastSeq + 1}, got ${seq}`);
// Request missing updates if function provided
if (requestMissedFn) {
try {
await requestMissedFn(lastSeq);
} catch (error) {
console.error("Failed to request missed updates for gap:", error);
}
}
}
// Process all updates in the batch
for (const update of updates) {
handler(update);
}
// Update last sequence number
await setLastSequence(seq);
}
/**
* Request missed updates from the server
* @param lastSeq - The last sequence number we received
* @param requestFn - Function to send the request to the server
* @param credentials - Optional WebSocket credentials for authentication
*/
export async function requestMissedUpdates(
lastSeq: number,
requestFn: (request: WebSocketMessage<{ lastSeq: number }>) => Promise<void>,
credentials?: WebSocketCredentials
): Promise<void> {
if (lastSeq > 0) {
await requestFn({
type: "getUpdates",
data: { lastSeq },
credentials
});
}
}
+98 -32
View File
@@ -12,6 +12,8 @@ import { CallSignalingHandler } from "./calls/signaling";
import { onlineStatusManager } from "./onlineStatusManager";
import { typingManager } from "./typingManager";
import { useUserStore } from "@/state/user";
import { getLastSequence, processBatchedUpdates, requestMissedUpdates } from "./updateManager";
import { getAuthToken } from "@/core/api/user/auth";
/**
* Creates a new WebSocket connection to the chat server
@@ -148,55 +150,119 @@ async function reconnect(): Promise<void> {
*/
function setupEventHandlers(): void {
// Message handler
messageHandler = (e: MessageEvent) => {
messageHandler = async (e: MessageEvent) => {
try {
const response: WebSocketMessage<any> = JSON.parse(e.data);
// Handle batched updates
if (response.type === "updates" && "seq" in response && "updates" in response) {
// Create function to request missed updates with credentials
const token = getAuthToken();
const requestMissedFn = token ? async (lastSeq: number) => {
await requestMissedUpdates(lastSeq, async (req) => {
await request(req);
}, {
scheme: "Bearer",
credentials: token
});
} : undefined;
await processBatchedUpdates(response as any, (update) => {
// Route individual updates to appropriate handlers
handleUpdate(update);
}, requestMissedFn);
return;
}
// Handle call signaling messages
if (callSignalingHandler && response.type === "call_signaling" && response.data) {
callSignalingHandler.handleWebSocketMessage(response.data);
}
// Handle status and typing messages
if (response.type === "statusUpdate") {
onlineStatusManager.handleStatusUpdate(response as any);
} else if (response.type === "typing") {
typingManager.handleTyping(response as any);
} else if (response.type === "stopTyping") {
typingManager.handleStopTyping(response as any);
} else if (response.type === "dmTyping") {
typingManager.handleDmTyping(response as any);
} else if (response.type === "stopDmTyping") {
typingManager.handleStopDmTyping(response as any);
} else if (response.type === "suspended") {
// Handle account suspension
const { setSuspended } = useUserStore.getState();
const reason = response.data?.reason || "No reason provided";
setSuspended(reason);
// Close WebSocket connection
websocket.close();
} else if (response.type === "account_deleted") {
// Handle account deletion - silent logout
const { logout } = useUserStore.getState();
logout();
// Close WebSocket connection
websocket.close();
}
// Route message to global handler if set
if (globalMessageHandler) {
globalMessageHandler(response);
}
// Handle status and typing messages (these may come as immediate messages or in batches)
handleUpdate(response);
} catch (error) {
console.error("Error parsing WebSocket message:", error);
}
};
// Helper function to handle individual updates
function handleUpdate(response: WebSocketMessage<any>): void {
if (response.type === "statusUpdate") {
onlineStatusManager.handleStatusUpdate(response as any);
} else if (response.type === "typing") {
typingManager.handleTyping(response as any);
} else if (response.type === "stopTyping") {
typingManager.handleStopTyping(response as any);
} else if (response.type === "dmTyping") {
typingManager.handleDmTyping(response as any);
} else if (response.type === "stopDmTyping") {
typingManager.handleStopDmTyping(response as any);
} else if (response.type === "suspended") {
// Handle account suspension
const { setSuspended } = useUserStore.getState();
const reason = response.data?.reason || "No reason provided";
setSuspended(reason);
// Close WebSocket connection
websocket.close();
} else if (response.type === "account_deleted") {
// Handle account deletion - silent logout
const { logout } = useUserStore.getState();
logout();
// Close WebSocket connection
websocket.close();
}
// Route message to global handler if set
if (globalMessageHandler) {
globalMessageHandler(response);
}
}
websocket.addEventListener("message", messageHandler);
// Open handler
openHandler = () => {
openHandler = async () => {
reconnectAttempts = 0; // Reset on successful connection
isReconnecting = false;
// Authenticate by sending ping with credentials and request missed updates
try {
const token = getAuthToken();
if (token) {
const credentials = {
scheme: "Bearer",
credentials: token
};
// Send ping to authenticate and set user_by_ws on the server
try {
await request({
type: "ping",
credentials,
data: {}
});
} catch (error) {
console.error("Failed to send ping on reconnect:", error);
}
// Send last sequence number and request missed updates on reconnect
// Wait a bit for ping to complete authentication
await delay(100);
try {
const lastSeq = await getLastSequence();
if (lastSeq > 0) {
await requestMissedUpdates(lastSeq, async (req) => {
await request(req);
}, credentials);
}
} catch (error) {
console.error("Failed to request missed updates:", error);
}
}
} catch (error) {
console.error("Failed to authenticate on reconnect:", error);
}
};
websocket.addEventListener("open", openHandler);
@@ -58,6 +58,8 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
const [panelState, setPanelState] = useState<MessagePanelState | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const previousMessageCountRef = useRef(0);
const messagesContainerRef = useRef<HTMLElement | null>(null);
const isLoadingMoreRef = useRef(false);
const [replyTo, setReplyTo] = useState<Message | null>(null);
const [replyToVisible, setReplyToVisible] = useState(Boolean(replyTo));
const [editMessage, setEditMessage] = useState<Message | null>(null);
@@ -92,6 +94,50 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
}
}, [editMessage]);
// Handle scroll detection for infinite loading
useEffect(() => {
if (!panel || !panelState) return;
const messagesContainer = document.getElementById("chat-messages");
if (!messagesContainer) return;
messagesContainerRef.current = messagesContainer;
const handleScroll = async () => {
if (!panel || !panelState || isLoadingMoreRef.current) return;
const container = messagesContainerRef.current;
if (!container) return;
// Check if scrolled to top (within 100px threshold)
if (container.scrollTop <= 100 && panelState.hasMoreMessages && !panelState.isLoadingMore) {
isLoadingMoreRef.current = true;
const previousScrollHeight = container.scrollHeight;
try {
await panel.loadMoreMessages();
// Preserve scroll position after loading
requestAnimationFrame(() => {
if (container) {
const newScrollHeight = container.scrollHeight;
container.scrollTop = newScrollHeight - previousScrollHeight;
}
isLoadingMoreRef.current = false;
});
} catch (error) {
console.error("Error loading more messages:", error);
isLoadingMoreRef.current = false;
}
}
};
messagesContainer.addEventListener("scroll", handleScroll);
return () => {
messagesContainer.removeEventListener("scroll", handleScroll);
};
}, [panel, panelState]);
// Handle panel state changes
useEffect(() => {
if (panel) {
@@ -280,31 +326,43 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
</div>
</div>
) : panelState && panel ? (
<ChatMessages
messages={panelState.messages}
isDm={panel.isDm()}
dmRecipientPublicKey={(panel as DMPanel).dmData?.publicKey}
onReplySelect={(message) => {
if (editMessage || editVisible) {
setPendingAction({ type: "reply", message: message });
setEditVisible(false); // onCloseEdit will apply pending
} else {
setReplyTo(message);
}
}}
onEditSelect={(message) => {
if (replyTo || replyToVisible) {
setPendingAction({ type: "edit", message: message });
setReplyToVisible(false); // onCloseReply will apply pending
} else {
setEditMessage(message);
}
}}
onDelete={(id) => panel.handleDeleteMessage(id)}
onRetryMessage={(id) => panel.retryMessage(id)}
>
<div ref={messagesEndRef} />
</ChatMessages>
<>
{panelState.isLoadingMore && (
<div style={{
display: "flex",
justifyContent: "center",
padding: "8px",
color: "var(--mdui-color-on-surface-variant)"
}}>
Загрузка...
</div>
)}
<ChatMessages
messages={panelState.messages}
isDm={panel.isDm()}
dmRecipientPublicKey={(panel as DMPanel).dmData?.publicKey}
onReplySelect={(message) => {
if (editMessage || editVisible) {
setPendingAction({ type: "reply", message: message });
setEditVisible(false); // onCloseEdit will apply pending
} else {
setReplyTo(message);
}
}}
onEditSelect={(message) => {
if (replyTo || replyToVisible) {
setPendingAction({ type: "edit", message: message });
setReplyToVisible(false); // onCloseReply will apply pending
} else {
setEditMessage(message);
}
}}
onDelete={(id) => panel.handleDeleteMessage(id)}
onRetryMessage={(id) => panel.retryMessage(id)}
>
<div ref={messagesEndRef} />
</ChatMessages>
</>
) : (
<div className={rightPanelStyles.chatMessages} id="chat-messages">
<div style={{
@@ -103,7 +103,8 @@ export class DMPanel extends MessagePanel {
this.setLoading(true);
try {
const { messages } = await api.chats.dm.fetchMessages(this.dmData.userId, this.currentUser.authToken, 50);
const limit = this.calculateMessageLimit();
const { messages, has_more } = await api.chats.dm.fetchMessages(this.dmData.userId, this.currentUser.authToken, limit);
const decryptedMessages: Message[] = [];
let maxIncomingId = 0;
@@ -122,6 +123,7 @@ export class DMPanel extends MessagePanel {
this.clearMessages();
decryptedMessages.forEach(msg => this.addMessage(msg));
this.setHasMoreMessages(has_more);
// Update last read ID
if (maxIncomingId > 0) {
@@ -135,6 +137,50 @@ export class DMPanel extends MessagePanel {
}
}
async loadMoreMessages(): Promise<void> {
if (!this.currentUser.authToken || !this.dmData || !this.state.hasMoreMessages || this.state.isLoadingMore) return;
const messages = this.getMessages();
if (messages.length === 0) return;
const oldestMessage = messages[0];
const oldestEnvelope = oldestMessage.runtimeData?.dmEnvelope;
if (!oldestEnvelope) return;
this.setLoadingMore(true);
try {
const limit = this.calculateMessageLimit();
const { messages: newEnvelopes, has_more } = await api.chats.dm.fetchMessages(
this.dmData.userId,
this.currentUser.authToken,
limit,
oldestEnvelope.id
);
if (newEnvelopes && newEnvelopes.length > 0) {
const decryptedMessages: Message[] = [];
for (const env of newEnvelopes) {
try {
const dmMsg = await this.parseTextPayload(env, decryptedMessages);
decryptedMessages.push(dmMsg);
} catch (error) {
console.error("Error decrypting message:", error);
}
}
// Prepend older messages (they come in reverse chronological order)
this.updateState({
messages: [...decryptedMessages.reverse(), ...messages]
});
}
this.setHasMoreMessages(has_more);
} catch (error) {
console.error("Failed to load more DM messages:", error);
} finally {
this.setLoadingMore(false);
}
}
protected async sendMessage(content: string, replyToId?: number, files: File[] = []): Promise<void> {
if (!this.currentUser.authToken || !this.dmData || !content.trim()) return;
@@ -9,6 +9,8 @@ export interface MessagePanelState {
messages: Message[];
isLoading: boolean;
isTyping: boolean;
hasMoreMessages: boolean;
isLoadingMore: boolean;
}
export interface MessagePanelCallbacks {
@@ -35,7 +37,9 @@ export abstract class MessagePanel {
online: false,
messages: [],
isLoading: false,
isTyping: false
isTyping: false,
hasMoreMessages: false,
isLoadingMore: false
};
this.currentUser = currentUser;
}
@@ -107,6 +111,27 @@ export abstract class MessagePanel {
this.updateState({ isTyping: typing });
}
protected setLoadingMore(loading: boolean): void {
this.updateState({ isLoadingMore: loading });
}
protected setHasMoreMessages(hasMore: boolean): void {
this.updateState({ hasMoreMessages: hasMore });
}
/**
* Calculate message limit based on viewport height (5x screen height)
*/
protected calculateMessageLimit(): number {
const viewportHeight = window.innerHeight;
return Math.ceil((viewportHeight * 5) / 100);
}
/**
* Load more messages (to be implemented by subclasses)
*/
abstract loadMoreMessages(): Promise<void>;
// Getters
getState(): MessagePanelState {
return { ...this.state };
@@ -41,13 +41,15 @@ export class PublicChatPanel extends MessagePanel {
this.setLoading(true);
try {
const { messages } = await api.chats.general.fetchMessages(this.currentUser.authToken);
const limit = this.calculateMessageLimit();
const { messages, has_more } = await api.chats.general.fetchMessages(this.currentUser.authToken, limit);
if (messages && messages.length > 0) {
this.clearMessages();
messages.forEach((msg: Message) => {
this.addMessage(msg);
});
}
this.setHasMoreMessages(has_more);
this.messagesLoaded = true;
} catch (error) {
console.error("Error loading public chat messages:", error);
@@ -56,6 +58,35 @@ export class PublicChatPanel extends MessagePanel {
}
}
async loadMoreMessages(): Promise<void> {
if (!this.currentUser.authToken || !this.state.hasMoreMessages || this.state.isLoadingMore) return;
const messages = this.getMessages();
if (messages.length === 0) return;
const oldestMessage = messages[0];
this.setLoadingMore(true);
try {
const limit = this.calculateMessageLimit();
const { messages: newMessages, has_more } = await api.chats.general.fetchMessages(
this.currentUser.authToken,
limit,
oldestMessage.id
);
if (newMessages && newMessages.length > 0) {
// Prepend older messages (they come in reverse chronological order)
this.updateState({
messages: [...newMessages.reverse(), ...messages]
});
}
this.setHasMoreMessages(has_more);
} catch (error) {
console.error("Error loading more public chat messages:", error);
} finally {
this.setLoadingMore(false);
}
}
protected async sendMessage(content: string, replyToId?: number, files: File[] = []): Promise<void> {
if (!this.currentUser.authToken || !content.trim()) return;
+4 -21
View File
@@ -1,6 +1,5 @@
import { create } from "zustand";
import type { User } from "@/core/types";
import { request } from "@/core/websocket";
import api from "@/core/api";
import { API_BASE_URL } from "@/core/config";
import { initialize, subscribe, startElectronReceiver, isSupported } from "@/core/push-notifications/push-notifications";
@@ -44,16 +43,8 @@ export const useUserStore = create<UserStore>((set) => ({
console.error('Failed to store credentials in localStorage:', error);
}
try {
request({
type: "ping",
credentials: {
scheme: "Bearer",
credentials: token
},
data: {}
})
} catch {}
// Ping will be sent automatically on WebSocket reconnect
// No need to send here to avoid duplicate pings
},
logout: () => {
try {
@@ -113,16 +104,8 @@ export const useUserStore = create<UserStore>((set) => ({
onlineStatusManager.setAuthToken(token);
typingManager.setAuthToken(token);
try {
request({
type: "ping",
credentials: {
scheme: "Bearer",
credentials: token
},
data: {}
})
} catch {}
// Ping will be sent automatically on WebSocket reconnect
// No need to send here to avoid duplicate pings
try {
if (isSupported()) {