Skip to content

Commit

Permalink
fix(placement): fix firing multiple impressions (#9631)
Browse files Browse the repository at this point in the history
* fix(placement): fix firing multiple impressions

* unify sendViewed and empty placement.
* add intersection observer hook, simplify logic (#9653
* feature detect IntersectionObserver

---------

Co-authored-by: Leo McArdle <lmcardle@mozilla.com>
  • Loading branch information
fiji-flo and LeoMcA authored Sep 15, 2023
1 parent 1d1a9b4 commit b8da642
Show file tree
Hide file tree
Showing 6 changed files with 105 additions and 84 deletions.
1 change: 0 additions & 1 deletion client/src/document/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { Route, Routes, MemoryRouter } from "react-router-dom";
import React from "react";
import { render, waitFor } from "@testing-library/react";

import { Document } from "./index";
Expand Down
3 changes: 2 additions & 1 deletion client/src/document/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import "./interactive-examples.scss";
import { DocumentSurvey } from "../ui/molecules/document-survey";
import { useIncrementFrequentlyViewed } from "../plus/collections/frequently-viewed";
import { useInteractiveExamplesActionHandler as useInteractiveExamplesTelemetry } from "../telemetry/interactive-examples";
import { SidePlacement } from "../ui/organisms/placement";
import { BottomBanner, SidePlacement } from "../ui/organisms/placement";
import { BaselineIndicator } from "./baseline-indicator";
// import { useUIStatus } from "../ui-context";

Expand Down Expand Up @@ -264,6 +264,7 @@ export function Document(props /* TODO: define a TS interface for this */) {
<DocumentSurvey doc={doc} />
<RenderDocumentBody doc={doc} />
<Metadata doc={doc} locale={locale} />
<BottomBanner />
</article>
</MainContentContainer>
</div>
Expand Down
20 changes: 20 additions & 0 deletions client/src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,3 +193,23 @@ export function usePageVisibility() {
});
return isVisible;
}

export function useIsIntersecting(
node: HTMLElement | undefined,
options: IntersectionObserverInit
) {
const [isIntersectingState, setIsIntersectingState] = useState(false);
useEffect(() => {
if (node && window.IntersectionObserver) {
const intersectionObserver = new IntersectionObserver((entries) => {
const [{ isIntersecting = false } = {}] = entries;
setIsIntersectingState(isIntersecting);
}, options);
intersectionObserver.observe(node);
return () => {
intersectionObserver.disconnect();
};
}
}, [node, options]);
return isIntersectingState;
}
5 changes: 3 additions & 2 deletions client/src/placement-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export enum Status {
geoUnsupported = "geo_unsupported",
capReached = "cap_reached",
loading = "loading",
empty = "empty",
}

export interface Fallback {
Expand All @@ -22,8 +23,8 @@ export interface Fallback {

export interface PlacementData {
status: Status;
click: string;
view: string;
click?: string;
view?: string;
copy?: string;
image?: string;
fallback?: Fallback;
Expand Down
8 changes: 8 additions & 0 deletions client/src/ui/organisms/placement/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,14 @@ section.place.hp-main {
}
}

div.empty-place {
&.bottom-banner {
height: 152px;
position: absolute;
width: 1px;
}
}

.dark .top-banner {
--place-top-background: var(--place-top-background-dark);
--place-top-color: var(--place-top-color-dark);
Expand Down
152 changes: 72 additions & 80 deletions client/src/ui/organisms/placement/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { useCallback, useEffect, useRef } from "react";
import { useIsServer, usePageVisibility } from "../../../hooks";
import { useCallback, useEffect, useRef, useState } from "react";
import {
useIsIntersecting,
useIsServer,
usePageVisibility,
} from "../../../hooks";
import { User, useUserData } from "../../../user-context";

import "./index.scss";
Expand All @@ -13,8 +17,6 @@ import { BANNER_AI_HELP_CLICK } from "../../../telemetry/constants";

interface Timer {
timeout: number | null;
start: number | null;
notVisible?: boolean;
}

interface PlacementRenderArgs {
Expand All @@ -30,18 +32,21 @@ interface PlacementRenderArgs {
style: object;
}

function viewed(
pong: PlacementData,
observer: IntersectionObserver | null = null
) {
navigator?.sendBeacon?.(
`/pong/viewed?code=${encodeURIComponent(pong?.view)}${
pong?.fallback
? `&fallback=${encodeURIComponent(pong?.fallback?.view)}`
: ""
}`
);
observer?.disconnect();
const INTERSECTION_OPTIONS = {
root: null,
rootMargin: "0px",
threshold: 0.5,
};

function viewed(pong?: PlacementData) {
pong?.view &&
navigator.sendBeacon?.(
`/pong/viewed?code=${encodeURIComponent(pong?.view)}${
pong?.fallback
? `&fallback=${encodeURIComponent(pong?.fallback?.view)}`
: ""
}`
);
}

export function SidePlacement() {
Expand All @@ -56,6 +61,7 @@ export function SidePlacement() {
imageWidth={130}
imageHeight={100}
renderer={RenderSideOrTopBanner}
typ="side"
></PlacementInner>
);
}
Expand Down Expand Up @@ -130,6 +136,7 @@ export function TopPlacement() {
cta={placementData.top?.cta}
imageHeight={50}
renderer={RenderSideOrTopBanner}
typ="top-banner"
></PlacementInner>
)}
</div>
Expand Down Expand Up @@ -179,10 +186,21 @@ function HpPlacement({
imageHeight={imageHeight}
style={css}
renderer={RenderHpPlacement}
typ="hp-main"
></PlacementInner>
);
}

export function BottomBanner() {
return (
<PlacementInner
pong={{ status: Status.empty }}
renderer={RenderBottomBanner}
typ="bottom-banner"
/>
);
}

export function PlacementInner({
pong,
extraClassNames = [],
Expand All @@ -191,94 +209,64 @@ export function PlacementInner({
imageHeight,
style,
renderer,
typ,
}: {
pong: PlacementData;
pong?: PlacementData;
extraClassNames?: string[];
cta?: string;
imageWidth?: number;
imageHeight?: number;
style?: object;
renderer: (PlacementRenderArgs) => JSX.Element;
typ: string;
}) {
const isServer = useIsServer();
const user = useUserData();
const isVisible = usePageVisibility();
const gleanClick = useGleanClick();

const observer = useRef<IntersectionObserver | null>(null);
const timer = useRef<Timer>({ timeout: null, start: null });
const place = useCallback(
(node) => {
if (pong && node !== null && !observer.current) {
const observerOptions = {
root: null,
rootMargin: "0px",
threshold: [0.5],
};
const intersectionObserver = new IntersectionObserver((entries) => {
const [{ isIntersecting = false, intersectionRatio = 0 } = {}] =
entries;
if (isIntersecting && intersectionRatio >= 0.5) {
if (timer.current.timeout === null) {
timer.current = {
timeout: window?.setTimeout?.(() => {
viewed(pong, observer?.current);
gleanClick("pong: pong->viewed");
timer.current = { timeout: -1, start: -1 };
}, 1000),
start: Date.now(),
};
}
} else if (
!isIntersecting &&
intersectionRatio <= 0.5 &&
timer.current.timeout !== null
) {
clearTimeout(timer.current.timeout);
timer.current = { timeout: null, start: null };
}
}, observerOptions);
observer.current = intersectionObserver;
intersectionObserver.observe(node);
}
},
[pong, gleanClick]
);
const timer = useRef<Timer>({ timeout: null });

const { image, copy } = pong?.fallback || pong || {};
const { click } = pong || {};
useEffect(() => {
return () => observer.current?.disconnect();
const [node, setNode] = useState<HTMLElement>();
const isIntersecting = useIsIntersecting(node, INTERSECTION_OPTIONS);

const sendViewed = useCallback(() => {
viewed(pong);
gleanClick(`pong: pong->viewed ${typ}`);
timer.current = { timeout: -1 };
}, [pong, gleanClick, typ]);

const place = useCallback((node: HTMLElement | null) => {
if (node) {
setNode(node);
}
}, []);

useEffect(() => {
if (timer.current.timeout !== -1) {
// timeout !== -1 means the viewed has been sent
if (!isVisible && timer.current.timeout !== null) {
clearTimeout(timer.current.timeout);
timer.current = { timeout: null, start: null, notVisible: true };
} else if (
isVisible &&
pong &&
timer.current.notVisible &&
timer.current.timeout === null
) {
timer.current = {
timeout: window?.setTimeout?.(
() => viewed(pong, observer?.current),
1000
),
start: Date.now(),
};
// timeout !== -1 means the viewed has not been sent
if (isVisible && isIntersecting) {
if (timer.current.timeout === null) {
timer.current = {
timeout: window.setTimeout(sendViewed, 1000),
};
}
}
}
}, [isVisible, pong]);
return () => {
if (timer.current.timeout !== null && timer.current.timeout !== -1) {
clearTimeout(timer.current.timeout);
timer.current = { timeout: null };
}
};
}, [isVisible, isIntersecting, sendViewed]);

const { image, copy } = pong?.fallback || pong || {};
const { click } = pong || {};
return (
<>
{!isServer &&
click &&
image &&
((click && image) || pong?.status === Status.empty) &&
renderer({
place,
extraClassNames,
Expand Down Expand Up @@ -402,3 +390,7 @@ function RenderHpPlacement({
</section>
);
}

function RenderBottomBanner({ place }: PlacementRenderArgs) {
return <div ref={place} className="empty-place bottom-banner"></div>;
}

0 comments on commit b8da642

Please sign in to comment.