From 94abe403ab013da307bc764115e174283d55e6e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sat, 12 Sep 2026 13:48:04 +0200 Subject: [PATCH] hotfix jointlist --- shopping_app/sockets.py | 55 +++++++++++---- shopping_app/static/js/live.js | 6 +- shopping_app/static/js/sockets.js | 97 ++++++++++++-------------- shopping_app/templates/list.html | 4 +- shopping_app/templates/list_share.html | 4 +- 5 files changed, 93 insertions(+), 73 deletions(-) diff --git a/shopping_app/sockets.py b/shopping_app/sockets.py index 5dfb77e..effbd8a 100644 --- a/shopping_app/sockets.py +++ b/shopping_app/sockets.py @@ -221,30 +221,57 @@ def handle_join(data): global active_users room = str(data["room"]) username = data.get("username", "Gość") + sid = request.sid + + # Trzymamy obecność per SID, a nie tylko per nazwa użytkownika. Dzięki temu + # ponowiony join z tego samego połączenia jest idempotentny, a dwie karty + # tego samego użytkownika nie psują statusu obecności przy disconnect. + room_users = active_users.setdefault(room, {}) + already_joined = sid in room_users + room_users[sid] = username join_room(room) - if room not in active_users: - active_users[room] = set() - active_users[room].add(username) - shopping_list = db.session.get(ShoppingList, int(data["room"])) - list_title = shopping_list.title if shopping_list else "Twoja lista" - emit("user_joined", {"username": username}, to=room) - emit("user_list", {"users": list(active_users[room])}, to=room) - emit("joined_confirmation", {"room": room, "list_title": list_title}) + if not already_joined: + # Nie pokazujemy użytkownikowi komunikatu, że sam właśnie dołączył. + emit("user_joined", {"username": username}, to=room, include_self=False) + emit( + "user_list", + {"users": sorted(set(room_users.values()))}, + to=room, + ) + + emit( + "joined_confirmation", + {"room": room, "list_title": list_title}, + to=sid, + ) + return {"ok": True, "room": room, "already_joined": already_joined} @socketio.on("disconnect") -def handle_disconnect(sid): +def handle_disconnect(_reason=None): global active_users - username = current_user.username if current_user.is_authenticated else "Gość" - for room, users in active_users.items(): - if username in users: - users.remove(username) + sid = request.sid + + for room, room_users in list(active_users.items()): + if not isinstance(room_users, dict) or sid not in room_users: + continue + + username = room_users.pop(sid) + remaining_users = sorted(set(room_users.values())) + + # Jeśli ten sam użytkownik ma drugą kartę/urządzenie w pokoju, + # nie ogłaszamy jego wyjścia dopóki ostatnie połączenie nie zniknie. + if username not in room_users.values(): emit("user_left", {"username": username}, to=room) - emit("user_list", {"users": list(users)}, to=room) + + emit("user_list", {"users": remaining_users}, to=room) + + if not room_users: + active_users.pop(room, None) @socketio.on("add_item") diff --git a/shopping_app/static/js/live.js b/shopping_app/static/js/live.js index 536d777..aae69cc 100644 --- a/shopping_app/static/js/live.js +++ b/shopping_app/static/js/live.js @@ -27,7 +27,11 @@ function setupList(listId, username) { window.usernameForReconnect = username; window.CURRENT_LIST_USERNAME = username; - socket.emit('join_list', { room: listId, username: username }); + // Jedno miejsce odpowiada za dołączanie do pokoju. Dzięki temu setupList() + // nie ściga się z eventem `connect` i nie wysyła podwójnego join_list. + if (typeof window.ensureCurrentListJoined === 'function') { + window.ensureCurrentListJoined(); + } const newItemInput = document.getElementById('newItem'); diff --git a/shopping_app/static/js/sockets.js b/shopping_app/static/js/sockets.js index a7f77d3..ce07d58 100644 --- a/shopping_app/static/js/sockets.js +++ b/shopping_app/static/js/sockets.js @@ -1,19 +1,17 @@ let didReceiveFirstFullList = false; -// Przeglądarki mobilne i desktopowe mogą zamrozić kartę lub zerwać transport -// bez natychmiastowej zmiany socket.connected. Po wznowieniu odświeżamy transport, -// ponownie dołączamy do pokoju i pobieramy aktualny stan listy. -const WAKE_RECONNECT_THRESHOLD_MS = 2500; +// Karta może zostać zamrożona lub transport może umrzeć bez natychmiastowej +// zmiany socket.connected. Po wznowieniu najpierw weryfikujemy połączenie ACK-em. +// Reconnect robimy dopiero wtedy, gdy transport faktycznie nie odpowiada. const WAKE_RECONNECT_DEBOUNCE_MS = 200; const WAKE_WATCHDOG_INTERVAL_MS = 10000; const WAKE_WATCHDOG_GAP_MS = 25000; -let pageHiddenAt = null; let recoveryTimer = null; let recoveryForceRequested = false; let recoveryInProgress = false; -let firstConnect = true; -let wasReconnected = false; let lastWatchdogTick = Date.now(); +let joinedSocketId = null; +let joinedRoomId = null; function hasActiveListRoom() { return Boolean(window.LIST_ID && window.usernameForReconnect); @@ -23,8 +21,6 @@ function disableCheckboxes(disable) { document.querySelectorAll('#items input[type="checkbox"]').forEach(cb => { if (disable) { // Zapamiętaj stan biznesowy tylko przy pierwszym nałożeniu blokady transportu. - // Dzięki temu checkboxy wyłączone np. przez archiwizację nie zostaną - // przypadkiem odblokowane po ponownej synchronizacji. if (cb.dataset.socketDisabledBefore === undefined) { cb.dataset.socketDisabledBefore = cb.disabled ? '1' : '0'; } @@ -39,14 +35,27 @@ function disableCheckboxes(disable) { }); } -function rejoinCurrentList() { - if (!socket.connected || !hasActiveListRoom()) return; +function ensureCurrentListJoined() { + if (!socket.connected || !hasActiveListRoom()) return false; + + const roomId = String(window.LIST_ID); + const socketId = socket.id || null; + + // join_list może przyjść zarówno z setupList(), jak i z eventu connect. + // Ten klucz sprawia, że dla jednego fizycznego połączenia wysyłamy go tylko raz. + if (joinedSocketId === socketId && joinedRoomId === roomId) return false; + + joinedSocketId = socketId; + joinedRoomId = roomId; socket.emit('join_list', { room: window.LIST_ID, username: window.usernameForReconnect }); + return true; } +window.ensureCurrentListJoined = ensureCurrentListJoined; + function requestCurrentList({ verify = false } = {}) { if (!socket.connected || !window.LIST_ID) return; @@ -64,6 +73,7 @@ function requestCurrentList({ verify = false } = {}) { function recoverListSocket({ force = false } = {}) { recoveryForceRequested = recoveryForceRequested || force; clearTimeout(recoveryTimer); + recoveryTimer = setTimeout(() => { const shouldForce = recoveryForceRequested; recoveryForceRequested = false; @@ -80,15 +90,18 @@ function recoverListSocket({ force = false } = {}) { if (recoveryInProgress) return; recoveryInProgress = true; disableCheckboxes(true); + joinedSocketId = null; + joinedRoomId = null; - // disconnect/connect czyści potencjalnie "martwy" transport po uśpieniu karty/procesu. + // Twardy reconnect tylko po realnym błędzie/timeout ACK, nie po samym + // przełączeniu karty lub powrocie aplikacji na pierwszy plan. socket.disconnect(); setTimeout(() => socket.connect(), 0); return; } if (hasActiveListRoom()) { - rejoinCurrentList(); + // Jeśli transport żyje, synchronizacja wystarczy. Timeout sam uruchomi reconnect. requestCurrentList({ verify: true }); } }, WAKE_RECONNECT_DEBOUNCE_MS); @@ -97,42 +110,26 @@ function recoverListSocket({ force = false } = {}) { // Udostępnione dla akcji w live.js (np. timeout checkboxa). window.recoverListSocket = recoverListSocket; -// Powrót po zablokowaniu telefonu / zmianie aplikacji. +// Powrót po zablokowaniu telefonu, zmianie aplikacji lub karty. document.addEventListener('visibilitychange', () => { - if (document.hidden) { - pageHiddenAt = Date.now(); - return; - } - - const hiddenFor = pageHiddenAt === null ? 0 : Date.now() - pageHiddenAt; - pageHiddenAt = null; - recoverListSocket({ force: hiddenFor >= WAKE_RECONNECT_THRESHOLD_MS }); + if (!document.hidden) recoverListSocket(); }); // BFCache może przywrócić stronę ze starym transportem Socket.IO. -window.addEventListener('pageshow', event => { - recoverListSocket({ force: Boolean(event.persisted) }); -}); - -window.addEventListener('pagehide', () => { - pageHiddenAt = Date.now(); +window.addEventListener('pageshow', () => { + recoverListSocket(); }); window.addEventListener('focus', () => { - recoverListSocket({ force: false }); + recoverListSocket(); }); window.addEventListener('online', () => { - recoverListSocket({ force: hasActiveListRoom() }); -}); - -// Dodatkowe lifecycle events obsługiwane przez część przeglądarek mobilnych. -document.addEventListener('freeze', () => { - pageHiddenAt = Date.now(); + recoverListSocket(); }); document.addEventListener('resume', () => { - recoverListSocket({ force: hasActiveListRoom() }); + recoverListSocket(); }); // Watchdog łapie także wznowienia, przy których przeglądarka nie wyśle poprawnie @@ -143,30 +140,24 @@ setInterval(() => { lastWatchdogTick = now; if (!document.hidden && gap >= WAKE_WATCHDOG_GAP_MS) { - recoverListSocket({ force: hasActiveListRoom() }); + recoverListSocket(); } }, WAKE_WATCHDOG_INTERVAL_MS); socket.on('connect', function () { - const isReconnect = !firstConnect; recoveryInProgress = false; + joinedSocketId = null; + joinedRoomId = null; if (hasActiveListRoom()) { - if (isReconnect) { - disableCheckboxes(true); - wasReconnected = true; - } - rejoinCurrentList(); - - // Nie polegamy wyłącznie na joined_confirmation — po wake-up lista ma się - // zsynchronizować nawet jeśli pojedyncze zdarzenie zaginie. - setTimeout(requestCurrentList, 250); + disableCheckboxes(true); + ensureCurrentListJoined(); } - - firstConnect = false; }); socket.on('disconnect', function () { + joinedSocketId = null; + joinedRoomId = null; if (window.LIST_ID) disableCheckboxes(true); }); @@ -175,11 +166,9 @@ socket.on('connect_error', function () { }); socket.off('joined_confirmation'); -socket.on('joined_confirmation', function (data) { - if (wasReconnected) { - showToast(`Lista: ${data.list_title} – ponownie dołączono.`, 'info'); - wasReconnected = false; - } +socket.on('joined_confirmation', function () { + // Potwierdzenie dołączenia nie jest komunikatem dla użytkownika. Po zwykłym + // reconnectcie karta ma wrócić do działania bez sztucznego "ponownie dołączono". requestCurrentList(); }); diff --git a/shopping_app/templates/list.html b/shopping_app/templates/list.html index efb61bb..05737f5 100644 --- a/shopping_app/templates/list.html +++ b/shopping_app/templates/list.html @@ -565,6 +565,8 @@ +{% endblock %} + {% block scripts %} {% endblock %} - -{% endblock %} \ No newline at end of file diff --git a/shopping_app/templates/list_share.html b/shopping_app/templates/list_share.html index 4ebef98..9fbd99a 100644 --- a/shopping_app/templates/list_share.html +++ b/shopping_app/templates/list_share.html @@ -374,6 +374,8 @@ +{% endblock %} + {% block scripts %} {% endblock %} - -{% endblock %} \ No newline at end of file