Skip to content

Commit 3670893

Browse files
committed
Add error display when entering or submitting invalid domain name
1 parent 0e479b3 commit 3670893

4 files changed

Lines changed: 104 additions & 8 deletions

File tree

package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"@reduxjs/toolkit": "^2.9.0",
2424
"@tailwindcss/vite": "^4.1.13",
2525
"@tanstack/react-query": "^5.87.1",
26+
"clsx": "^2.1.1",
2627
"react": "^19.1.1",
2728
"react-dom": "^19.1.1",
2829
"react-intl": "^7.1.11",

src/NewDomain.tsx

Lines changed: 74 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,22 @@
1-
import { useState, useCallback, useId } from "react";
1+
import { useState, useCallback, useId, useRef, useEffect } from "react";
22
import { useQuery } from "@tanstack/react-query";
33
import { useFuzzySearchList, Highlight } from "@nozbe/microfuzz/react";
44
import { addDomain } from "./actions";
55
import { useAppDispatch } from "./store";
66
import { FormattedMessage } from "react-intl";
77
import { useTextParam } from "./utils";
8+
import { clsx } from "clsx";
89

910
interface ServerResponseJSON {
1011
domain: string;
1112
}
1213

1314
const 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+
2239
export 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" />

src/index.css

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,25 @@
3535
--color-eggplant: #17063b;
3636
--color-lime: #baff3b;
3737
--color-goldenrod: #ffbe2e;
38+
--animate-shake: shake 0.5s ease-in-out both;
39+
40+
@keyframes shake {
41+
0% {
42+
transform: translateX(0);
43+
}
44+
25% {
45+
transform: translateX(-10px);
46+
}
47+
50% {
48+
transform: translateX(10px);
49+
}
50+
75% {
51+
transform: translateX(-10px);
52+
}
53+
100% {
54+
transform: translateX(0);
55+
}
56+
}
3857
}
3958

4059
body {

0 commit comments

Comments
 (0)