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