1import { Controller } from "@hotwired/stimulus" 2 3export default class extends Controller { 4 static values = { 5 messageId: Number, 6 readUrl: String, 7 delaySeconds: Number, 8 } 9 10 connect() { 11 if (this.element.classList.contains("is-read")) { 12 this.marked = true 13 return 14 } 15 this.marked = false 16 this.timer = null 17 this.pollInterval = null 18 this.observe() 19 } 20 21 disconnect() { 22 this.cleanup() 23 } 24 25 observe() { 26 if (!("IntersectionObserver" in window)) return 27 28 this.observer = new IntersectionObserver(entries => { 29 entries.forEach(entry => { 30 if (entry.isIntersecting && this.visibleEnough(entry)) { 31 this.stopPoll() 32 this.startTimer() 33 } else if (entry.isIntersecting && this.needsPollFallback()) { 34 this.startPoll() 35 } else { 36 this.stopPoll() 37 this.clearTimer() 38 } 39 }) 40 }, { threshold: [0, 0.25, 0.7] }) 41 42 this.observer.observe(this.element) 43 } 44 45 visibleEnough(entry) { 46 const visiblePx = entry.intersectionRect.height 47 const elementHeight = entry.target.getBoundingClientRect().height || 1 48 const ratio = visiblePx / elementHeight 49 50 // Standard case: most of the element is on screen. 51 if (ratio >= 0.7) return true 52 53 // Very tall elements that cannot reach high ratio: accept when a good chunk is visible. 54 const viewportHeight = window.innerHeight || 800 55 const substantialPx = Math.min(viewportHeight * 0.8, 400) // cap to avoid excessive requirement 56 if (visiblePx >= substantialPx) return true 57 58 // Fallback: modest ratio plus reasonable pixels. 59 return ratio >= 0.25 && visiblePx >= 200 60 } 61 62 needsPollFallback() { 63 return this.element.getBoundingClientRect().height > (window.innerHeight || 800) 64 } 65 66 startPoll() { 67 if (this.marked || this.pollInterval || this.timer) return 68 this.pollInterval = setInterval(() => this.checkVisibility(), 1000) 69 } 70 71 stopPoll() { 72 if (this.pollInterval) { 73 clearInterval(this.pollInterval) 74 this.pollInterval = null 75 } 76 } 77 78 checkVisibility() { 79 if (this.marked) { this.stopPoll(); return } 80 81 const rect = this.element.getBoundingClientRect() 82 const viewportHeight = window.innerHeight || 800 83 const visibleTop = Math.max(0, rect.top) 84 const visibleBottom = Math.min(viewportHeight, rect.bottom) 85 const visiblePx = Math.max(0, visibleBottom - visibleTop) 86 87 if (visiblePx <= 0) { 88 this.stopPoll() 89 this.clearTimer() 90 return 91 } 92 93 const substantialPx = Math.min(viewportHeight * 0.8, 400) 94 if (visiblePx >= substantialPx) { 95 this.stopPoll() 96 this.startTimer() 97 } 98 } 99 100 startTimer() { 101 if (this.marked || this.timer) return 102 const delay = (this.delaySecondsValue || 5) * 1000 103 this.timer = setTimeout(() => this.markRead(), delay) 104 } 105 106 clearTimer() { 107 if (this.timer) { 108 clearTimeout(this.timer) 109 this.timer = null 110 } 111 } 112 113 cleanup() { 114 this.clearTimer() 115 this.stopPoll() 116 if (this.observer) { 117 this.observer.disconnect() 118 this.observer = null 119 } 120 } 121 122 async markRead() { 123 if (this.marked) return 124 this.marked = true 125 this.element.classList.add("is-read") 126 127 try { 128 await fetch(this.readUrlValue, { 129 method: "POST", 130 headers: this.csrfHeaders(), 131 }) 132 } catch (e) { 133 // If it fails, we leave the optimistic state; we could retry if needed. 134 console.warn("mark read failed", e) 135 } 136 } 137 138 csrfHeaders() { 139 const token = document.querySelector("meta[name=csrf-token]")?.content 140 return token ? { "X-CSRF-Token": token } : {} 141 } 142}
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.