import { Localized } from "@fluent/react/compat" ; import { ListenerFn } from "eventemitter2" ; import React , { FunctionComponent , useCallback , useContext , useEffect , useState , } from "react" ; import { Environment , graphql } from "react-relay" ; import { waitFor } from "coral-common/helpers" ; import { onPymMessage } from "coral-framework/helpers" ; import { useInMemoryState } from "coral-framework/hooks" ; import { useCoralContext } from "coral-framework/lib/bootstrap" ; import { globalErrorReporter } from "coral-framework/lib/errors" ; import { useLocal , useMutation } from "coral-framework/lib/relay" ; import { LOCAL_ID } from "coral-framework/lib/relay/localState" ; import lookup from "coral-framework/lib/relay/lookup" ; import CLASSES from "coral-stream/classes" ; import { CloseMobileToolbarEvent , JumpToNextCommentEvent , JumpToNextUnseenCommentEvent , JumpToPreviousCommentEvent , JumpToPreviousUnseenCommentEvent , LoadMoreAllCommentsEvent , ShowAllRepliesEvent , UnmarkAllEvent , ViewNewCommentsNetworkEvent , } from "coral-stream/events" ; import computeCommentElementID from "coral-stream/tabs/Comments/Comment/computeCommentElementID" ; import parseCommentElementID from "coral-stream/tabs/Comments/Comment/parseCommentElementID" ; import { CommentSeenContext , COMMIT_SEEN_EVENT , } from "coral-stream/tabs/Comments/commentSeen/CommentSeenContext" ; import useZKeyEnabled from "coral-stream/tabs/Comments/commentSeen/useZKeyEnabled" ; import useAMP from "coral-stream/tabs/Comments/helpers/useAMP" ; import { Button , ButtonIcon , Flex } from "coral-ui/components/v2" ; import { MatchMedia } from "coral-ui/components/v2/MatchMedia/MatchMedia" ; import { KeyboardShortcuts_local } from "coral-stream/__generated__/KeyboardShortcuts_local.graphql" ; import MobileToolbar from "./MobileToolbar" ; import { SetTraversalFocus } from "./SetTraversalFocus" ; import styles from "./KeyboardShortcuts.css" ; interface Props { loggedIn : boolean ; } export interface KeyboardEventData { key : string ; shiftKey ? : boolean ; altKey ? : boolean ; ctrlKey ? : boolean ; metaKey ? : boolean ; } interface KeyStop { id : string ; isLoadMore : boolean ; element : HTMLElement ; notSeen : boolean ; isViewNew : boolean ; } interface TraverseOptions { noCircle ? : boolean ; skipSeen ? : boolean ; skipLoadMore ? : boolean ; } const toKeyStop = ( element ) : KeyStop => { return { element , id : element . id , isLoadMore : "isLoadMore" in element . dataset , notSeen : "notSeen" in element . dataset , isViewNew : "isViewNew" in element . dataset , } ; } ; const matchTraverseOptions = ( stop , options ) => { if ( options . skipLoadMore && stop . isLoadMore ) { return false ; } if ( options . skipSeen && ! stop . notSeen && ! stop . isLoadMore ) { return false ; } return true ; } ; const getKeyStops = ( window ) => { const stops = [ ] ; window . document . querySelectorAll < HTMLElement > ( "[data-key-stop]" ) . forEach ( ( el ) => stops . push ( toKeyStop ( el ) ) ) ; return stops ; } ; const shouldDisableUnmarkAll = ( window ) => { return ( window . document . querySelector < HTMLElement > ( `.${ CLASSES . comment . notSeen }` ) === null ) ; } ; const getFirstKeyStop = ( stops , options = { } ) => { for ( const stop of stops ) { if ( ! matchTraverseOptions ( stop , options ) ) { continue ; } return stop ; } return null ; } ; const getLastKeyStop = ( stops , options = { } ) => { for ( const stop of stops . reverse ( ) ) { if ( ! matchTraverseOptions ( stop , options ) ) { continue ; } return stop ; } return null ; } ; const getCurrentKeyStop = ( window , relayEnvironment ) => { const currentCommentID = lookup ( relayEnvironment , LOCAL_ID ) . commentWithTraversalFocus ; const currentCommentElement = window . document . getElementById ( computeCommentElementID ( currentCommentID ) ) ; if ( ! currentCommentElement ) { return null ; } return toKeyStop ( currentCommentElement ) ; } ; const findNextKeyStop = ( window , currentStop , options = { } ) : KeyStop | null => { const stops = getKeyStops ( window ) ; if ( stops . length === 0 ) { return null ; } if ( ! currentStop ) { return getFirstKeyStop ( stops , options ) ; } let passedCurrentStop = false ; for ( let index = 0 ; index < stops . length ; index ++ ) { if ( stops [ index ] . id === currentStop . id ) { passedCurrentStop = true ; continue ; } if ( passedCurrentStop ) { if ( ! matchTraverseOptions ( stops [ index ] , options ) ) { continue ; } return stops [ index ] ; } } if ( options . noCircle ) { return null ; } return getFirstKeyStop ( stops , options ) ; } ; const findPreviousKeyStop = ( window , currentStop , options = { } ) : KeyStop | null => { const stops = getKeyStops ( window ) ; if ( stops . length === 0 ) { return null ; } if ( ! currentStop ) { return getLastKeyStop ( stops , options ) ; } let passedCurrentStop = false ; for ( let index = stops . length - 1 ; index >= 0 ; index -- ) { if ( stops [ index ] . id === currentStop . id ) { passedCurrentStop = true ; continue ; } if ( passedCurrentStop ) { if ( ! matchTraverseOptions ( stops [ index ] , options ) ) { continue ; } return stops [ index ] ; } } if ( options . noCircle ) { return null ; } return getLastKeyStop ( stops , options ) ; } ; const NextUnread = ( < Localized id = "comments-mobileToolbar-nextUnread" > < span > Next unread < /span> < /Localized>); const getNextAction = ( window , relayEnvironment , options = { } ) => { const currentStop = getCurrentKeyStop ( window , relayEnvironment ) ; const next = findNextKeyStop ( window , currentStop , options ) ; if ( next ) { if ( next . isLoadMore ) { return { element : NextUnread , disabled : Boolean ( ( next . element as HTMLInputElement ) . disabled ) , } ; } else { return { element : NextUnread , disabled : false , } ; } } return null ; } ; const eventsOfInterest = [ "mutation.viewNew" , "mutation.SetTraversalFocus" , "subscription.subscribeToCommentEntered.data" , ShowAllRepliesEvent . nameSuccess , LoadMoreAllCommentsEvent . nameSuccess , ViewNewCommentsNetworkEvent . nameSuccess , COMMIT_SEEN_EVENT , ] ; const loadMoreEvents = [ ShowAllRepliesEvent . nameSuccess , LoadMoreAllCommentsEvent . nameSuccess , ViewNewCommentsNetworkEvent . nameSuccess , ] ; const KeyboardShortcuts = ( { loggedIn } ) => { const { pym , relayEnvironment , renderWindow , eventEmitter , browserInfo , } = useCoralContext ( ) ; const [ toolbarClosed , setToolbarClosed ] = useInMemoryState ( "keyboardShortcutMobileToolbarClosed" , false ) ; const setTraversalFocus = useMutation ( SetTraversalFocus ) ; const [ , setLocal ] = useLocal < KeyboardShortcuts_local > ( graphql `
    fragment KeyboardShortcuts_local on Local {
      keyboardShortcutsConfig {
        key
        source
        reverse
      }
    }
  ` ) ; const amp = useAMP ( ) ; const zKeyEnabled = useZKeyEnabled ( ) ; const { commitSeen , enabled } = useContext ( CommentSeenContext ) ; const [ nextZAction , setNextZAction ] = useState < React . ReactChild | null > ( NextUnread ) ; const [ disableZAction , setDisableZAction ] = useState < boolean > ( true ) ; const [ disableUnmarkAction , setDisableUnmarkAction ] = useState < boolean > ( true ) ; const updateButtonStates = useCallback ( ( ) => { const nextAction = getNextAction ( renderWindow , relayEnvironment , { skipSeen : true , } ) ; if ( ! nextAction ) { if ( ! disableZAction ) { setDisableZAction ( true ) ; } if ( ! disableUnmarkAction ) { setDisableUnmarkAction ( true ) ; } return ; } if ( nextAction . element !== nextZAction ) { setNextZAction ( nextAction . element ) ; } if ( nextAction . disabled !== disableZAction ) { setDisableZAction ( nextAction . disabled ) ; } if ( shouldDisableUnmarkAll ( renderWindow ) !== disableUnmarkAction ) { setDisableUnmarkAction ( ! disableUnmarkAction ) ; } } , [ disableUnmarkAction , disableZAction , nextZAction , relayEnvironment , renderWindow , ] ) ; const unmarkAll = useCallback ( ( config ) => { UnmarkAllEvent . emit ( eventEmitter , { source : config . source } ) ; commitSeen ( ) ; if ( ! disableUnmarkAction ) { setDisableUnmarkAction ( true ) ; } } , [ commitSeen , disableUnmarkAction , eventEmitter ] ) ; const handleUnmarkAllButton = useCallback ( ( ) => { unmarkAll ( { source : "mobileToolbar" } ) ; } , [ unmarkAll ] ) ; const handleCloseToolbarButton = useCallback ( ( ) => { setToolbarClosed ( true ) ; CloseMobileToolbarEvent . emit ( eventEmitter ) ; } , [ eventEmitter , setToolbarClosed ] ) ; const traverse = useCallback ( ( config ) => { if ( ! pym ) { return ; } let stop = null ; let traverseOptions ; if ( config . key === "c" || ( config . key === "z" && zKeyEnabled ) ) { if ( config . key === "z" ) { traverseOptions = { skipSeen : true , } ; } const currentStop = getCurrentKeyStop ( renderWindow , relayEnvironment ) ; if ( config . reverse ) { stop = findPreviousKeyStop ( renderWindow , currentStop , traverseOptions ) ; } else { stop = findNextKeyStop ( renderWindow , currentStop , traverseOptions ) ; } } if ( ! stop ) { return ; } if ( config . key === "c" ) { if ( config . reverse ) { JumpToPreviousCommentEvent . emit ( eventEmitter , { source : config . source , } ) ; } else { JumpToNextCommentEvent . emit ( eventEmitter , { source : config . source } ) ; } } else if ( config . key === "z" ) { if ( config . reverse ) { JumpToPreviousUnseenCommentEvent . emit ( eventEmitter , { source : config . source , } ) ; } else { JumpToNextUnseenCommentEvent . emit ( eventEmitter , { source : config . source , } ) ; } } const offset = renderWindow . document . getElementById ( stop . id ) ! . getBoundingClientRect ( ) . top + renderWindow . pageYOffset - 150 ; pym . scrollParentToChildPos ( offset ) ; if ( stop . isLoadMore ) { if ( ! stop . isViewNew ) { let prevOrNextStop = findPreviousKeyStop ( renderWindow , stop , { skipLoadMore : true , noCircle : true , } ) ; if ( ! prevOrNextStop ) { prevOrNextStop = findNextKeyStop ( renderWindow , stop , { skipLoadMore : true , noCircle : true , } ) ; } if ( prevOrNextStop ) { void setTraversalFocus ( { commentID : parseCommentElementID ( prevOrNextStop . id ) , commentSeenEnabled : enabled , } ) ; prevOrNextStop . element . focus ( ) ; } } stop . element . click ( ) ; } else { void setTraversalFocus ( { commentID : parseCommentElementID ( stop . id ) , commentSeenEnabled : enabled , } ) ; stop . element . focus ( ) ; } } , [ enabled , eventEmitter , pym , relayEnvironment , renderWindow , setTraversalFocus , zKeyEnabled , ] ) ; const handleKeypress = useCallback ( ( event ) => { if ( ! pym ) { return ; } let data ; try { if ( typeof event === "string" ) { data = JSON . parse ( event ) ; } else { if ( event . target ) { const el = event . target as HTMLElement ; if ( el . tagName === "INPUT" || el . tagName === "TEXTAREA" ) { return ; } if ( el . isContentEditable ) { return ; } } data = event ; } } catch ( err ) { globalErrorReporter . report ( err ) ; return ; } if ( data . ctrlKey || data . metaKey || data . altKey ) { return ; } const pressedKey = data . key . toLocaleLowerCase ( ) ; if ( pressedKey === "a" && data . shiftKey ) { unmarkAll ( { source : "keyboard" } ) ; return ; } if ( pressedKey === "c" || ( pressedKey === "z" && zKeyEnabled ) ) { setLocal ( { keyboardShortcutsConfig : { key : pressedKey , reverse : Boolean ( data . shiftKey ) , source : "keyboard" , } , } ) ; traverse ( { key : pressedKey , reverse : Boolean ( data . shiftKey ) , source : "keyboard" , } ) ; } } , [ pym , traverse , unmarkAll , zKeyEnabled ] ) ; const handleZKeyButton = useCallback ( ( ) => { setLocal ( { keyboardShortcutsConfig : { key : "z" , reverse : false , source : "mobileToolbar" , } , } ) ; traverse ( { key : "z" , reverse : false , source : "mobileToolbar" } ) ; } , [ traverse ] ) ; useEffect ( ( ) => { updateButtonStates ( ) ; } , [ updateButtonStates ] ) ; useEffect ( ( ) => { const listener = async ( e , data ) => { if ( ! eventsOfInterest . includes ( e ) ) { return ; } if ( loadMoreEvents . includes ( e ) && pym ) { pym . sendHeight ( ) ; if ( data . keyboardShortcutsConfig ) { traverse ( data . keyboardShortcutsConfig ) ; } } await waitFor ( 50 ) ; updateButtonStates ( ) ; } ; eventEmitter . onAny ( listener ) ; return ( ) => { eventEmitter . offAny ( listener ) ; } ; } , [ eventEmitter , updateButtonStates ] ) ; useEffect ( ( ) => { if ( ! pym ) { return ; } const unsubscribe = onPymMessage ( pym , "keypress" , handleKeypress ) ; renderWindow . addEventListener ( "keypress" , handleKeypress ) ; return ( ) => { unsubscribe ( ) ; renderWindow . removeEventListener ( "keypress" , handleKeypress ) ; } ; } , [ handleKeypress , pym , renderWindow ] ) ; if ( amp || toolbarClosed || ! zKeyEnabled || ! loggedIn ) { return null ; } return ( < MatchMedia lteDeviceWidth = "mobileMax" > { ( matches ) => ( matches || browserInfo . mobile || browserInfo . tablet || browserInfo . iPadOS ) && ( < MobileToolbar onKeyPress = { handleKeypress } > < Flex className = { styles . flexContainer } alignItems = "center" > < div className = { styles . unmarkAllContainer } > < Button variant = "text" size = "large" uppercase = { false } disabled = { disableUnmarkAction } onClick = { handleUnmarkAllButton } classes = { { variantText : styles . button , disabled : styles . buttonDisabled , colorRegular : styles . buttonColor , } } > < ButtonIcon > done_all < /ButtonIcon> < Localized id = "comments-mobileToolbar-unmarkAll" > < span > Unmark all < /span> < /Localized> < /Button> < /div> < div className = { styles . nextActionContainer } > < Button variant = "text" size = "large" uppercase = { false } disabled = { disableZAction } classes = { { variantText : styles . button , disabled : styles . buttonDisabled , colorRegular : styles . buttonColor , } } onClick = { handleZKeyButton } > { nextZAction } < ButtonIcon > skip_next < /ButtonIcon> < /Button> < /div> < div className = { styles . closeContainer } > < Localized id = "comments-mobileToolbar-closeButton" attrs = { { "aria-label" : true } } > < Button variant = "text" size = "large" uppercase = { false } classes = { { variantText : styles . button , disabled : styles . buttonDisabled , colorRegular : styles . buttonColor , } } onClick = { handleCloseToolbarButton } aria - label = "Close" > < ButtonIcon > close < /ButtonIcon> < /Button> < /Localized> < /div> < /Flex> < /MobileToolbar>)} < /MatchMedia>); } ; export default KeyboardShortcuts ;