diff --git a/app/javascript/mastodon/features/collections/editor/details.tsx b/app/javascript/mastodon/features/collections/editor/details.tsx index 875d09c9ebb..f59bd4de51b 100644 --- a/app/javascript/mastodon/features/collections/editor/details.tsx +++ b/app/javascript/mastodon/features/collections/editor/details.tsx @@ -6,6 +6,7 @@ import { useHistory } from 'react-router-dom'; import { isFulfilled } from '@reduxjs/toolkit'; +import { inputToHashtag } from '@/mastodon/utils/hashtags'; import type { ApiCreateCollectionPayload, ApiUpdateCollectionPayload, @@ -64,7 +65,7 @@ export const CollectionDetails: React.FC = () => { dispatch( updateCollectionEditorField({ field: 'topic', - value: event.target.value, + value: inputToHashtag(event.target.value), }), ); }, @@ -219,6 +220,9 @@ export const CollectionDetails: React.FC = () => { } value={topic} onChange={handleTopicChange} + autoCapitalize='off' + autoCorrect='off' + spellCheck='false' maxLength={40} /> diff --git a/app/javascript/mastodon/reducers/slices/collections.ts b/app/javascript/mastodon/reducers/slices/collections.ts index dc20b987323..a534a134404 100644 --- a/app/javascript/mastodon/reducers/slices/collections.ts +++ b/app/javascript/mastodon/reducers/slices/collections.ts @@ -37,30 +37,30 @@ interface CollectionState { status: QueryStatus; } >; - editor: { - id: string | undefined; - name: string; - description: string; - topic: string; - language: string | null; - discoverable: boolean; - sensitive: boolean; - accountIds: string[]; - }; + editor: EditorState; } -type EditorField = CollectionState['editor']; +interface EditorState { + id: string | null; + name: string; + description: string; + topic: string; + language: string | null; + discoverable: boolean; + sensitive: boolean; + accountIds: string[]; +} -interface UpdateEditorFieldPayload { +interface UpdateEditorFieldPayload { field: K; - value: EditorField[K]; + value: EditorState[K]; } const initialState: CollectionState = { collections: {}, accountCollections: {}, editor: { - id: undefined, + id: null, name: '', description: '', topic: '', @@ -79,7 +79,7 @@ const collectionSlice = createSlice({ const collection = action.payload; state.editor = { - id: collection?.id, + id: collection?.id ?? null, name: collection?.name ?? '', description: collection?.description ?? '', topic: collection?.tag?.name ?? '', @@ -92,7 +92,7 @@ const collectionSlice = createSlice({ reset(state) { state.editor = initialState.editor; }, - updateEditorField( + updateEditorField( state: CollectionState, action: PayloadAction>, ) { diff --git a/app/javascript/mastodon/utils/hashtags.test.ts b/app/javascript/mastodon/utils/hashtags.test.ts new file mode 100644 index 00000000000..05b79b1d52c --- /dev/null +++ b/app/javascript/mastodon/utils/hashtags.test.ts @@ -0,0 +1,28 @@ +import { inputToHashtag } from './hashtags'; + +describe('inputToHashtag', () => { + test.concurrent.each([ + ['', ''], + // Prepend or keep hashtag + ['mastodon', '#mastodon'], + ['#mastodon', '#mastodon'], + // Preserve trailing whitespace + ['mastodon ', '#mastodon '], + [' ', '# '], + // Collapse whitespace & capitalise first character + ['cats of mastodon', '#catsOfMastodon'], + ['x y z', '#xYZ'], + [' mastodon', '#mastodon'], + // Preserve initial casing + ['Log in', '#LogIn'], + ['#NaturePhotography', '#NaturePhotography'], + // Normalise hash symbol variant + ['#nature', '#nature'], + ['#Nature Photography', '#NaturePhotography'], + // Allow special characters + ['hello-world', '#hello-world'], + ['hello,world', '#hello,world'], + ])('for input "%s", return "%s"', (input, expected) => { + expect(inputToHashtag(input)).toBe(expected); + }); +}); diff --git a/app/javascript/mastodon/utils/hashtags.ts b/app/javascript/mastodon/utils/hashtags.ts index 0c5505c6c9a..d14efe5db33 100644 --- a/app/javascript/mastodon/utils/hashtags.ts +++ b/app/javascript/mastodon/utils/hashtags.ts @@ -27,3 +27,35 @@ const buildHashtagRegex = () => { export const HASHTAG_PATTERN_REGEX = buildHashtagPatternRegex(); export const HASHTAG_REGEX = buildHashtagRegex(); + +/** + * Formats an input string as a hashtag: + * - Prepends `#` unless present + * - Strips spaces (except at the end, to allow typing it) + * - Capitalises first character after stripped space + */ +export const inputToHashtag = (input: string): string => { + if (!input) { + return ''; + } + + const trailingSpace = /\s+$/.exec(input)?.[0] ?? ''; + const trimmedInput = input.trimEnd(); + + const withoutHash = + trimmedInput.startsWith('#') || trimmedInput.startsWith('#') + ? trimmedInput.slice(1) + : trimmedInput; + + // Split by space, filter empty strings, and capitalise the start of each word but the first + const words = withoutHash + .split(/\s+/) + .filter((word) => word.length > 0) + .map((word, index) => + index === 0 + ? word + : word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(), + ); + + return `#${words.join('')}${trailingSpace}`; +};