input.tsx 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  1. import {
  2. ChangeEvent,
  3. KeyboardEvent,
  4. useCallback,
  5. useEffect,
  6. useState,
  7. useRef,
  8. } from 'react'
  9. import { getJSON } from '../../../../../infrastructure/fetch-json'
  10. import useAbortController from '../../../../../shared/hooks/use-abort-controller'
  11. import domainBlocklist from '../../../domain-blocklist'
  12. const LOCAL_AND_DOMAIN_REGEX = /([^@]+)@(.+)/
  13. function matchLocalAndDomain(emailHint: string) {
  14. const match = emailHint.match(LOCAL_AND_DOMAIN_REGEX)
  15. if (match) {
  16. return { local: match[1], domain: match[2] }
  17. } else {
  18. return { local: null, domain: null }
  19. }
  20. }
  21. export type InstitutionInfo = {
  22. hostname: string
  23. confirmed?: boolean
  24. university: {
  25. id: number
  26. name: string
  27. ssoEnabled?: boolean
  28. ssoBeta?: boolean
  29. }
  30. }
  31. let domainCache = new Map<string, InstitutionInfo>()
  32. export function clearDomainCache() {
  33. domainCache = new Map<string, InstitutionInfo>()
  34. }
  35. type InputProps = {
  36. onChange: (value: string, institution?: InstitutionInfo) => void
  37. }
  38. function Input({ onChange }: InputProps) {
  39. const { signal } = useAbortController()
  40. const inputRef = useRef<HTMLInputElement | null>(null)
  41. const [suggestion, setSuggestion] = useState<string | null>(null)
  42. const [inputValue, setInputValue] = useState<string | null>(null)
  43. const [matchedInstitution, setMatchedInstitution] =
  44. useState<InstitutionInfo | null>(null)
  45. useEffect(() => {
  46. inputRef.current?.focus()
  47. }, [inputRef])
  48. useEffect(() => {
  49. if (inputValue == null) {
  50. return
  51. }
  52. if (
  53. matchedInstitution &&
  54. inputValue.endsWith(matchedInstitution.hostname)
  55. ) {
  56. onChange(inputValue, matchedInstitution)
  57. } else {
  58. onChange(inputValue)
  59. }
  60. }, [onChange, inputValue, suggestion, matchedInstitution])
  61. const handleEmailChange = useCallback(
  62. (event: ChangeEvent<HTMLInputElement>) => {
  63. const hint = event.target.value
  64. setInputValue(hint)
  65. const match = matchLocalAndDomain(hint)
  66. if (!matchedInstitution?.hostname.startsWith(match.domain)) {
  67. setSuggestion(null)
  68. }
  69. if (!match.domain) {
  70. return
  71. }
  72. if (domainCache.has(match.domain)) {
  73. const cachedDomain = domainCache.get(match.domain)
  74. setSuggestion(`${match.local}@${cachedDomain.hostname}`)
  75. setMatchedInstitution(cachedDomain)
  76. return
  77. }
  78. const query = `?hostname=${match.domain}&limit=1`
  79. getJSON(`/institutions/domains${query}`, { signal })
  80. .then(data => {
  81. if (!(data && data[0])) {
  82. return
  83. }
  84. if (domainBlocklist.has(data[0].hostname)) {
  85. return
  86. }
  87. const hostname = data[0]?.hostname
  88. if (hostname) {
  89. domainCache.set(match.domain, data[0])
  90. setSuggestion(`${match.local}@${hostname}`)
  91. setMatchedInstitution(data[0])
  92. } else {
  93. setSuggestion(null)
  94. setMatchedInstitution(null)
  95. }
  96. })
  97. .catch(error => {
  98. setSuggestion(null)
  99. setMatchedInstitution(null)
  100. console.error(error)
  101. })
  102. },
  103. [signal, matchedInstitution]
  104. )
  105. const handleKeyDownEvent = useCallback(
  106. (event: KeyboardEvent) => {
  107. const setInputValueAndResetSuggestion = () => {
  108. setInputValue(suggestion)
  109. setSuggestion(null)
  110. }
  111. if (event.key === 'Enter') {
  112. event.preventDefault()
  113. if (suggestion) {
  114. setInputValueAndResetSuggestion()
  115. }
  116. }
  117. if (event.key === 'Tab' && suggestion) {
  118. event.preventDefault()
  119. setInputValueAndResetSuggestion()
  120. }
  121. },
  122. [suggestion]
  123. )
  124. useEffect(() => {
  125. if (suggestion && !suggestion.startsWith(inputValue)) {
  126. setSuggestion(null)
  127. }
  128. }, [suggestion, inputValue])
  129. return (
  130. <div className="input-suggestions">
  131. <div className="form-control input-suggestions-shadow">
  132. <div className="input-suggestions-shadow-suggested">
  133. {suggestion || ''}
  134. </div>
  135. </div>
  136. <input
  137. id="affiliations-email"
  138. className="form-control input-suggestions-main"
  139. type="email"
  140. onChange={handleEmailChange}
  141. onKeyDown={handleKeyDownEvent}
  142. value={inputValue || ''}
  143. placeholder="e.g. johndoe@mit.edu"
  144. ref={inputRef}
  145. />
  146. </div>
  147. )
  148. }
  149. export default Input