diff --git a/src/gui/src/UI/Dashboard/TabApps.js b/src/gui/src/UI/Dashboard/TabApps.js index 470f0149e..dba476375 100644 --- a/src/gui/src/UI/Dashboard/TabApps.js +++ b/src/gui/src/UI/Dashboard/TabApps.js @@ -214,6 +214,7 @@ const TabApps = { _hasCustomOrder: false, _drag: null, _justDragged: false, + _reduceMotionMQL: undefined, html () { let h = '
'; @@ -308,6 +309,12 @@ const TabApps = { if ( items.length === 0 ) return; + // A touch long-press arms a drag pickup (see _onTilePointerDown). If + // the user held rather than dragged, they want this menu — cancel the + // pending pickup so the long-press → Uninstall path keeps working on + // touch. An already-started drag was handled by the guard above. + if ( self._drag ) self._endDrag(false); + e.preventDefault(); e.stopPropagation(); @@ -374,8 +381,13 @@ const TabApps = { clearTimeout(self._resizeTimer); self._resizeTimer = setTimeout(() => { if ( ! self._apps ) return; - // Don't rebuild the DOM out from under an in-progress drag. - if ( self._drag && self._drag.started ) return; + if ( self._drag ) { + // Don't rebuild the DOM out from under an in-progress drag. + if ( self._drag.started ) return; + // A press is pending but hasn't become a drag; cancel it so a + // rebuild can't detach the tile the pickup is waiting on. + self._endDrag(false); + } const layout = self.computeLayout($el_window.find('.myapps-container')); if ( ! layout ) return; if ( self._layout && layout.cols === self._layout.cols && layout.rows === self._layout.rows ) { @@ -526,7 +538,14 @@ const TabApps = { }, _reduceMotion () { - return !! window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; + // Cache the live MediaQueryList — this is read on every pointermove + // during a drag, and matchMedia() is comparatively expensive. + if ( this._reduceMotionMQL === undefined ) { + this._reduceMotionMQL = window.matchMedia + ? window.matchMedia('(prefers-reduced-motion: reduce)') + : null; + } + return !! (this._reduceMotionMQL && this._reduceMotionMQL.matches); }, // -- Drag-to-reorder -- @@ -555,6 +574,7 @@ const TabApps = { offsetX: 0, offsetY: 0, started: false, + readyToDrag: pointerType !== 'touch', // touch must long-press first ghost: null, edgeTimer: null, edgeDir: 0, @@ -578,11 +598,18 @@ const TabApps = { document.addEventListener('keydown', d.onKey); window.addEventListener('blur', d.onBlur); - // Touch: hold still to enter reorder mode; a moving finger is a page - // swipe and cancels the intent (see _onDragPointerMove). + // Touch: a long-press *arms* reordering (it doesn't grab the tile yet). + // Moving after that begins the drag; holding still instead lets the + // native long-press context menu (Uninstall) fire. A finger that moves + // before the long-press is a page swipe and cancels the intent (see + // _onDragPointerMove). if ( pointerType === 'touch' ) { d.longPressTimer = setTimeout(() => { - if ( this._drag === d && ! d.started ) this._beginDrag(); + if ( this._drag !== d || d.started ) return; + d.readyToDrag = true; + if ( navigator.vibrate ) { + try { navigator.vibrate(8); } catch ( _e ) { /* not supported */ } + } }, DRAG_TOUCH_LONGPRESS_MS); } }, @@ -593,14 +620,18 @@ const TabApps = { if ( ! d.started ) { const dist = Math.hypot(e.clientX - d.startX, e.clientY - d.startY); - if ( d.pointerType === 'touch' ) { - if ( dist > DRAG_TOUCH_CANCEL_DISTANCE ) this._abortDragIntent(); + if ( ! d.readyToDrag ) { + // Touch, pre-long-press: a moving finger is a page swipe. + if ( dist > DRAG_TOUCH_CANCEL_DISTANCE ) this._endDrag(false); return; } if ( dist <= DRAG_START_DISTANCE ) return; d.lastClientX = e.clientX; d.lastClientY = e.clientY; this._beginDrag(); + // _beginDrag bails (without starting) if the tile was detached by a + // re-render during the pre-start window; don't touch a dead drag. + if ( ! d.started ) return; // Fall through so this same event also places the tile — a coarse // (few-event) drag still reorders instead of just lifting. } @@ -619,6 +650,9 @@ const TabApps = { _beginDrag () { const d = this._drag; if ( ! d || d.started ) return; + // A re-render during the pre-start window can detach the pressed tile; + // starting a drag on a stale node would corrupt the persisted order. + if ( ! d.tileEl.isConnected ) { this._endDrag(false); return; } d.started = true; clearTimeout(d.longPressTimer); d.longPressTimer = null; @@ -640,10 +674,6 @@ const TabApps = { d.tileEl.classList.add('myapps-tile-dragging'); document.body.classList.add('myapps-reordering'); - - if ( d.pointerType === 'touch' && navigator.vibrate ) { - try { navigator.vibrate(8); } catch ( _e ) { /* not supported */ } - } }, _positionGhost (x, y) { @@ -752,13 +782,6 @@ const TabApps = { } }, - _abortDragIntent () { - const d = this._drag; - if ( ! d ) return; - this._drag = null; - this._teardownDragListeners(d); - }, - _teardownDragListeners (d) { document.removeEventListener('pointermove', d.onMove, { passive: false }); document.removeEventListener('pointerup', d.onUp); @@ -782,27 +805,32 @@ const TabApps = { return; } - // Swallow the click the browser synthesizes after this pointerup. - this._justDragged = true; - clearTimeout(this._justDraggedTimer); - this._justDraggedTimer = setTimeout(() => { this._justDragged = false; }, 350); - d.tileEl.classList.remove('myapps-tile-dragging'); + let changed = false; if ( commit ) { const names = d.$el_window.find('.myapps-page .myapps-tile').toArray() .map(t => t.getAttribute('data-app-name')); const current = this._apps.map(a => a.name); // Only persist when the order actually changed, so an accidental // long-press or drop-in-place doesn't freeze the default ordering. - const changed = names.length !== current.length + changed = names.length !== current.length || names.some((name, i) => name !== current[i]); if ( changed ) { - this._apps = this._reorderAppsByNames(names); + this._apps = reconcileAppOrder(this._apps, names); this.saveOrder(); } } + // Swallow the click synthesized after this pointerup only when the drag + // actually reordered something — a drift/no-op press should still open + // the app, matching a plain click. + if ( changed ) { + this._justDragged = true; + clearTimeout(this._justDraggedTimer); + this._justDraggedTimer = setTimeout(() => { this._justDragged = false; }, 350); + } + const ghost = d.ghost; if ( ghost ) { ghost.classList.add('myapps-drag-ghost-drop'); @@ -813,21 +841,6 @@ const TabApps = { this.renderApps(d.$el_window, { preservePage: true, instant: true }); }, - _reorderAppsByNames (names) { - const byName = new Map(); - for ( const app of this._apps ) byName.set(app.name, app); - const out = []; - for ( const name of names ) { - if ( byName.has(name) ) { - out.push(byName.get(name)); - byName.delete(name); - } - } - // Anything not represented in the DOM (shouldn't happen) is kept. - for ( const app of byName.values() ) out.push(app); - return out; - }, - saveOrder () { this._hasCustomOrder = true; const names = serializeAppOrder(this._apps); @@ -842,14 +855,18 @@ const TabApps = { }, async loadApps ($el_window) { - // Don't fetch/re-render on top of a live drag. - if ( this._drag && this._drag.started ) return; + if ( this._drag ) { + // Don't fetch/re-render on top of a live drag; cancel a pending + // (not-yet-started) pickup so a rebuild can't strand it. + if ( this._drag.started ) return; + this._endDrag(false); + } const $container = $el_window.find('.myapps-container'); try { - // Fetch both APIs in parallel - const [installedRes, launchRes] = await Promise.all([ + // Fetch the two app lists and the saved order together. + const [installedRes, launchRes, savedOrderRaw] = await Promise.all([ fetch( `${window.api_origin}/installedApps?orderBy=name&limit=100`, { @@ -864,6 +881,7 @@ const TabApps = { method: 'GET', }, ), + puter.kv.get(APPS_ORDER_KV_KEY).catch(() => null), ]); const installedApps = await installedRes.json(); @@ -903,8 +921,11 @@ const TabApps = { // in their default order; stale names are ignored. let orderedNames = null; try { - const raw = await puter.kv.get(APPS_ORDER_KV_KEY); - if ( raw ) orderedNames = typeof raw === 'string' ? JSON.parse(raw) : raw; + if ( savedOrderRaw ) { + orderedNames = typeof savedOrderRaw === 'string' + ? JSON.parse(savedOrderRaw) + : savedOrderRaw; + } } catch ( _e ) { orderedNames = null; }