Async-Redis

 view release on metacpan or  search on metacpan

examples/pagi-chat/public/js/app.js  view on Meta::CPAN

        const toast = document.createElement('div');
        toast.className = `toast ${type}`;
        toast.textContent = message;

        elements.toastContainer.appendChild(toast);

        setTimeout(() => {
            toast.style.animation = 'slideIn 0.3s ease reverse';
            setTimeout(() => toast.remove(), 300);
        }, 3000);
    }

    // ===== Exponential Backoff =====
    function calculateReconnectDelay() {
        // Formula: min(1000 * 2^attempts + random(0, 1000), 30000)
        const baseDelay = 1000 * Math.pow(2, state.reconnectAttempts);
        const jitter = Math.random() * 1000;
        return Math.min(baseDelay + jitter, state.maxReconnectDelay);
    }

    // ===== Session Management =====
    function getOrCreateSessionId() {
        let sessionId = localStorage.getItem('chat-session-id');
        if (!sessionId) {
            sessionId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
            localStorage.setItem('chat-session-id', sessionId);
        }
        return sessionId;
    }

    function clearSession() {
        localStorage.removeItem('chat-session-id');
        state.sessionId = '';
        state.lastMsgId = 0;
    }

    // ===== WebSocket Connection =====
    function connectWebSocket() {
        const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';

        // Get or create persistent session ID
        state.sessionId = getOrCreateSessionId();

        // Build connection URL with session info for resume support
        const params = new URLSearchParams({
            name: state.username,
            session: state.sessionId,
            lastMsgId: state.lastMsgId.toString()
        });
        const wsUrl = `${protocol}//${window.location.host}/ws/chat?${params}`;

        setConnectionStatus(state.reconnectAttempts > 0 ? 'reconnecting' : 'connecting');

        state.ws = new WebSocket(wsUrl);

        state.ws.onopen = () => {
            setConnectionStatus('connected');
            state.reconnectAttempts = 0;
            state.lastPongTime = Date.now();

            // Start keepalive ping interval
            if (state.pingInterval) {
                clearInterval(state.pingInterval);
            }
            state.pingInterval = setInterval(() => {
                if (state.ws && state.ws.readyState === WebSocket.OPEN) {
                    sendMessage({ type: 'ping' });
                }
            }, state.pingIntervalMs);

            // Start heartbeat timeout check
            if (state.heartbeatCheckInterval) {
                clearInterval(state.heartbeatCheckInterval);
            }
            state.heartbeatCheckInterval = setInterval(() => {
                const timeSinceLastPong = Date.now() - state.lastPongTime;
                if (timeSinceLastPong > state.heartbeatTimeoutMs) {
                    console.warn('Heartbeat timeout - connection appears dead, reconnecting...');
                    if (state.ws) {
                        state.ws.close();
                    }
                }
            }, 5000); // Check every 5 seconds
        };

        state.ws.onclose = (event) => {
            setConnectionStatus('disconnected');

            // Clear intervals
            if (state.pingInterval) {
                clearInterval(state.pingInterval);
                state.pingInterval = null;
            }
            if (state.heartbeatCheckInterval) {
                clearInterval(state.heartbeatCheckInterval);
                state.heartbeatCheckInterval = null;
            }

            // Always try to reconnect with exponential backoff
            state.reconnectAttempts++;
            const delay = calculateReconnectDelay();
            console.log(`Reconnecting in ${Math.round(delay)}ms (attempt ${state.reconnectAttempts})...`);
            setConnectionStatus('reconnecting');
            setTimeout(connectWebSocket, delay);
        };

        state.ws.onerror = (error) => {
            console.error('WebSocket error:', error);
        };

        state.ws.onmessage = (event) => {
            // Any message resets the heartbeat timer
            state.lastPongTime = Date.now();

            try {
                const data = JSON.parse(event.data);
                handleWebSocketMessage(data);
            } catch (e) {
                console.error('Failed to parse message:', e);
            }
        };

examples/pagi-chat/public/js/app.js  view on Meta::CPAN

                }
                updateRoomsList();
                break;

            case 'user_left':
                if (data.room === state.currentRoom) {
                    addSystemMessage(`${data.user} left the room`);
                    updateUsersList(data.users || []);
                }
                updateRoomsList();
                break;

            case 'typing':
                if (data.room === state.currentRoom) {
                    updateTypingIndicator(data.user, data.typing);
                }
                break;

            case 'pm':
                addPrivateMessage(data, 'received');
                showToast(`Private message from ${data.from}`, 'info');
                break;

            case 'pm_sent':
                addPrivateMessage({ from: 'You', to: data.to, text: data.text, ts: data.ts }, 'sent');
                break;

            case 'nick_changed':
                if (data.old_name === state.username) {
                    state.username = data.new_name;
                    updateUserInfo();
                    showToast(`You are now known as ${data.new_name}`, 'success');
                } else if (data.room === state.currentRoom) {
                    addSystemMessage(`${data.old_name} is now known as ${data.new_name}`);
                    updateUsersList(data.users || []);
                }
                break;

            case 'room_list':
                updateRoomsList(data.rooms);
                break;

            case 'user_list':
                if (data.room === state.currentRoom) {
                    updateUsersList(data.users);
                }
                break;

            case 'history':
                if (data.room === state.currentRoom) {
                    renderMessages(data.messages || []);
                }
                break;

            case 'error':
                showToast(data.message, 'error');
                break;

            case 'pong':
            case 'server_ping':
                // Keepalive messages - no action needed
                break;

            case 'stats':
                updateStats(data);
                break;
        }
    }

    function sendMessage(data) {
        if (state.ws && state.ws.readyState === WebSocket.OPEN) {
            state.ws.send(JSON.stringify(data));
        }
    }

    // ===== SSE Connection =====
    function connectSSE() {
        state.sse = new EventSource('/events');

        state.sse.addEventListener('stats', (event) => {
            try {
                const stats = JSON.parse(event.data);
                updateStats(stats);
            } catch (e) {
                console.error('Failed to parse stats:', e);
            }
        });

        state.sse.addEventListener('user_connected', (event) => {
            try {
                const data = JSON.parse(event.data);
                elements.statUsers.textContent = data.count;
            } catch (e) {}
        });

        state.sse.addEventListener('user_disconnected', (event) => {
            try {
                const data = JSON.parse(event.data);
                elements.statUsers.textContent = data.count;
            } catch (e) {}
        });

        state.sse.onerror = () => {
            // SSE will auto-reconnect
        };
    }

    function updateStats(stats) {
        elements.statUsers.textContent = stats.users_online;
        elements.statRooms.textContent = stats.rooms_count;
        elements.statUptime.textContent = formatUptime(stats.uptime);
    }

    function formatUptime(seconds) {
        if (seconds < 60) return `${seconds}s`;
        if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
        if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
        return `${Math.floor(seconds / 86400)}d`;
    }

    // ===== UI Updates =====

examples/pagi-chat/public/js/app.js  view on Meta::CPAN


    function formatMessageText(text) {
        // Escape HTML first
        text = escapeHtml(text);

        // Convert newlines to <br> for multiline messages (like /help output)
        text = text.replace(/\n/g, '<br>');

        // Simple URL detection
        text = text.replace(
            /(https?:\/\/[^\s<]+)/g,
            '<a href="$1" target="_blank" rel="noopener">$1</a>'
        );

        return text;
    }

    function escapeHtml(text) {
        if (!text) return '';
        const div = document.createElement('div');
        div.textContent = text;
        return div.innerHTML;
    }

    // ===== Typing Indicator =====
    function handleTyping() {
        if (!state.isTyping) {
            state.isTyping = true;
            sendMessage({
                type: 'typing',
                room: state.currentRoom,
                typing: true
            });
        }

        // Clear existing timeout
        if (state.typingTimeout) {
            clearTimeout(state.typingTimeout);
        }

        // Stop typing after 2 seconds of inactivity
        state.typingTimeout = setTimeout(() => {
            state.isTyping = false;
            sendMessage({
                type: 'typing',
                room: state.currentRoom,
                typing: false
            });
        }, 2000);
    }

    // ===== Event Handlers =====
    function initEventHandlers() {
        // Theme toggle
        elements.themeToggle.addEventListener('click', toggleTheme);

        // Visibility change - send ping when tab becomes visible
        // This helps prevent disconnects from browser throttling background tabs
        document.addEventListener('visibilitychange', () => {
            if (document.visibilityState === 'visible') {
                // Tab became visible - send immediate ping to keep connection alive
                if (state.ws && state.ws.readyState === WebSocket.OPEN) {
                    sendMessage({ type: 'ping' });
                }
            }
        });

        // Login form
        elements.loginForm.addEventListener('submit', (e) => {
            e.preventDefault();
            const username = elements.usernameInput.value.trim();
            if (username) {
                state.username = username;
                elements.loginScreen.classList.add('hidden');
                elements.chatScreen.classList.remove('hidden');
                connectWebSocket();
                // connectSSE();  // SSE not implemented - stats update via WebSocket
            }
        });

        // Message form
        elements.messageForm.addEventListener('submit', (e) => {
            e.preventDefault();
            const text = elements.messageInput.value.trim();
            if (text) {
                sendMessage({
                    type: 'message',
                    room: state.currentRoom,
                    text: text
                });
                elements.messageInput.value = '';

                // Stop typing indicator
                if (state.typingTimeout) {
                    clearTimeout(state.typingTimeout);
                }
                state.isTyping = false;
            }
        });

        // Typing detection
        elements.messageInput.addEventListener('input', handleTyping);

        // Leave room button
        elements.leaveRoomBtn.addEventListener('click', () => {
            sendMessage({ type: 'leave', room: state.currentRoom });
        });

        // Create room button
        elements.createRoomBtn.addEventListener('click', () => {
            elements.createRoomModal.classList.remove('hidden');
            elements.roomNameInput.focus();
        });

        // Cancel create room
        elements.cancelCreateRoom.addEventListener('click', () => {
            elements.createRoomModal.classList.add('hidden');
            elements.roomNameInput.value = '';
        });

        // Modal backdrop click



( run in 0.888 second using v1.01-cache-2.11-cpan-df04353d9ac )