1- import { useState , useCallback , useId } from "react" ;
1+ import { useState , useCallback , useId , useRef , useEffect } from "react" ;
22import { useQuery } from "@tanstack/react-query" ;
33import { useFuzzySearchList , Highlight } from "@nozbe/microfuzz/react" ;
44import { addDomain } from "./actions" ;
55import { useAppDispatch } from "./store" ;
66import { FormattedMessage } from "react-intl" ;
77import { useTextParam } from "./utils" ;
8+ import { clsx } from "clsx" ;
89
910interface ServerResponseJSON {
1011 domain : string ;
1112}
1213
1314const isValidDomain = ( str : string ) : boolean => {
15+ // Highly unlikely someone has a Mastodon server running on a gTLD
16+ if ( str . indexOf ( " " ) !== - 1 || str . indexOf ( "." ) === - 1 ) {
17+ return false ;
18+ }
19+
1420 try {
1521 const url = new URL ( `https://${ str } ` ) ;
1622 return url . hostname === str ;
@@ -19,6 +25,17 @@ const isValidDomain = (str: string): boolean => {
1925 }
2026} ;
2127
28+ const extractDomain = ( str : string ) : string => {
29+ const value = str . trim ( ) . replace ( / ^ @ / , "" ) ;
30+
31+ if ( value . indexOf ( "@" ) !== - 1 ) {
32+ const [ , domain ] = value . split ( "@" ) ;
33+ return domain ;
34+ }
35+
36+ return value ;
37+ } ;
38+
2239export const NewDomain : React . FC < { onDismiss : ( ) => void } > = ( ) => {
2340 const dispatch = useAppDispatch ( ) ;
2441 const text = useTextParam ( ) ;
@@ -27,8 +44,12 @@ export const NewDomain: React.FC<{ onDismiss: () => void }> = () => {
2744 const [ focused , setFocused ] = useState ( false ) ;
2845 const [ showResults , setShowResults ] = useState ( false ) ;
2946 const [ submitting , setSubmitting ] = useState ( false ) ;
47+ const [ refusing , setRefusing ] = useState ( false ) ;
3048 const accessibilityId = useId ( ) ;
31- const domain = value . trim ( ) ;
49+ const domain = extractDomain ( value ) ;
50+
51+ const inputRef = useRef < HTMLInputElement > ( ) ;
52+ const clickAreaRef = useRef < HTMLDivElement > ( ) ;
3253
3354 const serversQuery = useQuery < ServerResponseJSON [ ] > ( {
3455 queryKey : [ "servers" ] ,
@@ -70,8 +91,25 @@ export const NewDomain: React.FC<{ onDismiss: () => void }> = () => {
7091 setFocused ( true ) ;
7192 } , [ ] ) ;
7293
73- const handleBlur = useCallback ( ( ) => {
74- setFocused ( false ) ;
94+ // To handle input blur, we need a custom handler to avoid hiding the results
95+ // when they are clicked, since then the click event would not be registered on them.
96+ useEffect ( ( ) => {
97+ const handleClickOutside = ( e ) => {
98+ if (
99+ clickAreaRef . current ?. contains ( e . target ) ||
100+ inputRef . current ?. contains ( e . target )
101+ ) {
102+ return ;
103+ }
104+
105+ setFocused ( false ) ;
106+ } ;
107+
108+ document . addEventListener ( "click" , handleClickOutside ) ;
109+
110+ return ( ) => {
111+ document . removeEventListener ( "click" , handleClickOutside ) ;
112+ } ;
75113 } , [ ] ) ;
76114
77115 const handleClick = useCallback (
@@ -98,6 +136,8 @@ export const NewDomain: React.FC<{ onDismiss: () => void }> = () => {
98136 e . preventDefault ( ) ;
99137
100138 if ( ! domain || ! isValidDomain ( domain ) ) {
139+ setRefusing ( true ) ;
140+ setTimeout ( ( ) => setRefusing ( false ) , 500 ) ;
101141 return ;
102142 }
103143
@@ -113,6 +153,12 @@ export const NewDomain: React.FC<{ onDismiss: () => void }> = () => {
113153 [ dispatch , text , domain ] ,
114154 ) ;
115155
156+ const error =
157+ ( value . length > 0 &&
158+ ! isValidDomain ( domain ) &&
159+ ( ! focused || results . length === 0 ) ) ||
160+ refusing ;
161+
116162 return (
117163 < >
118164 < form onSubmit = { handleSubmit } autoComplete = "off" >
@@ -136,21 +182,38 @@ export const NewDomain: React.FC<{ onDismiss: () => void }> = () => {
136182 </ label >
137183
138184 < div
139- className = { `w-full flex items-center border bg-white rounded-md ${ focused ? "border-blurple-500" : "border-slate-200" } ${ showResults ? "rounded-b-none" : "" } ` }
185+ className = { clsx (
186+ "w-full flex items-center border bg-white rounded-md" ,
187+ {
188+ "border-blurple-500" : focused && ! error ,
189+ "border-slate-200" : ! focused && ! error ,
190+ "border-red-500" : error ,
191+ "rounded-b-none" :
192+ focused && showResults && results . length > 0 && ! refusing ,
193+ } ,
194+ ) }
140195 >
141196 < input
197+ ref = { inputRef }
142198 className = "text-black flex-grow p-3 border-0 focus:outline-0"
143199 type = "text"
200+ autoComplete = "off"
144201 value = { value }
145202 onChange = { handleChange }
146203 onFocus = { handleFocus }
147- onBlur = { handleBlur }
148204 id = { accessibilityId }
149205 />
150206 </ div >
151207
152- { showResults && results . length > 0 && (
208+ { error && value . length > 0 && (
209+ < p className = "text-sm mt-2 font-medium text-red-500" >
210+ < FormattedMessage defaultMessage = "does not seem to be a valid domain name." />
211+ </ p >
212+ ) }
213+
214+ { focused && showResults && results . length > 0 && ! refusing && (
153215 < div
216+ ref = { clickAreaRef }
154217 className = { `absolute top-full mt-[-1px] w-full flex flex-col bg-white border border-t-0 rounded-b-md p-1 ${ focused ? "border-blurple-500" : "border-slate-200" } ` }
155218 >
156219 { results . slice ( 0 , 5 ) . map ( ( { item, highlightRanges } ) => (
@@ -174,7 +237,10 @@ export const NewDomain: React.FC<{ onDismiss: () => void }> = () => {
174237 </ div >
175238
176239 < button
177- className = "mt-4 flex-none w-full bg-blurple-500 text-white text-base text-center font-bold px-4 py-3 rounded-md cursor-pointer hover:bg-blurple-600"
240+ className = { clsx (
241+ "mt-4 flex-none w-full bg-blurple-500 text-white text-base text-center font-bold px-4 py-3 rounded-md cursor-pointer hover:bg-blurple-600" ,
242+ { "animate-shake" : refusing , "animate-pulse" : submitting } ,
243+ ) }
178244 type = "submit"
179245 >
180246 < FormattedMessage id = "" defaultMessage = "Continue to Mastodon" />
0 commit comments