<commit_before>/*
 * Copyright (C) 2021 - present Instructure, Inc.
 *
 * This file is part of Canvas.
 *
 * Canvas is free software: you can redistribute it and/or modify it under
 * the terms of the GNU Affero General Public License as published by the Free
 * Software Foundation, version 3 of the License.
 *
 * Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
 * A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
 * details.
 *
 * You should have received a copy of the GNU Affero General Public License along
 * with this program. If not, see <http://www.gnu.org/licenses/>.
 */

import React, {useEffect, useRef, useState, useLayoutEffect, useCallback, useMemo} from 'react'
import MentionDropdownMenu from './MentionDropdownMenu'
import PropTypes from 'prop-types'
import getPosition from './getPosition'
import {
  ARIA_ID_TEMPLATES,
  MARKER_ID,
  TRUSTED_MESSAGE_ORIGIN,
  NAVIGATION_MESSAGE,
  INPUT_CHANGE_MESSAGE,
  KEY_NAMES,
  KEY_CODES
} from '../../constants'
import {MENTIONABLE_USERS_QUERY} from './graphql/Queries'
import {useQuery} from '@apollo/react-hooks'

const MentionUIManager = ({editor, onExited, onFocusedUserChange, onSelect}) => {
  // Setup State
  const [mentionCoordinates, setMentionCoordinates] = useState(null)
  const [focusedUser, setFocusedUser] = useState()
  const [inputText, setInputText] = useState('')
  const [debouncedInputText, setDebouncedInputText] = useState('')
  const [shouldExit, setShouldExit] = useState(false)
  const [noResults, setNoResults] = useState(false)

  // Setup Refs for listener access
  const focusedUserRef = useRef(focusedUser)
  const filteredOptionsRef = useRef([])
  const noResultsRef = useRef(noResults)

  useEffect(() => {
    const debouncer = setTimeout(() => {
      setDebouncedInputText(inputText?.trim())
    }, 500)

    return () => {
      clearTimeout(debouncer)
    }
  }, [inputText])

  const {data} = useQuery(MENTIONABLE_USERS_QUERY, {
    variables: {
      discussionTopicId: ENV.discussion_topic_id,
      searchTerm: debouncedInputText
    }
  })

  const mentionData = data?.legacyNode?.mentionableUsersConnection?.nodes || []

  const filteredOptions = useMemo(() => {
    return mentionData?.filter(o => {
      return o.name.toLowerCase().includes(inputText?.toLowerCase().trim())
    })
  }, [mentionData, inputText])

  useEffect(() => {
    filteredOptionsRef.current = filteredOptions
  }, [filteredOptions])

  const getXYPosition = useCallback(() => {
    const responseObj = getPosition(tinyMCE.activeEditor, `#${MARKER_ID}`)
    setMentionCoordinates(responseObj)
  }, [])

  // Navigates highlight of mention
  const navigateFocusedUser = dir => {
    // Return if no options present
    if (filteredOptionsRef.current.length === 0) {
      return
    }

    // When no user is selected or filterSet doesn't contain focused user
    if (
      focusedUserRef.current === null ||
      !filteredOptionsRef.current.includes(focusedUserRef.current)
    ) {
      setFocusedUser(filteredOptionsRef.current[0])
      return
    }

    const selectedUser = focusedUserRef.current
    const selectedUserIndex = filteredOptionsRef.current.findIndex(m => m.id === selectedUser.id)

    switch (dir) {
      case 'down':
        setFocusedUser(
          filteredOptionsRef.current[
            selectedUserIndex + 1 >= filteredOptionsRef.current.length ? 0 : selectedUserIndex + 1
          ]
        )
        break
      case 'up':
        setFocusedUser(
          filteredOptionsRef.current[
            selectedUserIndex - 1 < 0
              ? filteredOptionsRef.current.length - 1
              : selectedUserIndex - 1
          ]
        )
        break
      default:
        break
    }
  }

  const keyboardEvents = value => {
    switch (value) {
      case KEY_NAMES[KEY_CODES.up]:
        // Up
        navigateFocusedUser('up')
        break
      case KEY_NAMES[KEY_CODES.down]:
        // Down
        navigateFocusedUser('down')
        break
      default:
        break
    }
  }

  const handleInputChange = value => {
    // If no results then exit
    if (noResultsRef.current) {
      onExited(editor, false)
      return
    }

    getXYPosition()
    setInputText(value)
  }

  const messageCallback = e => {
    if (e.origin !== TRUSTED_MESSAGE_ORIGIN) {
      return
    }

    const messageType = e.data.messageType
    const value = e.data.value

    switch (messageType) {
      case NAVIGATION_MESSAGE:
        keyboardEvents(value)
        break
      case INPUT_CHANGE_MESSAGE:
        handleInputChange(value)
        break
      default:
        break
    }
  }

  // Prepare for exiting naturally
  useEffect(() => {
    // Check that last character isn't space and we have results
    if (mentionData.length === 0 && inputText.length > 0) {
      setNoResults(true)
      noResultsRef.current = true
    } else if (mentionData.length > 0) {
      setNoResults(false)
      noResultsRef.current = false
    }
  }, [inputText, mentionData])

  // Make us maintain a focused user when open
  useEffect(() => {
    if (!filteredOptions.includes(focusedUser)) {
      setFocusedUser(filteredOptions[0])
    }
  }, [filteredOptions, focusedUser])

  // Keep Focus User and active decendant always up to date
  useEffect(() => {
    if (focusedUser) {
      onFocusedUserChange({
        ...focusedUser,
        ariaActiveDescendantId: ARIA_ID_TEMPLATES.activeDescendant(editor.id, focusedUser.id)
      })
    } else {
      onFocusedUserChange(null)
    }
    focusedUserRef.current = focusedUser
  }, [editor.id, focusedUser, onFocusedUserChange])

  // Window listeners handler
  useLayoutEffect(() => {
    window.addEventListener('resize', getXYPosition)
    window.addEventListener('scroll', getXYPosition)
    window.addEventListener('message', messageCallback)

    return () => {
      window.removeEventListener('resize', getXYPosition)
      window.removeEventListener('scroll', getXYPosition)
      window.removeEventListener('message', messageCallback)
    }<commit_msg>Close RCE when first character after @ is a space<commit_after>/*
 * Copyright (C) 2021 - present Instructure, Inc.
 *
 * This file is part of Canvas.
 *
 * Canvas is free software: you can redistribute it and/or modify it under
 * the terms of the GNU Affero General Public License as published by the Free
 * Software Foundation, version 3 of the License.
 *
 * Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
 * A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
 * details.
 *
 * You should have received a copy of the GNU Affero General Public License along
 * with this program. If not, see <http://www.gnu.org/licenses/>.
 */

import React, {useEffect, useRef, useState, useLayoutEffect, useCallback, useMemo} from 'react'
import MentionDropdownMenu from './MentionDropdownMenu'
import PropTypes from 'prop-types'
import getPosition from './getPosition'
import {
  ARIA_ID_TEMPLATES,
  MARKER_ID,
  TRUSTED_MESSAGE_ORIGIN,
  NAVIGATION_MESSAGE,
  INPUT_CHANGE_MESSAGE,
  KEY_NAMES,
  KEY_CODES
} from '../../constants'
import {MENTIONABLE_USERS_QUERY} from './graphql/Queries'
import {useQuery} from '@apollo/react-hooks'

const MentionUIManager = ({editor, onExited, onFocusedUserChange, onSelect}) => {
  // Setup State
  const [mentionCoordinates, setMentionCoordinates] = useState(null)
  const [focusedUser, setFocusedUser] = useState()
  const [inputText, setInputText] = useState('')
  const [debouncedInputText, setDebouncedInputText] = useState('')
  const [shouldExit, setShouldExit] = useState(false)
  const [noResults, setNoResults] = useState(false)

  // Setup Refs for listener access
  const focusedUserRef = useRef(focusedUser)
  const filteredOptionsRef = useRef([])
  const noResultsRef = useRef(noResults)

  useEffect(() => {
    const debouncer = setTimeout(() => {
      setDebouncedInputText(inputText?.trim())
    }, 500)

    return () => {
      clearTimeout(debouncer)
    }
  }, [inputText])

  const {data} = useQuery(MENTIONABLE_USERS_QUERY, {
    variables: {
      discussionTopicId: ENV.discussion_topic_id,
      searchTerm: debouncedInputText
    }
  })

  const mentionData = data?.legacyNode?.mentionableUsersConnection?.nodes || []

  const filteredOptions = useMemo(() => {
    return mentionData?.filter(o => {
      return o.name.toLowerCase().includes(inputText?.toLowerCase().trim())
    })
  }, [mentionData, inputText])

  useEffect(() => {
    filteredOptionsRef.current = filteredOptions
  }, [filteredOptions])

  const getXYPosition = useCallback(() => {
    const responseObj = getPosition(tinyMCE.activeEditor, `#${MARKER_ID}`)
    setMentionCoordinates(responseObj)
  }, [])

  // Navigates highlight of mention
  const navigateFocusedUser = dir => {
    // Return if no options present
    if (filteredOptionsRef.current.length === 0) {
      return
    }

    // When no user is selected or filterSet doesn't contain focused user
    if (
      focusedUserRef.current === null ||
      !filteredOptionsRef.current.includes(focusedUserRef.current)
    ) {
      setFocusedUser(filteredOptionsRef.current[0])
      return
    }

    const selectedUser = focusedUserRef.current
    const selectedUserIndex = filteredOptionsRef.current.findIndex(m => m.id === selectedUser.id)

    switch (dir) {
      case 'down':
        setFocusedUser(
          filteredOptionsRef.current[
            selectedUserIndex + 1 >= filteredOptionsRef.current.length ? 0 : selectedUserIndex + 1
          ]
        )
        break
      case 'up':
        setFocusedUser(
          filteredOptionsRef.current[
            selectedUserIndex - 1 < 0
              ? filteredOptionsRef.current.length - 1
              : selectedUserIndex - 1
          ]
        )
        break
      default:
        break
    }
  }

  const keyboardEvents = value => {
    switch (value) {
      case KEY_NAMES[KEY_CODES.up]:
        // Up
        navigateFocusedUser('up')
        break
      case KEY_NAMES[KEY_CODES.down]:
        // Down
        navigateFocusedUser('down')
        break
      default:
        break
    }
  }

  const handleInputChange = value => {
    // If no results then exit
    if (noResultsRef.current) {
      onExited(editor, false)
      return
    }

    getXYPosition()
    setInputText(value)
  }

  const messageCallback = e => {
    if (e.origin !== TRUSTED_MESSAGE_ORIGIN) {
      return
    }

    const messageType = e.data.messageType
    const value = e.data.value

    switch (messageType) {
      case NAVIGATION_MESSAGE:
        keyboardEvents(value)
        break
      case INPUT_CHANGE_MESSAGE:
        handleInputChange(value)
        break
      default:
        break
    }
  }

  // Prepare for exiting naturally
  useEffect(() => {
    // Check that last character isn't space and we have results
    if (mentionData.length === 0 && inputText.length > 0) {
      setNoResults(true)
      noResultsRef.current = true
    } else if (mentionData.length > 0) {
      setNoResults(false)
      noResultsRef.current = false
    }
  }, [inputText, mentionData])

  // When only spces exit without saving mention
  useEffect(() => {
    if (!inputText.replace(/\s/g, '').length && inputText.length > 0) {
      onExited(editor, false)
    }
  }, [editor, inputText, onExited])

  // Make us maintain a focused user when open
  useEffect(() => {
    if (!filteredOptions.includes(focusedUser)) {
      setFocusedUser(filteredOptions[0])
    }
  }, [filteredOptions, focusedUser])

  // Keep Focus User and active decendant always up to date
  useEffect(() => {
    if (focusedUser) {
      onFocusedUserChange({
        ...focusedUser,
        ariaActiveDescendantId: ARIA_ID_TEMPLATES.activeDescendant(editor.id, focusedUser.id)
      })
    } else {
      onFocusedUserChange(null)
    }
    focusedUserRef.current = focusedUser
  }, [editor.id, focusedUser, onFocusedUserChange])

  // Window listeners handler
  useLayoutEffect(() => {
    window.addEventListener('resize', getXYPosition)
    window.addEventListener('scroll', getXYPosition)
    window.addEventListener('message', messageCallback)

    return () => {
      window.removeEventListener('resize', getXYPosition)
      window.removeEventListener('scroll', getXYPosition)
      window.removeEventListener('message', messageCallback)
    }
{'fraction_non_alphanumeric': 0.1035691523263225, 'fraction_numerical': 0.003425748884639898, 'mean_word_length': 3.0118248641738576, 'pattern_counts': {'":': 0, '<': 7, '<?xml version=': 0, '>': 49, 'https://': 0, 'lorem ipsum': 0, 'www.': 2, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '3440974', 'n_tokens_mistral': 3699, 'n_tokens_neox': 3607, 'n_words': 916}