mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-08 02:13:30 +00:00
## Summary Reframes the browser's native pull-to-refresh on touch devices as a **WebSocket reconnect** instead of a full page reload. On data pages (Packets, Nodes, Channels — and globally, since the WS is shared) a downward pull at `scrollTop=0` cycles the WS, which is what users actually want when they reach for that gesture. Fixes #1063. ## Behavior - **Touch-only**: gated by `('ontouchstart' in window) || navigator.maxTouchPoints > 0`. Desktop is untouched. - **Scroll-safe**: every handler re-checks `scrollTop > 0` and bails out — never hijacks normal scroll. - **Visual affordance**: a fixed chip slides down from the top with a rotating ⟳ icon; opacity and rotation scale with pull progress (0 → `PULL_THRESHOLD_PX = 80px`). - **`preventDefault` is conservative**: only after `dy > 16px` and only on `touchmove`, so taps and short swipes are not affected. - **Result feedback**: a brief toast — green `Connected ✓` if WS was already OPEN, `Reconnecting…` otherwise. Both auto-dismiss after ~1.8s. - **Reconnect path**: closes the existing WS so the existing `onclose` auto-reconnect fires immediately; an explicit `connectWS()` is also called as a safety net when `ws` is null. - **No regression** to existing WS auto-reconnect — same `connectWS` / `setTimeout(connectWS, 3000)` chain, just kicked manually. ## TDD - **Red commit** `f90f5e9` — adds `test-pull-to-reconnect.js` with 6 assertions; stub functions added to `app.js` so tests reach assertion failures (not ReferenceError). 3/6 fail on behavior. - **Green commit** `53adbd9` — full implementation; 6/6 pass. ## Files - `public/app.js` — `pullReconnect()`, `setupPullToReconnect()`, `_ensurePullIndicator()`, `_showPullToast()`, `_isTouchDevice()`. Wired into `DOMContentLoaded` next to `connectWS()`. Touched the WS section only. - `test-pull-to-reconnect.js` — vm sandbox suite covering exposure, WS-close, listener wiring, threshold trigger, scroll-position gate. ## Acceptance criteria check - ✅ Pull-down at scroll-top triggers WS reconnect + data refetch (debounced cache invalidate fires on next WS message) - ✅ Visible affordance during pull (rotating chip) - ✅ Resolves on success (toast), shows status toast on disconnect path - ✅ Disabled when not at `scrollTop=0` - ✅ No regression to existing WS auto-reconnect --------- Co-authored-by: openclaw-bot <bot@openclaw.local> Co-authored-by: Kpa-clawbot <bot@kpa-clawbot>
This commit is contained in:
+143
@@ -501,6 +501,148 @@ function connectWS() {
|
||||
function onWS(fn) { wsListeners.push(fn); }
|
||||
function offWS(fn) { wsListeners = wsListeners.filter(f => f !== fn); }
|
||||
|
||||
// --- Pull-to-reconnect (#1063) ---
|
||||
// Touch-device pull-down at scrollTop=0 reconnects the WebSocket
|
||||
// (instead of triggering native pull-to-refresh full-page reload).
|
||||
// Visual indicator pulses during pull; toast confirms result.
|
||||
const PULL_THRESHOLD_PX = 80;
|
||||
let _pullToast = null;
|
||||
let _pullToastTimer = null;
|
||||
let _pullIndicator = null;
|
||||
|
||||
function _ensurePullIndicator() {
|
||||
if (_pullIndicator && document.body && typeof document.body.contains === 'function' && document.body.contains(_pullIndicator)) return _pullIndicator;
|
||||
if (_pullIndicator) return _pullIndicator;
|
||||
const el = document.createElement('div');
|
||||
el.id = 'pullReconnectIndicator';
|
||||
el.setAttribute('aria-hidden', 'true');
|
||||
el.innerHTML = '<span class="prr-icon">⟳</span>';
|
||||
el.style.cssText = [
|
||||
'position:fixed', 'top:0', 'left:50%', 'transform:translate(-50%,-100%)',
|
||||
'z-index:99999', 'padding:8px 14px', 'border-radius:0 0 12px 12px',
|
||||
'background:var(--accent,#2563eb)', 'color:#fff', 'font:14px/1 var(--font,system-ui)',
|
||||
'box-shadow:0 2px 8px rgba(0,0,0,.2)', 'pointer-events:none',
|
||||
'transition:transform .15s ease, opacity .15s ease', 'opacity:0',
|
||||
].join(';');
|
||||
document.body.appendChild(el);
|
||||
_pullIndicator = el;
|
||||
return el;
|
||||
}
|
||||
|
||||
function _showPullToast(msg, ok) {
|
||||
try {
|
||||
if (_pullToast && _pullToast.remove) _pullToast.remove();
|
||||
} catch (e) {}
|
||||
if (_pullToastTimer) { try { clearTimeout(_pullToastTimer); } catch (e) {} _pullToastTimer = null; }
|
||||
const el = document.createElement('div');
|
||||
el.className = 'pull-reconnect-toast';
|
||||
el.textContent = msg;
|
||||
el.style.cssText = [
|
||||
'position:fixed', 'top:12px', 'left:50%', 'transform:translateX(-50%)',
|
||||
'z-index:99999', 'padding:8px 16px', 'border-radius:8px',
|
||||
'background:' + (ok ? 'var(--status-green,#16a34a)' : 'var(--status-red,#dc2626)'),
|
||||
'color:#fff', 'font:14px/1.2 var(--font,system-ui)',
|
||||
'box-shadow:0 2px 8px rgba(0,0,0,.2)', 'pointer-events:none',
|
||||
].join(';');
|
||||
document.body.appendChild(el);
|
||||
_pullToast = el;
|
||||
_pullToastTimer = setTimeout(function () {
|
||||
_pullToastTimer = null;
|
||||
try { el.remove(); } catch (e) {}
|
||||
}, 1800);
|
||||
}
|
||||
|
||||
function pullReconnect() {
|
||||
// If WS is connected (readyState OPEN), give a brief "Connected ✓"
|
||||
// confirmation but still cycle so the user sees fresh data.
|
||||
const wasOpen = ws && ws.readyState === 1;
|
||||
if (wasOpen) {
|
||||
_showPullToast('Connected ✓', true);
|
||||
// Fast cycle: close and let onclose reconnect immediately
|
||||
try { ws.close(); } catch (e) {}
|
||||
} else {
|
||||
_showPullToast('Reconnecting…', true);
|
||||
try { if (ws) ws.close(); } catch (e) {}
|
||||
// onclose handler schedules reconnect; force one now in case ws was null
|
||||
try { connectWS(); } catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
function _isTouchDevice() {
|
||||
try {
|
||||
return ('ontouchstart' in window) ||
|
||||
(navigator && (navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0));
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
|
||||
function setupPullToReconnect() {
|
||||
// Always attach listeners (tests + future-proof). Inside the handler we
|
||||
// gate on _isTouchDevice() AND scrollTop=0 so desktop/scrolled pages are
|
||||
// unaffected.
|
||||
let startY = null;
|
||||
let pulling = false;
|
||||
let dist = 0;
|
||||
|
||||
function getScrollTop() {
|
||||
return (document.documentElement && document.documentElement.scrollTop) ||
|
||||
(document.body && document.body.scrollTop) || 0;
|
||||
}
|
||||
|
||||
function onStart(e) {
|
||||
if (!_isTouchDevice()) return;
|
||||
if (getScrollTop() > 0) { startY = null; pulling = false; return; }
|
||||
const t = e.touches && e.touches[0];
|
||||
startY = t ? t.clientY : null;
|
||||
pulling = false;
|
||||
dist = 0;
|
||||
}
|
||||
|
||||
function onMove(e) {
|
||||
if (startY == null) return;
|
||||
if (getScrollTop() > 0) { startY = null; pulling = false; return; }
|
||||
const t = e.touches && e.touches[0];
|
||||
if (!t) return;
|
||||
const dy = t.clientY - startY;
|
||||
if (dy <= 0) return; // upward swipe — ignore
|
||||
dist = dy;
|
||||
if (dy > 8) {
|
||||
pulling = true;
|
||||
const ind = _ensurePullIndicator();
|
||||
const pct = Math.min(1, dy / PULL_THRESHOLD_PX);
|
||||
ind.style.opacity = String(pct);
|
||||
ind.style.transform = 'translate(-50%, ' + (-100 + pct * 100) + '%)';
|
||||
const icon = ind.querySelector && ind.querySelector('.prr-icon');
|
||||
if (icon) icon.style.transform = 'rotate(' + Math.round(pct * 360) + 'deg)';
|
||||
// Prevent native pull-to-refresh ONLY once we've committed to the gesture
|
||||
if (dy > 16 && typeof e.preventDefault === 'function' && e.cancelable !== false) {
|
||||
try { e.preventDefault(); } catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onEnd() {
|
||||
const wasPulling = pulling;
|
||||
const finalDist = dist;
|
||||
startY = null; pulling = false; dist = 0;
|
||||
if (_pullIndicator) {
|
||||
_pullIndicator.style.opacity = '0';
|
||||
_pullIndicator.style.transform = 'translate(-50%, -100%)';
|
||||
}
|
||||
if (wasPulling && finalDist >= PULL_THRESHOLD_PX) {
|
||||
try { (window.pullReconnect || pullReconnect)(); } catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('touchstart', onStart, { passive: true });
|
||||
document.addEventListener('touchmove', onMove, { passive: false });
|
||||
document.addEventListener('touchend', onEnd, { passive: true });
|
||||
document.addEventListener('touchcancel', onEnd, { passive: true });
|
||||
}
|
||||
|
||||
window.pullReconnect = pullReconnect;
|
||||
window.setupPullToReconnect = setupPullToReconnect;
|
||||
window.connectWS = connectWS;
|
||||
|
||||
/* Global escapeHtml — used by multiple pages */
|
||||
function escapeHtml(s) {
|
||||
if (s == null) return '';
|
||||
@@ -676,6 +818,7 @@ window.addEventListener('timestamp-mode-changed', () => {
|
||||
});
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
connectWS();
|
||||
setupPullToReconnect();
|
||||
|
||||
// --- Dark Mode ---
|
||||
const darkToggle = document.getElementById('darkModeToggle');
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
/* test-pull-to-reconnect.js — behavioral tests for pull-to-reconnect (#1063)
|
||||
* Loads app.js in a vm sandbox, stubs WebSocket + DOM, asserts that:
|
||||
* - pullReconnect() exists as a global helper
|
||||
* - calling it closes the existing WS (which triggers the existing
|
||||
* auto-reconnect path)
|
||||
* - setupPullToReconnect() exists and wires touchstart/touchmove/touchend
|
||||
* listeners on the document
|
||||
* - a pull-down gesture at scrollTop=0 over the threshold triggers
|
||||
* pullReconnect
|
||||
* - a touch when scrollTop > 0 does NOT trigger pullReconnect (don't
|
||||
* hijack normal scrolling)
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
const assert = require('assert');
|
||||
|
||||
console.log('--- test-pull-to-reconnect.js ---');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function test(name, fn) {
|
||||
try { fn(); passed++; console.log(` ✅ ${name}`); }
|
||||
catch (e) { failed++; console.log(` ❌ ${name}: ${e.message}\n ${e.stack.split('\n').slice(1, 3).join('\n ')}`); }
|
||||
}
|
||||
|
||||
function makeSandbox(opts) {
|
||||
opts = opts || {};
|
||||
const listeners = {}; // event name -> [fn]
|
||||
const elements = {};
|
||||
function makeEl(id) {
|
||||
const el = {
|
||||
id, textContent: '', innerHTML: '', value: '',
|
||||
style: {}, dataset: {},
|
||||
_classes: new Set(),
|
||||
classList: {
|
||||
add: function() { for (const a of arguments) el._classes.add(a); },
|
||||
remove: function() { for (const a of arguments) el._classes.delete(a); },
|
||||
toggle: function(c) { if (el._classes.has(c)) el._classes.delete(c); else el._classes.add(c); },
|
||||
contains: function(c) { return el._classes.has(c); },
|
||||
},
|
||||
addEventListener: function(ev, fn) { (el['_on_' + ev] = el['_on_' + ev] || []).push(fn); },
|
||||
removeEventListener: function() {},
|
||||
setAttribute: function() {}, getAttribute: function() { return null; },
|
||||
appendChild: function(child) { (el._children = el._children || []).push(child); return child; },
|
||||
remove: function() {},
|
||||
querySelector: function() { return null; },
|
||||
querySelectorAll: function() { return []; },
|
||||
};
|
||||
elements[id] = el;
|
||||
return el;
|
||||
}
|
||||
|
||||
// Pre-create elements app.js touches at WS time
|
||||
makeEl('liveDot');
|
||||
|
||||
// Stub WebSocket — track instances + close calls
|
||||
const wsInstances = [];
|
||||
function FakeWS(url) {
|
||||
this.url = url;
|
||||
this.readyState = 1; // OPEN
|
||||
this.closed = false;
|
||||
this.onopen = null; this.onclose = null; this.onerror = null; this.onmessage = null;
|
||||
wsInstances.push(this);
|
||||
// simulate immediate open so onopen fires synchronously isn't required;
|
||||
// tests will invoke handlers directly when needed.
|
||||
}
|
||||
FakeWS.prototype.close = function() {
|
||||
this.closed = true;
|
||||
if (typeof this.onclose === 'function') this.onclose({});
|
||||
};
|
||||
FakeWS.prototype.send = function() {};
|
||||
|
||||
const body = makeEl('body');
|
||||
|
||||
const ctx = {
|
||||
console,
|
||||
setTimeout: function(fn, ms) { return 0; }, // suppress reconnect loop
|
||||
clearTimeout: function() {},
|
||||
setInterval: function() { return 0; },
|
||||
clearInterval: function() {},
|
||||
Date, Math, JSON, Object, Array, String, Number, Boolean,
|
||||
Error, RegExp, Map, Set, Symbol, Promise,
|
||||
requestAnimationFrame: function(fn) { return 0; },
|
||||
performance: { now: function() { return 0; } },
|
||||
location: { protocol: 'http:', host: 'localhost', hash: '' },
|
||||
navigator: { userAgent: 'test' },
|
||||
WebSocket: FakeWS,
|
||||
fetch: function() { return Promise.resolve({ ok: true, json: function() { return Promise.resolve({}); } }); },
|
||||
localStorage: {
|
||||
_data: {},
|
||||
getItem: function(k) { return this._data[k] || null; },
|
||||
setItem: function(k, v) { this._data[k] = String(v); },
|
||||
removeItem: function(k) { delete this._data[k]; },
|
||||
},
|
||||
document: {
|
||||
readyState: 'complete',
|
||||
documentElement: { scrollTop: opts.scrollTop || 0, style: { setProperty: function() {} }, setAttribute: function() {}, getAttribute: function() { return null; } },
|
||||
body: body,
|
||||
head: { appendChild: function() {} },
|
||||
createElement: function(tag) { return makeEl(tag); },
|
||||
getElementById: function(id) { return elements[id] || null; },
|
||||
querySelector: function() { return null; },
|
||||
querySelectorAll: function() { return []; },
|
||||
addEventListener: function(ev, fn) { (listeners[ev] = listeners[ev] || []).push(fn); },
|
||||
removeEventListener: function() {},
|
||||
dispatchEvent: function(e) { (listeners[e.type] || []).forEach(function(fn) { fn(e); }); return true; },
|
||||
},
|
||||
window: {
|
||||
addEventListener: function() {}, removeEventListener: function() {}, dispatchEvent: function() {},
|
||||
matchMedia: function() { return { matches: false, addEventListener: function() {} }; },
|
||||
ontouchstart: opts.touch === false ? undefined : null,
|
||||
},
|
||||
CustomEvent: function(type, init) { this.type = type; this.detail = (init || {}).detail; },
|
||||
};
|
||||
ctx.window.location = ctx.location;
|
||||
ctx.window.localStorage = ctx.localStorage;
|
||||
ctx.window.document = ctx.document;
|
||||
ctx.self = ctx.window;
|
||||
ctx.globalThis = ctx;
|
||||
|
||||
vm.createContext(ctx);
|
||||
return { ctx, elements, wsInstances, listeners };
|
||||
}
|
||||
|
||||
function loadApp(box) {
|
||||
const src = fs.readFileSync('public/app.js', 'utf8');
|
||||
vm.runInContext(src, box.ctx);
|
||||
}
|
||||
|
||||
console.log('\n=== pullReconnect helper exists ===');
|
||||
test('pullReconnect is exposed on window', () => {
|
||||
const box = makeSandbox();
|
||||
loadApp(box);
|
||||
assert.strictEqual(typeof box.ctx.window.pullReconnect, 'function',
|
||||
'window.pullReconnect must be a function');
|
||||
});
|
||||
|
||||
console.log('\n=== setupPullToReconnect exists ===');
|
||||
test('setupPullToReconnect is exposed on window', () => {
|
||||
const box = makeSandbox();
|
||||
loadApp(box);
|
||||
assert.strictEqual(typeof box.ctx.window.setupPullToReconnect, 'function',
|
||||
'window.setupPullToReconnect must be a function');
|
||||
});
|
||||
|
||||
console.log('\n=== pullReconnect closes existing WS ===');
|
||||
test('calling pullReconnect() closes the current WebSocket', () => {
|
||||
const box = makeSandbox();
|
||||
loadApp(box);
|
||||
// app.js does NOT call connectWS until DOMContentLoaded. Force one:
|
||||
box.ctx.window.connectWS && box.ctx.window.connectWS();
|
||||
// If app.js doesn't expose connectWS, fall back to invoking pullReconnect
|
||||
// and checking that something tries to open a new socket.
|
||||
const beforeCount = box.wsInstances.length;
|
||||
box.ctx.window.pullReconnect();
|
||||
// Either: existing WS got closed, OR a new WS was opened (reconnect)
|
||||
const closed = box.wsInstances.some(function(w) { return w.closed; });
|
||||
const opened = box.wsInstances.length > beforeCount;
|
||||
assert.ok(closed || opened,
|
||||
'pullReconnect must close the WS or open a new one (got closed=' + closed + ', opened=' + opened + ')');
|
||||
});
|
||||
|
||||
console.log('\n=== setupPullToReconnect wires document touch listeners ===');
|
||||
test('setupPullToReconnect attaches touchstart listener', () => {
|
||||
const box = makeSandbox();
|
||||
loadApp(box);
|
||||
box.ctx.window.setupPullToReconnect();
|
||||
assert.ok((box.listeners['touchstart'] || []).length > 0,
|
||||
'touchstart listener must be attached to document');
|
||||
assert.ok((box.listeners['touchmove'] || []).length > 0,
|
||||
'touchmove listener must be attached to document');
|
||||
assert.ok((box.listeners['touchend'] || []).length > 0,
|
||||
'touchend listener must be attached to document');
|
||||
});
|
||||
|
||||
console.log('\n=== Pull gesture at scrollTop=0 triggers reconnect ===');
|
||||
test('pull-down past threshold at scrollTop=0 triggers pullReconnect', () => {
|
||||
const box = makeSandbox({ scrollTop: 0 });
|
||||
loadApp(box);
|
||||
box.ctx.window.connectWS && box.ctx.window.connectWS();
|
||||
box.ctx.window.setupPullToReconnect();
|
||||
|
||||
let triggered = false;
|
||||
const orig = box.ctx.window.pullReconnect;
|
||||
box.ctx.window.pullReconnect = function() { triggered = true; return orig.apply(this, arguments); };
|
||||
|
||||
function fire(name, y) {
|
||||
(box.listeners[name] || []).forEach(function(fn) {
|
||||
fn({ touches: [{ clientY: y }], changedTouches: [{ clientY: y }], preventDefault: function() {}, type: name });
|
||||
});
|
||||
}
|
||||
fire('touchstart', 10);
|
||||
fire('touchmove', 100);
|
||||
fire('touchmove', 200);
|
||||
fire('touchend', 200);
|
||||
|
||||
assert.ok(triggered, 'pullReconnect must be called after pull > threshold at scrollTop=0');
|
||||
});
|
||||
|
||||
console.log('\n=== Pull gesture when scrolled DOWN does NOT trigger ===');
|
||||
test('pull when scrollTop > 0 does NOT trigger pullReconnect', () => {
|
||||
const box = makeSandbox({ scrollTop: 500 });
|
||||
loadApp(box);
|
||||
box.ctx.window.connectWS && box.ctx.window.connectWS();
|
||||
box.ctx.window.setupPullToReconnect();
|
||||
|
||||
let triggered = false;
|
||||
box.ctx.window.pullReconnect = function() { triggered = true; };
|
||||
|
||||
function fire(name, y) {
|
||||
(box.listeners[name] || []).forEach(function(fn) {
|
||||
fn({ touches: [{ clientY: y }], changedTouches: [{ clientY: y }], preventDefault: function() {}, type: name });
|
||||
});
|
||||
}
|
||||
fire('touchstart', 10);
|
||||
fire('touchmove', 200);
|
||||
fire('touchend', 200);
|
||||
|
||||
assert.strictEqual(triggered, false,
|
||||
'pullReconnect must NOT fire when page is scrolled (scrollTop > 0)');
|
||||
});
|
||||
|
||||
console.log('\n=== Results: ' + passed + ' passed, ' + failed + ' failed ===\n');
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
Reference in New Issue
Block a user