Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/improsocial/impro
An extensible Bluesky client for web impro.social
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172import { Signal, effect } from "/js/signals.js";import { HoverObserver } from "/js/hoverObserver.js";import "/js/components/profile-hover-card.js";
export const SHOW_DELAY = 500;export const HIDE_DELAY = 150;export const HOVER_ELEMENT_SELECTOR = "[data-hover-did]";
export class ProfileHoverCardService { constructor(dataLayer, interactionHandlers) { this.dataLayer = dataLayer; this.interactionHandlers = interactionHandlers;
// "hidden" | "might-show" | "showing" | "might-hide" this.$viewState = new Signal.State("hidden"); this.$currentTarget = new Signal.State(null); this.$currentDid = new Signal.State(null); this.transitionTimerId = 0; this.cardEl = null; this.disposeRenderEffect = null; this.onMouseUp = this.onMouseUp.bind(this); this.onPageTransition = this.onPageTransition.bind(this); this.hoverObserver = null; this.installed = false; }
install(rootEl) { if (this.installed) return; this.installed = true; this.rootEl = rootEl; this.hoverObserver = new HoverObserver(rootEl, { selector: HOVER_ELEMENT_SELECTOR, ignore: (target) => !!target.closest("dialog, [popover]"), onEnter: (target) => this.onHoverTargetEnter(target), onLeave: () => this.onHoverTargetLeave(), }); rootEl.addEventListener("mouseup", this.onMouseUp); window.addEventListener("page-transition", this.onPageTransition);
this.disposeRenderEffect = effect(() => { const viewState = this.$viewState.get(); const target = this.$currentTarget.get(); const did = this.$currentDid.get(); if ( (viewState !== "showing" && viewState !== "might-hide") || !target || !did ) { this.cardEl?.close(); return; } this.ensureCardEl(); this.cardEl.did = did; this.cardEl.open(target.getBoundingClientRect()); }); }
dispose() { if (!this.installed) return; this.installed = false; this.hoverObserver?.disconnect(); this.hoverObserver = null; this.rootEl.removeEventListener("mouseup", this.onMouseUp); window.removeEventListener("page-transition", this.onPageTransition); this.setViewState("hidden"); this.disposeRenderEffect?.(); this.disposeRenderEffect = null; this.cardEl?.remove(); this.cardEl = null; }
// The only writer to $viewState. Clears any pending auto-advance timer and // schedules the next one when moving into a might-* state. setViewState(next) { if (this.$viewState.get() === next) return; if (this.transitionTimerId) { clearTimeout(this.transitionTimerId); this.transitionTimerId = 0; } this.$viewState.set(next); if (next === "might-show") { this.transitionTimerId = setTimeout( () => this.setViewState("showing"), SHOW_DELAY, ); } else if (next === "might-hide") { this.transitionTimerId = setTimeout(() => { this.setViewState("hidden"); this.$currentTarget.set(null); this.$currentDid.set(null); }, HIDE_DELAY); } }
onHoverTargetEnter(target) { const did = target.getAttribute("data-hover-did"); if (!did) return; const currentTarget = this.$currentTarget.get(); const currentDid = this.$currentDid.get(); const targetChanged = currentTarget !== target; const didChanged = currentDid !== did; if (targetChanged && currentTarget) { this.setViewState("hidden"); } this.$currentTarget.set(target); if (didChanged) { this.$currentDid.set(did); this.prefetch(did); } const viewState = this.$viewState.get(); if (viewState === "hidden") this.setViewState("might-show"); else if (viewState === "might-hide") this.setViewState("showing"); }
onHoverTargetLeave() { const viewState = this.$viewState.get(); if (viewState === "might-show") this.setViewState("hidden"); else if (viewState === "showing") this.setViewState("might-hide"); // In "might-hide" the grace timer is running — leaving again is a no-op. if (this.$viewState.get() === "hidden") { this.$currentTarget.set(null); this.$currentDid.set(null); } }
onMouseUp(event) { if (event.target?.closest?.(HOVER_ELEMENT_SELECTOR)) { this.dismiss(); } }
onPageTransition() { this.dismiss(); }
onCardPointerEnter() { if (this.$viewState.get() === "might-hide") this.setViewState("showing"); }
onCardPointerLeave() { if (this.$viewState.get() === "showing") this.setViewState("might-hide"); }
dismiss() { this.setViewState("hidden"); this.$currentTarget.set(null); this.$currentDid.set(null); this.hoverObserver?.clear(); }
prefetch(did) { if (this.dataLayer.derived.$hydratedDetailedProfiles.get(did)) return; this.dataLayer.declarative.ensureDetailedProfile(did).catch(() => { /* the card renders a degraded view or nothing */ }); }
ensureCardEl() { if (this.cardEl) return; const cardEl = document.createElement("profile-hover-card"); cardEl.dataLayer = this.dataLayer; cardEl.interactionHandlers = this.interactionHandlers; cardEl.addEventListener("pointerenter", () => this.onCardPointerEnter()); cardEl.addEventListener("pointerleave", () => this.onCardPointerLeave()); // Any click inside the card dismisses (follow button opts out via // stopPropagation on the button itself). cardEl.addEventListener("mouseup", () => this.dismiss()); document.body.appendChild(cardEl); this.cardEl = cardEl; }}