diff --git a/app/javascript/flavours/glitch/features/account_edit/modals/fields_modals.tsx b/app/javascript/flavours/glitch/features/account_edit/modals/fields_modals.tsx index 891d42f86a..68f32daec5 100644 --- a/app/javascript/flavours/glitch/features/account_edit/modals/fields_modals.tsx +++ b/app/javascript/flavours/glitch/features/account_edit/modals/fields_modals.tsx @@ -18,6 +18,7 @@ import { useAppDispatch, useAppSelector, } from '@/flavours/glitch/store'; +import { isUrlWithoutProtocol } from '@/flavours/glitch/utils/checks'; import { ConfirmationModal } from '../../ui/components/confirmation_modals'; import type { DialogModalProps } from '../../ui/components/dialog_modal'; @@ -48,7 +49,7 @@ const messages = defineMessages({ }, editValueHint: { id: 'account_edit.field_edit_modal.value_hint', - defaultMessage: 'E.g. “example.me”', + defaultMessage: 'E.g. “https://example.me”', }, limitHeader: { id: 'account_edit.field_edit_modal.limit_header', @@ -109,6 +110,10 @@ export const EditFieldModal: FC = ({ ); return hasLink && hasEmoji; }, [customEmojiCodes, newLabel, newValue]); + const hasLinkWithoutProtocol = useMemo( + () => isUrlWithoutProtocol(newValue), + [newValue], + ); const dispatch = useAppDispatch(); const handleSave = useCallback(() => { @@ -175,6 +180,19 @@ export const EditFieldModal: FC = ({ /> )} + + {hasLinkWithoutProtocol && ( + + https://, + }} + /> + + )} ); }; diff --git a/app/javascript/flavours/glitch/utils/checks.test.ts b/app/javascript/flavours/glitch/utils/checks.test.ts new file mode 100644 index 0000000000..862a2a0abf --- /dev/null +++ b/app/javascript/flavours/glitch/utils/checks.test.ts @@ -0,0 +1,21 @@ +import { isUrlWithoutProtocol } from './checks'; + +describe('isUrlWithoutProtocol', () => { + test.concurrent.each([ + ['example.com', true], + ['sub.domain.co.uk', true], + ['example', false], // No dot + ['example..com', false], // Consecutive dots + ['example.com.', false], // Trailing dot + ['example.c', false], // TLD too short + ['example.123', false], // Numeric TLDs are not valid + ['example.com/path', true], // Paths are allowed + ['example.com?query=string', true], // Query strings are allowed + ['example.com#fragment', true], // Fragments are allowed + ['example .com', false], // Spaces are not allowed + ['example://com', false], // Protocol inside the string is not allowed + ['example.com^', false], // Invalid characters not allowed + ])('should return %s for input "%s"', (input, expected) => { + expect(isUrlWithoutProtocol(input)).toBe(expected); + }); +}); diff --git a/app/javascript/flavours/glitch/utils/checks.ts b/app/javascript/flavours/glitch/utils/checks.ts index 8b05ac24a7..d5d528bdc6 100644 --- a/app/javascript/flavours/glitch/utils/checks.ts +++ b/app/javascript/flavours/glitch/utils/checks.ts @@ -9,3 +9,29 @@ export function isValidUrl( return false; } } + +/** + * Checks if the input string is probably a URL without a protocol. Note this is not full URL validation, + * and is mostly used to detect link-like inputs. + * @see https://www.xjavascript.com/blog/check-if-a-javascript-string-is-a-url/ + * @param input The input string to check + */ +export function isUrlWithoutProtocol(input: string): boolean { + if (!input.length || input.includes(' ') || input.includes('://')) { + return false; + } + + try { + const url = new URL(`http://${input}`); + const { host } = url; + return ( + host !== '' && // Host is not empty + host.includes('.') && // Host contains at least one dot + !host.endsWith('.') && // No trailing dot + !host.includes('..') && // No consecutive dots + /\.[\w]{2,}$/.test(host) // TLD is at least 2 characters + ); + } catch {} + + return false; +}