input.tsx 4.7 KB

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