diff --git a/gerbil-manager-web/src/pages/TicketsPage.tsx b/gerbil-manager-web/src/pages/TicketsPage.tsx index d32beb8..d12df39 100644 --- a/gerbil-manager-web/src/pages/TicketsPage.tsx +++ b/gerbil-manager-web/src/pages/TicketsPage.tsx @@ -11,7 +11,17 @@ * Rückfragen kommen hierher zurück), „Geschlossen" (Resolved, zeigt den Changelog/fixNote). * Der Frage/Antwort-Verlauf (thread) wird angezeigt; das interne `agentContext` NIEMALS. */ -import { createContext, Fragment, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' +import { + createContext, + Fragment, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react' import { Link, useSearchParams } from 'react-router-dom' import { de } from '../strings/de' import { ApiError } from '../api/client' @@ -262,10 +272,11 @@ export default function TicketsPage() { // selbst gewählt hat (userPicked), darf ein ?focus=-Sprung die Ansicht bestimmen. const [view, setView] = useState('open') const [userPicked, setUserPicked] = useState(false) - // Scrollposition über das Verlassen/Zurückkehren (z. B. Klick auf einen Tier-Link und dann - // Browser-Zurück) hinweg erhalten. Erst NACH dem Wiederherstellen wieder mitschreiben, damit - // der Sprung an den Seitenanfang beim Re-Mount die gemerkte Position nicht überschreibt. - const scrollRestoredRef = useRef(false) + // Scrollposition über Verlassen/Zurückkehren UND Bildschirm-Sperre/Wiederaufwachen hinweg + // erhalten (Handy sperrt → Seite wird eingefroren/verworfen → kommt sonst oben an). Siehe die + // Scroll-Effekte unten (Speichern beim Ausblenden, Wiederherstellen beim Anzeigen + nach Laden). + const lastScrollRef = useRef(0) + const restoringRef = useRef(false) async function handleToggleStatus(ticket: FeedbackTicket) { try { @@ -437,40 +448,93 @@ export default function TicketsPage() { return () => clearTimeout(timer) }, [focusId, displayed]) - // Scrollposition laufend in der Session merken (für die Zurück-Navigation). + // Scrollposition laufend merken (außer während wir gerade selbst wiederherstellen, damit ein + // vom Browser beim Aufwachen ausgelöster Sprung nach oben die gemerkte Position nicht killt). const SCROLL_KEY = 'tickets-scroll-y' + const saveScroll = useCallback(() => { + if (restoringRef.current) return + lastScrollRef.current = window.scrollY + try { + sessionStorage.setItem(SCROLL_KEY, String(window.scrollY)) + } catch { + /* sessionStorage nicht verfügbar */ + } + }, []) useEffect(() => { const onScroll = () => { - if (!scrollRestoredRef.current) return - try { - sessionStorage.setItem(SCROLL_KEY, String(window.scrollY)) - } catch { - /* sessionStorage nicht verfügbar — Position dann nicht gemerkt */ - } + if (document.visibilityState !== 'visible') return + saveScroll() } window.addEventListener('scroll', onScroll, { passive: true }) return () => window.removeEventListener('scroll', onScroll) + }, [saveScroll]) + + // Position SICHER festhalten, kurz bevor die Seite eingefroren/verworfen wird (Handy-Sperre, + // App-Wechsel, Tab schließen) — da feuert evtl. kein Scroll-Event mehr. + useEffect(() => { + const persist = () => { + try { + sessionStorage.setItem(SCROLL_KEY, String(lastScrollRef.current || window.scrollY)) + } catch { + /* ignorieren */ + } + } + const onVisibility = () => { + if (document.visibilityState === 'hidden') persist() + } + document.addEventListener('visibilitychange', onVisibility) + window.addEventListener('pagehide', persist) + return () => { + document.removeEventListener('visibilitychange', onVisibility) + window.removeEventListener('pagehide', persist) + } }, []) - // Nach dem Laden der Tickets die gemerkte Scrollposition EINMALIG wiederherstellen — außer es - // steht ein ?focus=-Sprung an (der hat Vorrang). Erst danach beginnt das Mitschreiben (s. o.). - useEffect(() => { - if (scrollRestoredRef.current || rows === null) return - if (focusId) { - scrollRestoredRef.current = true - return - } + // Gemerkte Position wiederherstellen — robust gegen nachwachsende Inhalte (mehrere Versuche per + // rAF) und gegen den Aufwach-Sprung nach oben. ?focus= hat Vorrang. + const restoreScroll = useCallback(() => { + if (focusId) return let y = NaN try { y = Number(sessionStorage.getItem(SCROLL_KEY)) } catch { /* ignorieren */ } - scrollRestoredRef.current = true - if (Number.isFinite(y) && y > 0) { - requestAnimationFrame(() => window.scrollTo(0, y)) + if (!Number.isFinite(y) || y <= 0) return + restoringRef.current = true + let tries = 0 + const tick = () => { + window.scrollTo(0, y) + tries += 1 + const close = Math.abs(window.scrollY - y) <= 2 + const cannotReach = document.documentElement.scrollHeight <= y + window.innerHeight + if (!close && !cannotReach && tries < 30) { + requestAnimationFrame(tick) + } else { + restoringRef.current = false + } } - }, [rows, focusId]) + requestAnimationFrame(tick) + }, [focusId]) + + // Wiederherstellen nach dem Laden der Tickets (frischer Mount / Reload nach Tab-Verwerfung). + useEffect(() => { + if (rows) restoreScroll() + }, [rows, restoreScroll]) + + // … und beim Wiederanzeigen der Seite (Handy entsperrt / aus dem Hintergrund) OHNE Reload. + useEffect(() => { + const onVisible = () => { + if (document.visibilityState === 'visible') restoreScroll() + } + const onPageShow = () => restoreScroll() + document.addEventListener('visibilitychange', onVisible) + window.addEventListener('pageshow', onPageShow) + return () => { + document.removeEventListener('visibilitychange', onVisible) + window.removeEventListener('pageshow', onPageShow) + } + }, [restoreScroll]) // Nackte Tier-IDs in den sichtbaren Tickets sammeln und ihre Namen einmalig auflösen, // damit die KI im Text einfach eine Tier-ID hinterlegen kann (→ Name + Link zur Akte).