diff --git a/src/css/injected.scss b/src/css/injected.scss index 9a92a4a3..2e5f33cc 100644 --- a/src/css/injected.scss +++ b/src/css/injected.scss @@ -1653,6 +1653,13 @@ html.transparencyEffects box-shadow: 0px 10px 15px -3px rgba(0, 0, 0, 0.4); } +/* Smoothed by attachNotificationsPanelAnimation (matches #ExtensionPopup spring) */ +.bsplus-notifications-panel { + transform-origin: top right; + will-change: opacity, transform; + filter: drop-shadow(0px 0px 20px rgba(0, 0, 0, 0.35)); +} + #menu li.active { color: #ffffff !important; background: rgba(0, 0, 0, 0.35); diff --git a/src/seqta/ui/AddBetterSEQTAElements.ts b/src/seqta/ui/AddBetterSEQTAElements.ts index 8a7729a3..dd512c99 100644 --- a/src/seqta/ui/AddBetterSEQTAElements.ts +++ b/src/seqta/ui/AddBetterSEQTAElements.ts @@ -3,6 +3,7 @@ import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage"; import { loadEngageHomePage } from "@/seqta/utils/Loaders/LoadEngageHomePage"; import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage"; import { SendNewsPage } from "@/seqta/utils/SendNewsPage"; +import { attachNotificationsPanelAnimation } from "@/seqta/utils/attachNotificationsPanelAnimation"; import { setupSettingsButton } from "@/seqta/utils/setupSettingsButton"; import { waitForElm } from "@/seqta/utils/waitForElm"; @@ -89,6 +90,7 @@ export async function AddBetterSEQTAElements() { addExtensionSettings(); await createSettingsButton(); setupSettingsButton(); + attachNotificationsPanelAnimation(); } function createHomeButton(fragment: DocumentFragment, _: HTMLElement) { @@ -423,10 +425,12 @@ async function setupEngageSettingsButton() { await addDarkLightToggle(parent); await createSettingsButton(parent); setupSettingsButton(); + attachNotificationsPanelAnimation(); } catch { await addDarkLightToggle(); await createSettingsButton(); setupSettingsButton(); + attachNotificationsPanelAnimation(); } } diff --git a/src/seqta/utils/attachNotificationsPanelAnimation.ts b/src/seqta/utils/attachNotificationsPanelAnimation.ts new file mode 100644 index 00000000..1f74c558 --- /dev/null +++ b/src/seqta/utils/attachNotificationsPanelAnimation.ts @@ -0,0 +1,128 @@ +import { animate } from "motion"; +import { settingsState } from "@/seqta/utils/listeners/SettingsState"; +import { waitForElm } from "@/seqta/utils/waitForElm"; + +/** + * Finds the SEQTA notifications dropdown panel (the list container next to the bell). + */ +function findNotificationPanel(): HTMLElement | null { + const wrapper = document.querySelector(".connectedNotificationsWrapper"); + if (!wrapper) return null; + + const flat = wrapper.querySelector(":scope > div > button + div"); + if (flat) return flat; + + const notifBlock = wrapper.querySelector("[class*='notifications__notifications___']"); + if (notifBlock?.nextElementSibling instanceof HTMLElement) { + return notifBlock.nextElementSibling; + } + + const list = wrapper.querySelector("[class*='notifications__list___']"); + if (list) return list; + + return null; +} + +function isPanelVisible(el: HTMLElement): boolean { + return ( + el.getClientRects().length > 0 && getComputedStyle(el).visibility !== "hidden" + ); +} + +let lastVisible = false; +/** Invalidates in-flight open animations when the panel closes or reopens. */ +let motionGeneration = 0; + +function runOpenAnimation(panel: HTMLElement) { + const myGen = ++motionGeneration; + panel.classList.add("bsplus-notifications-panel"); + + if (!settingsState.animations) { + panel.style.opacity = "1"; + panel.style.transform = "scale(1)"; + return; + } + + panel.style.opacity = "0"; + panel.style.transform = "scale(0)"; + + requestAnimationFrame(() => { + if (myGen !== motionGeneration) return; + animate(0, 1, { + onUpdate: (progress) => { + panel.style.opacity = String(progress); + panel.style.transform = `scale(${progress})`; + }, + type: "spring", + stiffness: 280, + damping: 20, + }); + }); +} + +function clearPanelMotionStyles(panel: HTMLElement) { + motionGeneration++; + panel.style.opacity = ""; + panel.style.transform = ""; +} + +/** + * Spring open / fade close for the native SEQTA notifications dropdown, matching ExtensionPopup. + */ +export function attachNotificationsPanelAnimation() { + void setupNotificationsPanelAnimation(); +} + +async function setupNotificationsPanelAnimation() { + try { + await waitForElm(".connectedNotificationsWrapper", true, 100, 60); + } catch { + return; + } + + const wrapper = document.querySelector(".connectedNotificationsWrapper"); + if (!wrapper) return; + + const sync = () => { + const panel = findNotificationPanel(); + // When SEQTA removes the dropdown from the DOM on close, we must reset + // lastVisible — otherwise the next open still looks "already visible" and skips animation. + if (!panel) { + if (lastVisible) { + lastVisible = false; + motionGeneration++; + } + return; + } + + const visible = isPanelVisible(panel); + if (visible === lastVisible) return; + + if (visible) { + runOpenAnimation(panel); + } else { + clearPanelMotionStyles(panel); + } + lastVisible = visible; + }; + + const observer = new MutationObserver(() => { + sync(); + }); + observer.observe(wrapper, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ["style", "class"], + }); + + document.addEventListener( + "click", + () => { + requestAnimationFrame(() => requestAnimationFrame(sync)); + }, + true, + ); + + sync(); +}