1import { Controller } from "@hotwired/stimulus" 2 3const STORAGE_KEY = "saii:messages-scroll:v1" 4const MAX_AGE = 24 * 60 * 60 * 1000 5const MAX_ENTRIES = 12 6const ROW_SELECTOR = "[data-active-chat-row='true']" 7 8// Save only navigation coordinates, never inbox HTML or message contents. 9// Fresh, authorized rows still come from Rails on every return. 10export default class extends Controller { 11 static values = { key: String } 12 13 connect() { 14 this.active = true 15 this.onScroll = () => { 16 if (this.restoring || this.saveFrame != null) return 17 this.saveFrame = requestAnimationFrame(() => { 18 this.saveFrame = null 19 this.save() 20 }) 21 } 22 this.onLeave = () => { this.save(); this.cancelRestore() } 23 this.onTakeover = () => this.cancelRestore() 24 this.element.addEventListener("scroll", this.onScroll, { passive: true }) 25 for (const name of ["touchstart", "wheel", "pointerdown", "keydown"]) { 26 this.element.addEventListener(name, this.onTakeover, { passive: true }) 27 } 28 for (const name of ["turbo:before-visit", "turbo:before-cache", "turbo:before-render", "turbo:before-frame-render"]) { 29 document.addEventListener(name, this.onLeave) 30 } 31 window.addEventListener("pagehide", this.onLeave) 32 // Warm navigation restores the complete list in the render callback, before 33 // first paint. Do not fetch pages or replay the old top-to-anchor animation. 34 const retainedTop = this.element.dataset?.messagesScrollRestoredTop 35 if (retainedTop != null) { 36 this.element.scrollTop = Number(retainedTop) 37 delete this.element.dataset.messagesScrollRestoredTop 38 return 39 } 40 // Allow the child pagination controller to connect before restoring history. 41 this.restoreFrame = requestAnimationFrame(() => { 42 this.restoreFrame = null 43 this.restore() 44 }) 45 } 46 47 disconnect() { 48 this.active = false 49 this.cancelRestore() 50 if (this.saveFrame != null) cancelAnimationFrame(this.saveFrame) 51 this.saveFrame = null 52 this.element.removeEventListener("scroll", this.onScroll) 53 for (const name of ["touchstart", "wheel", "pointerdown", "keydown"]) { 54 this.element.removeEventListener(name, this.onTakeover) 55 } 56 for (const name of ["turbo:before-visit", "turbo:before-cache", "turbo:before-render", "turbo:before-frame-render"]) { 57 document.removeEventListener(name, this.onLeave) 58 } 59 window.removeEventListener("pagehide", this.onLeave) 60 } 61 62 cancelRestore() { 63 this.restoring = false 64 if (this.restoreFrame != null) cancelAnimationFrame(this.restoreFrame) 65 this.restoreFrame = null 66 this.reveal() 67 } 68 69 reveal() { 70 if (this.element.dataset?.messagesScrollAwaitingRestore) { 71 this.element.style.visibility = "" 72 delete this.element.dataset.messagesScrollAwaitingRestore 73 } 74 } 75 76 entries() { 77 try { 78 const entries = JSON.parse(window.sessionStorage.getItem(STORAGE_KEY) || "[]") 79 return Array.isArray(entries) ? entries.filter(entry => 80 entry && typeof entry.key === "string" && entry.savedAt > Date.now() - MAX_AGE && 81 Number.isFinite(entry.top) && entry.top >= 0 && 82 Array.isArray(entry.anchors) && entry.anchors.every(anchor => 83 typeof anchor.id === "string" && Number.isFinite(anchor.offset) 84 ) && Number.isInteger(entry.count) && entry.count >= 0 85 ) : [] 86 } catch { return [] } 87 } 88 89 save() { 90 if (!this.active || this.restoring || !this.element.isConnected || !this.keyValue) return 91 const rows = [...this.element.querySelectorAll(ROW_SELECTOR)] 92 const top = this.element.getBoundingClientRect().top 93 const anchors = rows.filter(row => row.getBoundingClientRect().bottom > top).slice(0, 3) 94 const entry = { 95 key: this.keyValue, savedAt: Date.now(), top: this.element.scrollTop, 96 anchors: anchors.map(row => ({ id: row.dataset.conversationId, offset: row.getBoundingClientRect().top - top })), 97 count: rows.length 98 } 99 try { 100 const entries = this.entries().filter(item => item.key !== this.keyValue) 101 window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify([...entries, entry].slice(-MAX_ENTRIES))) 102 } catch { /* Storage may be disabled in a WebView; navigation still works. */ } 103 } 104
105 async restore() { 106 const entry = this.entries().find(item => item.key === this.keyValue) 107 if (!entry || !this.active || !this.element.isConnected) { this.reveal(); return } 108 this.restoring = true 109 try { 110 let rows = [...this.element.querySelectorAll(ROW_SELECTOR)] 111 const list = this.element.querySelector('[data-controller~="messages-infinite-scroll"]') 112 const loader = list && this.application.getControllerForElementAndIdentifier(list, "messages-infinite-scroll") 113 // Restore loaded depth even if replying moved the old anchor to page one. 114 // Stop on exhaustion/failure; never fetch an unbounded search for a deleted row. 115 while (rows.length < entry.count && loader?.hasSentinelTarget) { 116 const previousCount = rows.length 117 await loader.loadNextPage() 118 if (!this.restoring || !this.active || !this.element.isConnected) return 119 rows = [...this.element.querySelectorAll(ROW_SELECTOR)] 120 if (rows.length <= previousCount) break 121 } 122 if (!this.restoring || !this.active || !this.element.isConnected) return 123 // A reply can move the opened chat to the top. Prefer the nearby anchor 124 // that moved least so that one reordered row cannot drag us to the top. 125 const positions = entry.anchors.flatMap(anchor => { 126 const row = rows.find(row => row.dataset.conversationId === anchor.id) 127 return row ? [this.element.scrollTop + row.getBoundingClientRect().top - this.element.getBoundingClientRect().top - anchor.offset] : [] 128 }) 129 positions.sort((a, b) => Math.abs(a - entry.top) - Math.abs(b - entry.top)) 130 this.element.scrollTop = entry.top === 0 ? 0 : (positions[0] ?? entry.top) 131 } finally { 132 this.restoring = false 133 this.reveal() 134 } 135 } 136}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.