input.tsx 4.8 KB

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