diff --git a/src/core/client/embed/PymControl.ts b/src/core/client/embed/PymControl.ts index 883954d248..8f1e55e92f 100644 --- a/src/core/client/embed/PymControl.ts +++ b/src/core/client/embed/PymControl.ts @@ -15,7 +15,7 @@ export const defaultPymControlFactory: PymControlFactory = (config) => new PymControl(config); export default class PymControl { - public pym: pym.Parent; + private pym: pym.Parent; private cleanups: CleanupCallback[]; constructor(config: PymControlConfig) { diff --git a/src/core/client/embed/StreamEmbed.ts b/src/core/client/embed/StreamEmbed.ts index b4d1573610..6f7030cf92 100644 --- a/src/core/client/embed/StreamEmbed.ts +++ b/src/core/client/embed/StreamEmbed.ts @@ -12,6 +12,7 @@ import { withConfig, withEventEmitter, withIOSSafariWidthWorkaround, + withKeypressEvent, withLiveCommentCount, withPymStorage, withSetCommentID, @@ -22,7 +23,6 @@ import PymControl, { defaultPymControlFactory, PymControlFactory, } from "./PymControl"; -import hookUpWindowEvents from "./WindowEvents"; export interface StreamEmbedConfig { storyID?: string; @@ -150,6 +150,7 @@ export class StreamEmbed { withPymStorage(localStorage, "localStorage"), withPymStorage(sessionStorage, "sessionStorage"), withConfig(externalConfig), + withKeypressEvent, ]; const query = stringifyQuery({ @@ -168,8 +169,6 @@ export class StreamEmbed { decorators: streamDecorators, url, }); - - hookUpWindowEvents(this.pymControl); } } diff --git a/src/core/client/embed/WindowEvents.ts b/src/core/client/embed/WindowEvents.ts deleted file mode 100644 index 91e2834ecf..0000000000 --- a/src/core/client/embed/WindowEvents.ts +++ /dev/null @@ -1,17 +0,0 @@ -import PymControl from "./PymControl"; - -export default function hookUpWindowEvents(pym: PymControl) { - window.addEventListener("keypress", (e: KeyboardEvent) => { - const payload = { - event: "keypress", - data: { - code: e.code, - key: e.key, - charCode: e.charCode, - shiftKey: e.shiftKey, - }, - }; - - pym.sendMessage("message", JSON.stringify(payload)); - }); -} diff --git a/src/core/client/embed/decorators/index.ts b/src/core/client/embed/decorators/index.ts index f795cfa68c..9611dc0095 100644 --- a/src/core/client/embed/decorators/index.ts +++ b/src/core/client/embed/decorators/index.ts @@ -1,9 +1,10 @@ export { Decorator, CleanupCallback } from "./types"; export { default as withAutoHeight } from "./withAutoHeight"; export { default as withClickEvent } from "./withClickEvent"; -export { default as withSetCommentID } from "./withSetCommentID"; -export { default as withEventEmitter } from "./withEventEmitter"; -export { default as withPymStorage } from "./withPymStorage"; export { default as withConfig } from "./withConfig"; -export { default as withLiveCommentCount } from "./withLiveCommentCount"; +export { default as withEventEmitter } from "./withEventEmitter"; export { default as withIOSSafariWidthWorkaround } from "./withIOSSafariWidthWorkaround"; +export { default as withKeypressEvent } from "./withKeypressEvent"; +export { default as withLiveCommentCount } from "./withLiveCommentCount"; +export { default as withPymStorage } from "./withPymStorage"; +export { default as withSetCommentID } from "./withSetCommentID"; diff --git a/src/core/client/embed/decorators/withKeypressEvent.ts b/src/core/client/embed/decorators/withKeypressEvent.ts new file mode 100644 index 0000000000..ed5f0ecf6c --- /dev/null +++ b/src/core/client/embed/decorators/withKeypressEvent.ts @@ -0,0 +1,22 @@ +import { Decorator } from "./types"; + +const withKeypressEvent: Decorator = (pym) => { + const handleKeypress = (e: KeyboardEvent) => { + const payload = { + key: e.key, + shiftKey: e.shiftKey, + }; + + pym.sendMessage("keypress", JSON.stringify(payload)); + }; + + document.addEventListener("keypress", handleKeypress); + + // Return cleanup callback. + return () => { + // Remove the event listeners. + document.removeEventListener("keypress", handleKeypress); + }; +}; + +export default withKeypressEvent; diff --git a/src/core/client/framework/helpers/index.ts b/src/core/client/framework/helpers/index.ts index 714861efec..c32cec725d 100644 --- a/src/core/client/framework/helpers/index.ts +++ b/src/core/client/framework/helpers/index.ts @@ -1,17 +1,18 @@ -export { default as getViewer } from "./getViewer"; -export { default as getViewerSourceID } from "./getViewerSourceID"; -export { default as getURLWithCommentID } from "./getURLWithCommentID"; -export { default as urls } from "./urls"; -export { default as createContextHOC } from "./createContextHOC"; -export { default as redirectOAuth2 } from "./redirectOAuth2"; -export { default as getParamsFromHashAndClearIt } from "./getParamsFromHashAndClearIt"; -export { default as getParamsFromHash } from "./getParamsFromHash"; export { default as clearHash } from "./clearHash"; -export { default as roleIsAtLeast } from "./roleIsAtLeast"; -export { default as resolveStoryURL } from "./resolveStoryURL"; +export { default as createContextHOC } from "./createContextHOC"; export { default as detectCountScript } from "./detectCountScript"; -export { default as potentiallyInjectAxe } from "./potentiallyInjectAxe"; +export { default as getModerationLink, QUEUE_NAME } from "./getModerationLink"; +export { default as getParamsFromHash } from "./getParamsFromHash"; +export { default as getParamsFromHashAndClearIt } from "./getParamsFromHashAndClearIt"; +export { default as getURLWithCommentID } from "./getURLWithCommentID"; +export { default as getViewer } from "./getViewer"; +export { default as getViewerSourceID } from "./getViewerSourceID"; export { default as injectConditionalPolyfills } from "./injectConditionalPolyfills"; +export { default as onPymMessage } from "./onPymMessage"; export { default as polyfillCSSVars } from "./polyfillCSSVars"; export { default as polyfillIntlLocale } from "./polyfillIntlLocale"; -export { default as getModerationLink, QUEUE_NAME } from "./getModerationLink"; +export { default as potentiallyInjectAxe } from "./potentiallyInjectAxe"; +export { default as redirectOAuth2 } from "./redirectOAuth2"; +export { default as resolveStoryURL } from "./resolveStoryURL"; +export { default as roleIsAtLeast } from "./roleIsAtLeast"; +export { default as urls } from "./urls"; diff --git a/src/core/client/framework/helpers/onPymMessage.ts b/src/core/client/framework/helpers/onPymMessage.ts new file mode 100644 index 0000000000..5c7834caaf --- /dev/null +++ b/src/core/client/framework/helpers/onPymMessage.ts @@ -0,0 +1,20 @@ +import { Child, MessageCallback } from "pym.js"; + +function onPymMessage( + child: Child, + messageType: string, + callback: MessageCallback +) { + child.onMessage(messageType, callback); + return () => { + const index = child.messageHandlers[messageType].indexOf(callback); + if (index > -1) { + child.messageHandlers[messageType].splice(index, 1); + if (child.messageHandlers[messageType].length === 0) { + delete child.messageHandlers[messageType]; + } + } + }; +} + +export default onPymMessage; diff --git a/src/core/client/framework/lib/bootstrap/createManaged.tsx b/src/core/client/framework/lib/bootstrap/createManaged.tsx index 81baaa90b2..d51c827f38 100644 --- a/src/core/client/framework/lib/bootstrap/createManaged.tsx +++ b/src/core/client/framework/lib/bootstrap/createManaged.tsx @@ -8,6 +8,7 @@ import { Environment, RecordSource, Store } from "relay-runtime"; import { v1 as uuid } from "uuid"; import { LanguageCode } from "coral-common/helpers/i18n"; +import { onPymMessage } from "coral-framework/helpers"; import polyfillIntlLocale from "coral-framework/helpers/polyfillIntlLocale"; import { getBrowserInfo } from "coral-framework/lib/browserInfo"; import { ErrorReporter } from "coral-framework/lib/errors"; @@ -303,14 +304,7 @@ export default async function createManaged({ let registerClickFarAway: ClickFarAwayRegister | undefined; if (pym) { registerClickFarAway = (cb) => { - pym.onMessage("click", cb); - // Return unlisten callback. - return () => { - const index = pym.messageHandlers.click.indexOf(cb); - if (index > -1) { - pym.messageHandlers.click.splice(index, 1); - } - }; + return onPymMessage(pym, "click", cb); }; } diff --git a/src/core/client/stream/common/KeyboardShortcuts/KeyboardShortcuts.tsx b/src/core/client/stream/common/KeyboardShortcuts/KeyboardShortcuts.tsx index c810b0dc79..fa60c4fe17 100644 --- a/src/core/client/stream/common/KeyboardShortcuts/KeyboardShortcuts.tsx +++ b/src/core/client/stream/common/KeyboardShortcuts/KeyboardShortcuts.tsx @@ -1,189 +1,147 @@ -import { FunctionComponent, useCallback, useEffect, useState } from "react"; +import { FunctionComponent, useEffect } from "react"; +import { onPymMessage } from "coral-framework/helpers"; import { useCoralContext } from "coral-framework/lib/bootstrap"; +interface KeyboardEventData { + key: string; + shiftKey: boolean; +} + interface KeyStop { - id?: string; + id: string; isLoadMore: boolean; - element: Element; + element: HTMLElement; } -const KeyboardShortcuts: FunctionComponent = () => { - const { pym } = useCoralContext(); - - const [currentStop, setCurrentStop] = useState(null); - - const scrollToElement = useCallback( - (stop: KeyStop) => { - if (!pym || !stop || !stop.id) { - return; - } - - const id = `comment-${stop.id}`; - - pym.scrollParentToChildEl(id); - }, - [pym] - ); - - const getKeyStops = useCallback(() => { - const matches = document.querySelectorAll(`[data-keystop="true"]`); - return matches; - }, []); +const getKeyStops = () => + document.querySelectorAll("[data-key-stop]"); - const toKeyStop = useCallback((el: Element) => { - const id = el.attributes.getNamedItem("data-keyid"); - const isLoadMore = el.attributes.getNamedItem("data-isloadmore"); +const toKeyStop = (element: HTMLElement): KeyStop => { + const id = element.id; + const isLoadMore = "isLoadMore" in element.dataset; - return { - element: el, - id: id ? id.value : undefined, - isLoadMore: isLoadMore ? isLoadMore.value === "true" : false, - }; - }, []); - - const findNextElement = useCallback((): KeyStop | null => { - const stops = getKeyStops(); - - if (currentStop === null && stops.length > 0) { - const stop = stops[0]; - return toKeyStop(stop); - } else if (currentStop !== null && currentStop.id && stops.length > 0) { - let index = -1; - stops.forEach((el, key) => { - if ( - el.attributes.getNamedItem("data-keyid")?.value === currentStop.id - ) { - index = key; - } - }); - - if (index >= 0 && index + 1 < stops.length) { - const stop = stops[index + 1]; - return toKeyStop(stop); - } - } + return { + element, + id, + isLoadMore, + }; +}; +const findNextElement = (currentStop: KeyStop | null): KeyStop | null => { + const stops = getKeyStops(); + if (stops.length === 0) { return null; - }, [getKeyStops, currentStop, toKeyStop]); + } + + // There is no current stop, so return the first one! + if (!currentStop) { + return toKeyStop(stops[0]); + } + + // eslint-disable-next-line @typescript-eslint/prefer-for-of + for (let index = 0; index < stops.length; index++) { + if (stops[index].id === currentStop.id) { + if (index === stops.length - 1) { + // We're at the last one, get the first one! + return toKeyStop(stops[0]); + } - const findPreviousElement = useCallback((): KeyStop | null => { - if (!currentStop) { - return null; + // Go one more element forward. + return toKeyStop(stops[index + 1]); } + } - const stops = getKeyStops(); + // We couldn't find your current element to get the next one! Go to the first + // stop. + return toKeyStop(stops[0]); +}; - let index = -1; - stops.forEach((el, key) => { - if (el.attributes.getNamedItem("data-keyid")?.value === currentStop.id) { - index = key; +const findPreviousElement = (currentStop: KeyStop | null): KeyStop | null => { + const stops = getKeyStops(); + if (stops.length === 0) { + return null; + } + + // There is no current stop, get the last one! + if (!currentStop) { + return toKeyStop(stops[stops.length - 1]); + } + + // eslint-disable-next-line @typescript-eslint/prefer-for-of + for (let index = 0; index < stops.length; index++) { + if (stops[index].id === currentStop.id) { + if (index === 0) { + // We are the first element, get the last one! + return toKeyStop(stops[stops.length - 1]); } - }); - if (index - 1 >= 0 && index < stops.length) { - const stop = stops[index - 1]; - return toKeyStop(stop); + // Get one element before the current index! + return toKeyStop(stops[index - 1]); } + } - return null; - }, [currentStop, getKeyStops, toKeyStop]); + // We couldn't find your current element to get the previous one! Go to the + // first stop. + return toKeyStop(stops[0]); +}; - const jumpToNextElement = useCallback(() => { - const stop = findNextElement(); - if (!stop) { +const KeyboardShortcuts: FunctionComponent = ({ children }) => { + const { pym } = useCoralContext(); + useEffect(() => { + if (!pym) { return; } - if (stop.isLoadMore) { - const clickEvent = document.createEvent("MouseEvent"); - clickEvent.initMouseEvent( - "click", - true, - true, - window, - 0, - 0, - 0, - 0, - 0, - false, - false, - false, - false, - 0, - null - ); - stop.element.dispatchEvent(clickEvent); - } else { - scrollToElement(stop); - setCurrentStop(stop); - } - }, [findNextElement, scrollToElement]); - - const jumpToPreviousElement = useCallback(() => { - const stop = findPreviousElement(); - if (!stop) { - return; - } + // Store a reference to the current stop. + let currentStop: KeyStop | null = null; - scrollToElement(stop); - setCurrentStop(stop); - }, [findPreviousElement, scrollToElement]); + const handle = (event: KeyboardEvent | string) => { + let data: KeyboardEventData; - const handleKeyMessage = useCallback( - (e) => { try { - if (!e.data) { - return; + if (typeof event === "string") { + data = JSON.parse(event); + } else { + data = event; + } + } catch (err) { + if (process.env.NODE_ENV !== "production") { + // eslint-disable-next-line no-console + console.error(err); } - const dataString: string = e.data; - const dataIndex = dataString.indexOf("{"); - const p = dataString.substring(dataIndex, dataString.length); + return; + } - const payload = JSON.parse(p); + let stop: KeyStop | null = null; + if (data.shiftKey && data.key === "C") { + stop = findPreviousElement(currentStop); + } else if (data.key === "c") { + stop = findNextElement(currentStop); + } - if ( - payload.event === "keypress" && - payload.data.shiftKey && - payload.data.key === "C" - ) { - jumpToPreviousElement(); - } else if (payload.event === "keypress" && payload.data.key === "c") { - jumpToNextElement(); - } - } catch { - // ignore + if (!stop) { + return; } - }, - [jumpToNextElement, jumpToPreviousElement] - ); - const handleKeyPress = useCallback( - (e: KeyboardEvent) => { - try { - if (e.shiftKey && e.key === "C") { - jumpToPreviousElement(); - } else if (e.key === "c") { - jumpToNextElement(); - } - } catch { - // ignore + pym.scrollParentToChildEl(stop.id); + + if (stop.isLoadMore) { + stop.element.click(); + } else { + currentStop = stop; } - }, - [jumpToNextElement, jumpToPreviousElement] - ); + }; - useEffect(() => { - window.addEventListener("message", handleKeyMessage); - window.addEventListener("keypress", handleKeyPress); + const unsubscribe = onPymMessage(pym, "keypress", handle); + window.addEventListener("keypress", handle); return () => { - window.removeEventListener("message", handleKeyMessage); - window.removeEventListener("keypress", handleKeyPress); + unsubscribe(); + window.removeEventListener("keypress", handle); }; - }, [handleKeyMessage, handleKeyPress]); + }, [pym]); return null; }; diff --git a/src/core/client/stream/tabs/Comments/Comment/CommentContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/CommentContainer.tsx index dd7c1ac279..790fec6fe1 100644 --- a/src/core/client/stream/tabs/Comments/Comment/CommentContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/CommentContainer.tsx @@ -341,9 +341,8 @@ export const CommentContainer: FunctionComponent = ({ )} id={`comment-${comment.id}`} data-testid={`comment-${comment.id}`} - data-keystop={true} - data-isLoadMore={false} - data-keyid={comment.id} + // Added for keyboard shortcut support. + data-key-stop > {!comment.deleted && ( diff --git a/src/core/client/stream/tabs/Comments/ReplyList/ReplyList.tsx b/src/core/client/stream/tabs/Comments/ReplyList/ReplyList.tsx index 9b25c96c60..3768f97c28 100644 --- a/src/core/client/stream/tabs/Comments/ReplyList/ReplyList.tsx +++ b/src/core/client/stream/tabs/Comments/ReplyList/ReplyList.tsx @@ -106,8 +106,9 @@ const ReplyList: FunctionComponent = (props) => { variant="outlined" color="secondary" fullWidth - data-keystop={true} - data-isLoadMore={true} + // Added for keyboard shortcut support. + data-key-stop + data-is-load-more > Show All Replies diff --git a/src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/AllCommentsTabContainer.tsx b/src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/AllCommentsTabContainer.tsx index f92f886a3b..a46f3fbc66 100644 --- a/src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/AllCommentsTabContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/AllCommentsTabContainer.tsx @@ -259,6 +259,7 @@ export const AllCommentsTabContainer: FunctionComponent = ({ {hasMore && ( diff --git a/src/types/pym.d.ts b/src/types/pym.d.ts index 41d7da3661..b1f8fc8041 100644 --- a/src/types/pym.d.ts +++ b/src/types/pym.d.ts @@ -1,4 +1,6 @@ declare module "pym.js" { + export type MessageCallback = (message: string) => void; + export interface ChildSettings { /** * Callback invoked after receiving a resize event from the parent, @@ -39,7 +41,7 @@ declare module "pym.js" { parentTitle: string; /** Stores the registered messageHandlers for each messageType */ - messageHandlers: Record void>>; + messageHandlers: Record>; /** RegularExpression to validate the received messages */ messageRegex: RegExp;