mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-08-03 23:42:31 +02:00
fix: refill the message gap left by a backgrounded app
The chat view only ever grew by socket push, so anything that arrived while the connection was down was never drawn. Android tears the connection down behind a locked screen, and the wrapper keeps the same page alive for days, so the list stopped at the last message that got through until the app was force-stopped. A browser tab hid the same bug by reloading the page on resume. Every way back from a gap now re-reads the list from the server: the socket reconnecting, the page becoming visible after more than a glance away, a heartbeat noticing its own tick arrived far too late (the page was frozen), a new Refresh item in the menu, and window.__mcAppResumed, which the wrapper calls from onResume since a WebView is not guaranteed to report the page as hidden at all. Direct messages get the same treatment. Verified in Chrome against the local container: all five triggers fire a resync, with no page errors and the list intact afterwards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "it.wojtaszek.mc.wrapper"
|
||||
minSdk = 21
|
||||
targetSdk = 34
|
||||
versionCode = 2
|
||||
versionName = "1.1"
|
||||
versionCode = 3
|
||||
versionName = "1.2"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
||||
@@ -106,6 +106,18 @@ class MainActivity : AppCompatActivity() {
|
||||
if (savedUrl.isNullOrEmpty()) showConfig(null) else connect(savedUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* The page keeps running while the app sits in the background - that is how
|
||||
* notifications keep arriving - but the connection behind it does not
|
||||
* survive doze, so the message list can be minutes behind by the time the
|
||||
* user looks at it again. A WebView is not guaranteed to tell the page it
|
||||
* was ever hidden, so say it here instead and let mc-webui catch up.
|
||||
*/
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
callJs("window.__mcAppResumed")
|
||||
}
|
||||
|
||||
/**
|
||||
* A notification tap on a running app lands here rather than in [onCreate].
|
||||
* When the app was not running, simply being launched is the whole point of
|
||||
|
||||
+126
-15
@@ -391,6 +391,86 @@ function isContactProtectedByName(senderName) {
|
||||
return pubkey && protectedContactPubkeys.has(pubkey.toLowerCase());
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Resync after a gap
|
||||
//
|
||||
// The chat view is push-driven: messages arrive over the socket and are
|
||||
// appended one at a time. Whatever the socket misses - Android doze tearing the
|
||||
// connection down behind a locked screen, a switch between Wi-Fi and mobile -
|
||||
// is never drawn, and the list silently stops at the last message that got
|
||||
// through. A browser tab hides this because it reloads the page on resume; the
|
||||
// Android wrapper keeps the same page alive for days, so the gap stays until
|
||||
// the app is force-stopped.
|
||||
//
|
||||
// So every way back from a gap ends up here: the socket reconnecting, the page
|
||||
// becoming visible, the heartbeat noticing it was frozen, the Refresh menu
|
||||
// item, and the wrapper's onResume hook.
|
||||
// =============================================================================
|
||||
|
||||
let chatSocketEverConnected = false;
|
||||
let resyncInFlight = false;
|
||||
|
||||
/** Re-read the message list and badges from the server. */
|
||||
async function resyncFromServer(reason, { toast = false } = {}) {
|
||||
if (resyncInFlight) return;
|
||||
resyncInFlight = true;
|
||||
console.log(`[resync] refreshing after ${reason}`);
|
||||
try {
|
||||
// The archive view is a frozen snapshot of one day - reloading it would
|
||||
// fight the date the user picked. Its badges still need updating.
|
||||
if (!currentArchiveDate) await loadMessages();
|
||||
await checkForUpdates();
|
||||
loadStatus();
|
||||
updatePendingContactsBadge();
|
||||
checkDmUpdates();
|
||||
if (toast) showNotification('Messages refreshed', 'success');
|
||||
} catch (error) {
|
||||
console.error('[resync] failed:', error);
|
||||
if (toast) showNotification('Refresh failed', 'danger');
|
||||
} finally {
|
||||
resyncInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Heartbeat that catches the cases no event announces.
|
||||
*
|
||||
* A tick arriving far later than scheduled means the page was frozen or
|
||||
* throttled - precisely the window in which socket events go missing - so the
|
||||
* gap is worth a resync even if the socket claims it never dropped. The same
|
||||
* tick nudges a socket still stuck in its reconnect backoff.
|
||||
*/
|
||||
function startResyncHeartbeat() {
|
||||
const TICK_MS = 20000;
|
||||
let lastTickAt = Date.now();
|
||||
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
const drift = now - lastTickAt;
|
||||
lastTickAt = now;
|
||||
|
||||
if (document.hidden) return; // visibilitychange covers the way back
|
||||
|
||||
if (drift > TICK_MS * 3) {
|
||||
resyncFromServer('timer gap');
|
||||
return;
|
||||
}
|
||||
// connect() during backoff simply retries now instead of later; the
|
||||
// 'connect' handler then does the resync
|
||||
if (chatSocket && !chatSocket.connected) chatSocket.connect();
|
||||
}, TICK_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the Android wrapper from onResume. The WebView is not guaranteed
|
||||
* to fire visibilitychange for an Activity coming back to the foreground, so
|
||||
* the wrapper says so itself.
|
||||
*/
|
||||
window.__mcAppResumed = function() {
|
||||
if (chatSocket && !chatSocket.connected) chatSocket.connect();
|
||||
resyncFromServer('app resumed');
|
||||
};
|
||||
|
||||
// Initialize on page load
|
||||
/**
|
||||
* Connect to SocketIO /chat namespace for real-time message updates
|
||||
@@ -412,6 +492,15 @@ function connectChatSocket() {
|
||||
|
||||
chatSocket.on('connect', () => {
|
||||
console.log('SocketIO connected to /chat');
|
||||
// Everything pushed while the socket was down is gone for good - only a
|
||||
// re-read of the list brings those messages back. Skipped on the very
|
||||
// first connect, where DOMContentLoaded has just loaded them anyway.
|
||||
if (chatSocketEverConnected) resyncFromServer('socket reconnect');
|
||||
chatSocketEverConnected = true;
|
||||
});
|
||||
|
||||
chatSocket.on('disconnect', (reason) => {
|
||||
console.warn('SocketIO /chat disconnected:', reason);
|
||||
});
|
||||
|
||||
chatSocket.on('connect_error', (err) => {
|
||||
@@ -582,6 +671,8 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
|
||||
// Connect SocketIO for real-time updates
|
||||
connectChatSocket();
|
||||
// Safety net for the updates the socket never delivered
|
||||
startResyncHeartbeat();
|
||||
|
||||
console.log(`[init] UI ready in ${(performance.now() - initStart).toFixed(0)}ms`);
|
||||
|
||||
@@ -609,23 +700,34 @@ window.addEventListener('pageshow', function(event) {
|
||||
});
|
||||
|
||||
// Handle app returning from background (PWA visibility change)
|
||||
let hiddenSince = null;
|
||||
document.addEventListener('visibilitychange', function() {
|
||||
if (!document.hidden) {
|
||||
// App became visible again, force viewport recalculation
|
||||
console.log('App became visible, recalculating viewport');
|
||||
setTimeout(() => {
|
||||
window.scrollTo(0, 0);
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
document.body.offsetHeight;
|
||||
}, 100);
|
||||
|
||||
// Clear app badge when user returns to app
|
||||
if ('clearAppBadge' in navigator) {
|
||||
navigator.clearAppBadge().catch((error) => {
|
||||
console.error('Error clearing app badge on visibility:', error);
|
||||
});
|
||||
}
|
||||
if (document.hidden) {
|
||||
hiddenSince = Date.now();
|
||||
return;
|
||||
}
|
||||
|
||||
// App became visible again, force viewport recalculation
|
||||
console.log('App became visible, recalculating viewport');
|
||||
setTimeout(() => {
|
||||
window.scrollTo(0, 0);
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
document.body.offsetHeight;
|
||||
}, 100);
|
||||
|
||||
// Clear app badge when user returns to app
|
||||
if ('clearAppBadge' in navigator) {
|
||||
navigator.clearAppBadge().catch((error) => {
|
||||
console.error('Error clearing app badge on visibility:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Anything longer than a glance away is long enough for the socket to have
|
||||
// dropped messages, so catch up before the user reads a stale list
|
||||
const away = hiddenSince ? Date.now() - hiddenSince : 0;
|
||||
hiddenSince = null;
|
||||
if (chatSocket && !chatSocket.connected) chatSocket.connect();
|
||||
if (away > 10000) resyncFromServer('back from background');
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -943,6 +1045,15 @@ function setupEventListeners() {
|
||||
inst.hide();
|
||||
});
|
||||
|
||||
// Manual refresh from the menu
|
||||
const refreshBtn = document.getElementById('refreshBtn');
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', () => {
|
||||
if (chatSocket && !chatSocket.connected) chatSocket.connect();
|
||||
resyncFromServer('manual refresh', { toast: true });
|
||||
});
|
||||
}
|
||||
|
||||
// Notification toggle
|
||||
const notificationsToggle = document.getElementById('notificationsToggle');
|
||||
if (notificationsToggle) {
|
||||
|
||||
+50
-10
@@ -114,6 +114,31 @@ function resolveConversationName(conversationId) {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
let chatSocketEverConnected = false;
|
||||
let resyncInFlight = false;
|
||||
|
||||
/**
|
||||
* Re-read the DM lists from the server.
|
||||
*
|
||||
* This view is push-driven, so a socket that drops while the phone sleeps
|
||||
* leaves it showing whatever arrived last - the 60s poll eventually catches up,
|
||||
* but only once its timer un-throttles. Every path back from a gap comes here.
|
||||
*/
|
||||
async function resyncFromServer(reason) {
|
||||
if (resyncInFlight) return;
|
||||
resyncInFlight = true;
|
||||
console.log(`DM: [resync] refreshing after ${reason}`);
|
||||
try {
|
||||
await loadConversations();
|
||||
if (currentConversationId) await loadMessages();
|
||||
await loadStatus();
|
||||
} catch (error) {
|
||||
console.error('DM: [resync] failed:', error);
|
||||
} finally {
|
||||
resyncInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to SocketIO /chat namespace for real-time DM and ACK updates
|
||||
*/
|
||||
@@ -134,10 +159,14 @@ function connectChatSocket() {
|
||||
|
||||
chatSocket.on('connect', () => {
|
||||
console.log('DM: SocketIO connected to /chat');
|
||||
// Whatever arrived while the socket was down was never pushed to us;
|
||||
// only re-reading the lists brings it back (see resyncFromServer)
|
||||
if (chatSocketEverConnected) resyncFromServer('socket reconnect');
|
||||
chatSocketEverConnected = true;
|
||||
});
|
||||
|
||||
chatSocket.on('disconnect', () => {
|
||||
console.log('DM: SocketIO disconnected');
|
||||
chatSocket.on('disconnect', (reason) => {
|
||||
console.log('DM: SocketIO disconnected:', reason);
|
||||
});
|
||||
|
||||
// Real-time new DM message
|
||||
@@ -330,16 +359,27 @@ window.addEventListener('pageshow', function(event) {
|
||||
});
|
||||
|
||||
// Handle app returning from background (PWA visibility change)
|
||||
let hiddenSince = null;
|
||||
document.addEventListener('visibilitychange', function() {
|
||||
if (!document.hidden) {
|
||||
// App became visible again, force viewport recalculation
|
||||
console.log('App became visible, recalculating viewport');
|
||||
setTimeout(() => {
|
||||
window.scrollTo(0, 0);
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
document.body.offsetHeight;
|
||||
}, 100);
|
||||
if (document.hidden) {
|
||||
hiddenSince = Date.now();
|
||||
return;
|
||||
}
|
||||
|
||||
// App became visible again, force viewport recalculation
|
||||
console.log('App became visible, recalculating viewport');
|
||||
setTimeout(() => {
|
||||
window.scrollTo(0, 0);
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
document.body.offsetHeight;
|
||||
}, 100);
|
||||
|
||||
// Long enough away for the socket to have dropped updates - catch up
|
||||
// rather than wait for the next 60s poll
|
||||
const away = hiddenSince ? Date.now() - hiddenSince : 0;
|
||||
hiddenSince = null;
|
||||
if (chatSocket && !chatSocket.connected) chatSocket.connect();
|
||||
if (away > 10000) resyncFromServer('back from background');
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -111,6 +111,16 @@
|
||||
<div class="offcanvas-body">
|
||||
<div class="list-group list-group-flush">
|
||||
<!-- Messages -->
|
||||
<!-- The Android wrapper has no pull-to-refresh of its own, so
|
||||
this is the manual way out of a list that fell behind -->
|
||||
<button id="refreshBtn" class="list-group-item list-group-item-action d-flex align-items-center gap-3" type="button"
|
||||
data-bs-dismiss="offcanvas" title="Reload messages from the server">
|
||||
<i class="bi bi-arrow-clockwise" style="font-size: 1.5rem;"></i>
|
||||
<div class="flex-grow-1">
|
||||
<div>Refresh</div>
|
||||
<small class="d-block text-muted">Reload messages from server</small>
|
||||
</div>
|
||||
</button>
|
||||
<button id="menu-filter" class="list-group-item list-group-item-action d-flex align-items-center gap-3 d-none" type="button">
|
||||
<i class="bi bi-funnel" style="font-size: 1.5rem;"></i>
|
||||
<div class="flex-grow-1">
|
||||
|
||||
+4
-1
@@ -10,7 +10,10 @@ For deep technical notes, see [architecture.md](architecture.md). For the full g
|
||||
|
||||
## Unreleased
|
||||
|
||||
_Nothing yet since 2.4.0._
|
||||
### Fixes
|
||||
|
||||
- **Messages no longer go missing after the app has been in the background.** Coming back to a minimised app — or to a phone that had been asleep — could show a chat that quietly stopped at whatever message arrived last before the screen went off, with everything since then missing until the app was force-stopped and reopened. New messages reach an open page over a live connection, and Android tears that connection down while the app sits in the background; nothing then went back to ask the server what had been missed. Now every way back from a gap re-reads the list: the connection coming back, the app returning to the foreground, and a heartbeat that notices when the page has been frozen. The same applies to direct messages, and to a browser tab that lost its network for a while.
|
||||
- **A Refresh item in the menu.** The browser's pull-to-refresh has no equivalent in the Android app, so there is now a **Refresh** entry at the top of the menu that reloads the messages from the server on demand — in the app, and everywhere else too.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user