Collection editor: Format topic as hashtag (#38153)
This commit is contained in:
@@ -6,6 +6,7 @@ import { useHistory } from 'react-router-dom';
|
|||||||
|
|
||||||
import { isFulfilled } from '@reduxjs/toolkit';
|
import { isFulfilled } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
import { inputToHashtag } from '@/mastodon/utils/hashtags';
|
||||||
import type {
|
import type {
|
||||||
ApiCreateCollectionPayload,
|
ApiCreateCollectionPayload,
|
||||||
ApiUpdateCollectionPayload,
|
ApiUpdateCollectionPayload,
|
||||||
@@ -64,7 +65,7 @@ export const CollectionDetails: React.FC = () => {
|
|||||||
dispatch(
|
dispatch(
|
||||||
updateCollectionEditorField({
|
updateCollectionEditorField({
|
||||||
field: 'topic',
|
field: 'topic',
|
||||||
value: event.target.value,
|
value: inputToHashtag(event.target.value),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -219,6 +220,9 @@ export const CollectionDetails: React.FC = () => {
|
|||||||
}
|
}
|
||||||
value={topic}
|
value={topic}
|
||||||
onChange={handleTopicChange}
|
onChange={handleTopicChange}
|
||||||
|
autoCapitalize='off'
|
||||||
|
autoCorrect='off'
|
||||||
|
spellCheck='false'
|
||||||
maxLength={40}
|
maxLength={40}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -37,30 +37,30 @@ interface CollectionState {
|
|||||||
status: QueryStatus;
|
status: QueryStatus;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
editor: {
|
editor: EditorState;
|
||||||
id: string | undefined;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
topic: string;
|
|
||||||
language: string | null;
|
|
||||||
discoverable: boolean;
|
|
||||||
sensitive: boolean;
|
|
||||||
accountIds: string[];
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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<K extends keyof EditorField> {
|
interface UpdateEditorFieldPayload<K extends keyof EditorState> {
|
||||||
field: K;
|
field: K;
|
||||||
value: EditorField[K];
|
value: EditorState[K];
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: CollectionState = {
|
const initialState: CollectionState = {
|
||||||
collections: {},
|
collections: {},
|
||||||
accountCollections: {},
|
accountCollections: {},
|
||||||
editor: {
|
editor: {
|
||||||
id: undefined,
|
id: null,
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
topic: '',
|
topic: '',
|
||||||
@@ -79,7 +79,7 @@ const collectionSlice = createSlice({
|
|||||||
const collection = action.payload;
|
const collection = action.payload;
|
||||||
|
|
||||||
state.editor = {
|
state.editor = {
|
||||||
id: collection?.id,
|
id: collection?.id ?? null,
|
||||||
name: collection?.name ?? '',
|
name: collection?.name ?? '',
|
||||||
description: collection?.description ?? '',
|
description: collection?.description ?? '',
|
||||||
topic: collection?.tag?.name ?? '',
|
topic: collection?.tag?.name ?? '',
|
||||||
@@ -92,7 +92,7 @@ const collectionSlice = createSlice({
|
|||||||
reset(state) {
|
reset(state) {
|
||||||
state.editor = initialState.editor;
|
state.editor = initialState.editor;
|
||||||
},
|
},
|
||||||
updateEditorField<K extends keyof EditorField>(
|
updateEditorField<K extends keyof EditorState>(
|
||||||
state: CollectionState,
|
state: CollectionState,
|
||||||
action: PayloadAction<UpdateEditorFieldPayload<K>>,
|
action: PayloadAction<UpdateEditorFieldPayload<K>>,
|
||||||
) {
|
) {
|
||||||
|
|||||||
28
app/javascript/mastodon/utils/hashtags.test.ts
Normal file
28
app/javascript/mastodon/utils/hashtags.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -27,3 +27,35 @@ const buildHashtagRegex = () => {
|
|||||||
export const HASHTAG_PATTERN_REGEX = buildHashtagPatternRegex();
|
export const HASHTAG_PATTERN_REGEX = buildHashtagPatternRegex();
|
||||||
|
|
||||||
export const HASHTAG_REGEX = buildHashtagRegex();
|
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}`;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user