1import { Controller } from "@hotwired/stimulus" 2 3// Adds a brief amber glow to a thread card if it lands in the DOM via a 4// Turbo Stream broadcast (i.e. AFTER the user navigated to the page), so 5// live updates announce themselves without yanking the viewport. 6// 7// Pairs with `scroll: :preserve` on the morph: scroll stays put, but the 8// card the user wasn't looking at gets a peripheral-vision flash. The 9// animation is automatically suppressed for users with 10// `prefers-reduced-motion: reduce` (see animation.css). 11// 12// Detection: `turbo:visit` stamps the page-load timestamp before any 13// controller on the next page connects. That event fires for cold loads 14// and Drive navigations (J/K hops) but NOT for live morph refreshes â 15// so a controller whose connect() runs more than FRESH_THRESHOLD_MS after 16// the stamp must have been inserted by the cable. No per-card server 17// state, no morph-event listeners. 18// 19// Limitation: a card whose DOM id survives the morph but whose inner 20// content was rewritten in place (the `refresh_from_mail!` case for the 21// selected reply) will NOT trigger this â Stimulus doesn't reconnect 22// surviving elements. That edge case needs `turbo:morph-element` work; 23// shipped separately if it proves load-bearing in real use. 24// 25// HTML usage: 26// <div id="<%= dom_id(reply) %>" data-controller="freshly-arrived"> 27// ...card contents... 28// </div> 29// 30// Values, targets, classes: none â the controller is purely behavioural. 31
32// Morph cycles complete in well under 300ms on a healthy connection; 33// 1000ms is a comfortable safety margin so a slow LCP can't accidentally 34// flag every card on the cold load as "freshly arrived". 35const FRESH_THRESHOLD_MS = 1000 36 37// Symbol-keyed off `window` so we don't pollute the enumerable global 38// namespace (Object.keys(window) won't surface this) while still sharing 39// the timestamp across every controller instance on the page. 40const STAMP_KEY = Symbol.for("kit.freshly_arrived.stamp") 41const LISTENER_KEY = Symbol.for("kit.freshly_arrived.listener_attached") 42 43if (typeof window !== "undefined") { 44 if (window[STAMP_KEY] === undefined) { 45 window[STAMP_KEY] = performance.now() 46 } 47 // Guard prevents duplicate listeners during HMR / module re-evaluation. 48 // The listener is intentionally module-singleton â every freshly-arrived 49 // controller on the page reads the same stamp. 50 if (!window[LISTENER_KEY]) { 51 window[LISTENER_KEY] = true 52 document.addEventListener("turbo:visit", () => { 53 window[STAMP_KEY] = performance.now() 54 }) 55 } 56} 57 58export default class extends Controller { 59 connect() { 60 const stamp = window[STAMP_KEY] 61 if (stamp === undefined) return 62 if (performance.now() - stamp < FRESH_THRESHOLD_MS) return 63 64 this.element.classList.add("animate-fresh-arrival") 65 this.element.addEventListener( 66 "animationend", 67 this.#handleAnimationEnd, 68 { once: true } 69 ) 70 } 71 72 disconnect() { 73 // If the element is morphed out before the 2.4s animation completes, 74 // strip the class so the listener â even though it's `{ once: true }` 75 // and would auto-remove on fire â has nothing left to fire on. Belt 76 // and suspenders against detached-node work. 77 this.element.classList.remove("animate-fresh-arrival") 78 this.element.removeEventListener("animationend", this.#handleAnimationEnd) 79 } 80 81 #handleAnimationEnd = () => { 82 this.element.classList.remove("animate-fresh-arrival") 83 } 84}
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.