Skip to content

Commit

Permalink
Merge pull request #223 from guardian/give-tab-focus-on-desktop-notif…
Browse files Browse the repository at this point in the history
…ication-click

attempt to `focus` relevant browser tab when clicking a desktop/push notification
  • Loading branch information
twrichards authored Dec 19, 2022
2 parents 089d9a8 + 7353650 commit ea59677
Show file tree
Hide file tree
Showing 7 changed files with 157 additions and 100 deletions.
142 changes: 75 additions & 67 deletions client/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -267,24 +267,32 @@ export const PinBoardApp = ({ apolloClient, userEmail }: PinBoardAppProps) => {

const showDesktopNotification = (item?: Item) => {
if (item && item.userEmail !== userEmail) {
serviceWorkerIFrameRef.current?.contentWindow?.postMessage(
{
item: {
...item,
payload: item.payload && JSON.parse(item.payload),
} as ItemWithParsedPayload,
},
desktopNotificationsPreferencesUrl
setTimeout(
() =>
serviceWorkerIFrameRef.current?.contentWindow?.postMessage(
{
item: {
...item,
payload: item.payload && JSON.parse(item.payload),
} as ItemWithParsedPayload,
},
"*"
),
500
);
}
};

const clearDesktopNotificationsForPinboardId = (pinboardId: string) => {
serviceWorkerIFrameRef.current?.contentWindow?.postMessage(
{
clearNotificationsForPinboardId: pinboardId,
},
desktopNotificationsPreferencesUrl
setTimeout(
() =>
serviceWorkerIFrameRef.current?.contentWindow?.postMessage(
{
clearNotificationsForPinboardId: pinboardId,
},
"*"
),
1000
);
};

Expand Down Expand Up @@ -336,53 +344,53 @@ export const PinBoardApp = ({ apolloClient, userEmail }: PinBoardAppProps) => {
return (
<TelemetryContext.Provider value={sendTelemetryEvent}>
<ApolloProvider client={apolloClient}>
<Global styles={agateFontFaceIfApplicable} />
<HiddenIFrameForServiceWorker iFrameRef={serviceWorkerIFrameRef} />
<root.div
onDragOver={(event) =>
isGridDragEvent(event) && event.preventDefault()
<GlobalStateProvider
presetUnreadNotificationCount={presetUnreadNotificationCount}
userEmail={userEmail}
openPinboardIdBasedOnQueryParam={openPinboardIdBasedOnQueryParam}
preselectedComposerId={preSelectedComposerId}
payloadToBeSent={payloadToBeSent}
clearPayloadToBeSent={clearPayloadToBeSent}
isExpanded={isExpanded}
setIsExpanded={setIsExpanded}
userLookup={userLookup}
addEmailsToLookup={addEmailsToLookup}
hasWebPushSubscription={hasWebPushSubscription}
manuallyOpenedPinboardIds={manuallyOpenedPinboardIds || []}
setManuallyOpenedPinboardIds={setManuallyOpenedPinboardIds}
showNotification={showDesktopNotification}
clearDesktopNotificationsForPinboardId={
clearDesktopNotificationsForPinboardId
}
onDragEnter={(event) => {
if (isGridDragEvent(event)) {
event.preventDefault();
setIsDropTarget(true);
}
}}
onDragLeave={() => setIsDropTarget(false)}
onDragEnd={() => setIsDropTarget(false)}
onDragExit={() => setIsDropTarget(false)}
onDrop={(event) => {
if (isGridDragEvent(event)) {
event.preventDefault();
const payload = convertGridDragEventToPayload(event);
setPayloadToBeSent(payload);
setIsExpanded(true);
payload &&
sendTelemetryEvent?.(PINBOARD_TELEMETRY_TYPE.DRAG_AND_DROP, {
assetType: payload.type,
});
}
setIsDropTarget(false);
}}
>
<GlobalStateProvider
presetUnreadNotificationCount={presetUnreadNotificationCount}
userEmail={userEmail}
openPinboardIdBasedOnQueryParam={openPinboardIdBasedOnQueryParam}
preselectedComposerId={preSelectedComposerId}
payloadToBeSent={payloadToBeSent}
clearPayloadToBeSent={clearPayloadToBeSent}
isExpanded={isExpanded}
setIsExpanded={setIsExpanded}
userLookup={userLookup}
addEmailsToLookup={addEmailsToLookup}
hasWebPushSubscription={hasWebPushSubscription}
manuallyOpenedPinboardIds={manuallyOpenedPinboardIds || []}
setManuallyOpenedPinboardIds={setManuallyOpenedPinboardIds}
showNotification={showDesktopNotification}
clearDesktopNotificationsForPinboardId={
clearDesktopNotificationsForPinboardId
<Global styles={agateFontFaceIfApplicable} />
<HiddenIFrameForServiceWorker iFrameRef={serviceWorkerIFrameRef} />
<root.div
onDragOver={(event) =>
isGridDragEvent(event) && event.preventDefault()
}
onDragEnter={(event) => {
if (isGridDragEvent(event)) {
event.preventDefault();
setIsDropTarget(true);
}
}}
onDragLeave={() => setIsDropTarget(false)}
onDragEnd={() => setIsDropTarget(false)}
onDragExit={() => setIsDropTarget(false)}
onDrop={(event) => {
if (isGridDragEvent(event)) {
event.preventDefault();
const payload = convertGridDragEventToPayload(event);
setPayloadToBeSent(payload);
setIsExpanded(true);
payload &&
sendTelemetryEvent?.(PINBOARD_TELEMETRY_TYPE.DRAG_AND_DROP, {
assetType: payload.type,
});
}
setIsDropTarget(false);
}}
>
<TickContext.Provider value={lastTickTimestamp}>
{isInlineMode ? (
Expand All @@ -396,16 +404,16 @@ export const PinBoardApp = ({ apolloClient, userEmail }: PinBoardAppProps) => {
</React.Fragment>
)}
</TickContext.Provider>
</GlobalStateProvider>
</root.div>
{assetHandles.map((node, index) => (
<AddToPinboardButtonPortal
key={index}
node={node}
setPayloadToBeSent={setPayloadToBeSent}
expand={expandFloaty}
/>
))}
</root.div>
{assetHandles.map((node, index) => (
<AddToPinboardButtonPortal
key={index}
node={node}
setPayloadToBeSent={setPayloadToBeSent}
expand={expandFloaty}
/>
))}
</GlobalStateProvider>
</ApolloProvider>
</TelemetryContext.Provider>
);
Expand Down
16 changes: 9 additions & 7 deletions client/src/globalState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -384,15 +384,17 @@ export const GlobalStateProvider: React.FC<GlobalStateProviderProps> = ({

useEffect(() => {
window.addEventListener("message", (event) => {
if (
event.source !== window &&
Object.prototype.hasOwnProperty.call(event.data, "item")
) {
const item = event.data.item;
if (Object.prototype.hasOwnProperty.call(event.data, "item")) {
const item: Item = event.data.item;
window.focus();
setIsExpanded(true);
setSelectedPinboardId(item.pinboardId); // FIXME handle if said pinboard is not active (i.e. load data)
// TODO ideally highlight the item
if (
!preselectedPinboardId ||
preselectedPinboardId === item.pinboardId
) {
setSelectedPinboardId(item.pinboardId); // FIXME handle if said pinboard is not active (i.e. load data)
// highlighting the item is handled in ScrollableItems component
}
}
});
}, []);
Expand Down
25 changes: 16 additions & 9 deletions client/src/push-notifications/serviceWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,19 +88,25 @@ self.addEventListener("notificationclick", (event: any) => {
.matchAll({
includeUncontrolled: true,
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.then((clients: any[]) => {
.then((clients: WindowClient[]) => {
if (!event.action && clients.length > 0) {
const client =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
clients.find((_: any) => _.id === item.clientId) || clients[0];
client.postMessage({
item,
});
clients.find(
(client: WindowClient) =>
client.id === item.clientId ||
client.url?.includes(
`?${OPEN_PINBOARD_QUERY_PARAM}=${item.pinboardId}`
)
) || clients[0];
client.postMessage({ item }); // send this item to the client, so ideally it can highlight the message from the notification
console.log(
"Pinboard push notification click, attempting to focus client"
);
return client.focus();
} else if (event.action === "grid") {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
self.clients.openWindow(
return self.clients.openWindow(
`https://media.${toolsDomain}/search?${openToPinboardQueryParam}&${openToPinboardItemIdQueryParam}`.replace(
".code.",
".test."
Expand All @@ -113,10 +119,11 @@ self.addEventListener("notificationclick", (event: any) => {
) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
self.clients.openWindow(
return self.clients.openWindow(
`https://workflow.${toolsDomain}/redirect/${item.pinboardId}?${EXPAND_PINBOARD_QUERY_PARAM}=true&${openToPinboardItemIdQueryParam}`
);
}
return Promise.resolve();
})
);
});
27 changes: 20 additions & 7 deletions client/src/pushNotificationPreferences.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import React, { useContext } from "react";
import { agateSans } from "../fontNormaliser";
import { TelemetryContext, PINBOARD_TELEMETRY_TYPE } from "./types/Telemetry";
import { useGlobalStateContext } from "./globalState";
import { OPEN_PINBOARD_QUERY_PARAM } from "../../shared/constants";
import { isPinboardData } from "../../shared/graphql/extraTypes";
interface PushNotificationPreferencesOpenerProps {
hasWebPushSubscription: boolean | null | undefined;
}
Expand Down Expand Up @@ -50,10 +53,20 @@ interface HiddenIFrameForServiceWorkerProps {

export const HiddenIFrameForServiceWorker = ({
iFrameRef,
}: HiddenIFrameForServiceWorkerProps) => (
<iframe
ref={iFrameRef}
src={desktopNotificationsPreferencesUrl}
style={{ display: "none" }}
/>
);
}: HiddenIFrameForServiceWorkerProps) => {
const { selectedPinboardId, preselectedPinboard } = useGlobalStateContext();
const maybePinboardId = isPinboardData(preselectedPinboard)
? preselectedPinboard.id
: selectedPinboardId;
return (
<iframe
ref={iFrameRef}
src={`${desktopNotificationsPreferencesUrl}${
maybePinboardId
? `?${OPEN_PINBOARD_QUERY_PARAM}=${maybePinboardId}`
: ""
}`}
style={{ display: "none" }}
/>
);
};
40 changes: 31 additions & 9 deletions client/src/scrollableItems.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -206,12 +206,15 @@ export const ScrollableItems = ({
const setRef = (itemID: string) => (node: HTMLDivElement) => {
refMap.current[itemID] = node;
};
const scrollToItem = (itemID: string) => {
const targetElement = refMap.current[itemID];
targetElement?.scrollIntoView({ behavior: "smooth" });
targetElement.style.animation = "highlight-item 0.5s linear infinite"; // see panel.tsx for definition of 'highlight-item' animation
setTimeout(() => (targetElement.style.animation = ""), 2500);
};
const scrollToItem = useCallback(
(itemID: string) => {
const targetElement = refMap.current[itemID];
targetElement?.scrollIntoView({ behavior: "smooth" });
targetElement.style.animation = "highlight-item 0.5s linear infinite"; // see panel.tsx for definition of 'highlight-item' animation
setTimeout(() => (targetElement.style.animation = ""), 2500);
},
[refMap.current]
);

useLayoutEffect(() => {
if (
Expand All @@ -221,14 +224,32 @@ export const ScrollableItems = ({
const queryParams = new URLSearchParams(window.location.search);
const itemIdToScrollTo = queryParams.get(PINBOARD_ITEM_ID_QUERY_PARAM);
if (itemIdToScrollTo && refMap.current[itemIdToScrollTo]) {
setTimeout(() => scrollToItem(itemIdToScrollTo), 250);
setIsScrolledToBottom(false);
setTimeout(() => {
console.log("scrolling to item with id", itemIdToScrollTo);
scrollToItem(itemIdToScrollTo);
}, 1000);
setHasProcessedItemIdInURL(true);
} else if (Object.keys(refMap.current).length > 0) {
setHasProcessedItemIdInURL(true);
}
} else {
setHasProcessedItemIdInURL(true);
}
});

useEffect(() => {
const handleItemFromServiceWorker = (event: MessageEvent) => {
if (Object.prototype.hasOwnProperty.call(event.data, "item")) {
const item: Item = event.data.item;
if (pinboardId === item.pinboardId) {
setTimeout(() => scrollToItem(item.id), 250);
}
}
};
window.addEventListener("message", handleItemFromServiceWorker);
return () =>
window.removeEventListener("message", handleItemFromServiceWorker);
}, []);

return (
<div
ref={setScrollableAreaRef}
Expand Down Expand Up @@ -264,6 +285,7 @@ export const ScrollableItems = ({
item.claimedByEmail,
userLookup,
lastItemSeenByUsersForItemIDLookup,
scrollToItem,
]
)
)}
Expand Down
6 changes: 5 additions & 1 deletion client/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
"@types/jest",
"@types/webpack-env"
],
"lib": ["esnext", "dom"]
"lib": [
"esnext",
"dom",
"webworker"
]
}
}
1 change: 1 addition & 0 deletions shared/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const NOTIFICATIONS_LAMBDA_BASENAME = "pinboard-notifications-lambda";
export const getNotificationsLambdaFunctionName = (stage: Stage) =>
`${NOTIFICATIONS_LAMBDA_BASENAME}-${stage}`;

// FIXME we should probably 'eat' these query params once used (using `history.replaceState`)
export const OPEN_PINBOARD_QUERY_PARAM = "pinboardId";
export const PINBOARD_ITEM_ID_QUERY_PARAM = "pinboardItemId";
export const EXPAND_PINBOARD_QUERY_PARAM = "expandPinboard";

0 comments on commit ea59677

Please sign in to comment.