hotfix jointlist

This commit is contained in:
Mateusz Gruszczyński
2026-09-12 13:48:04 +02:00
parent f37b19de53
commit 94abe403ab
5 changed files with 93 additions and 73 deletions
+41 -14
View File
@@ -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")
+5 -1
View File
@@ -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');
+43 -54
View File
@@ -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();
});
+2 -2
View File
@@ -565,6 +565,8 @@
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ static_asset_url('static_bp.serve_js_lib', 'Sortable.min.js') }}"></script>
<script>
@@ -586,5 +588,3 @@
</script>
<script src="{{ static_asset_url('static_bp.serve_js', 'list_item_form.js') }}"></script>
{% endblock %}
{% endblock %}
+2 -2
View File
@@ -374,6 +374,8 @@
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const isShare = document.getElementById('items').dataset.isShare === 'true';
@@ -396,5 +398,3 @@
</script>
<script src="{{ static_asset_url('static_bp.serve_js', 'list_item_form.js') }}"></script>
{% endblock %}
{% endblock %}