From cea35bfb1cce8088ddad66aecf22663a1126d1d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Thu, 12 Jun 2025 18:36:25 +0200 Subject: [PATCH] pl-fe: Show suggestions about hashtag accessibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- packages/pl-fe/src/actions/compose.ts | 22 +++++- .../compose/components/compose-form.tsx | 3 + .../components/hashtag-casing-suggestion.tsx | 76 +++++++++++++++++++ .../compose/editor/plugins/state-plugin.tsx | 32 +++++++- packages/pl-fe/src/locales/en.json | 4 + packages/pl-fe/src/reducers/compose.ts | 15 ++++ packages/pl-fe/src/schemas/pl-fe/settings.ts | 1 + 7 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 packages/pl-fe/src/features/compose/components/hashtag-casing-suggestion.tsx diff --git a/packages/pl-fe/src/actions/compose.ts b/packages/pl-fe/src/actions/compose.ts index 5301b71aa..45471c8a5 100644 --- a/packages/pl-fe/src/actions/compose.ts +++ b/packages/pl-fe/src/actions/compose.ts @@ -101,6 +101,9 @@ const COMPOSE_INTERACTION_POLICY_OPTION_CHANGE = 'COMPOSE_INTERACTION_POLICY_OPT const COMPOSE_CLEAR_LINK_SUGGESTION_CREATE = 'COMPOSE_CLEAR_LINK_SUGGESTION_CREATE' as const; const COMPOSE_CLEAR_LINK_SUGGESTION_IGNORE = 'COMPOSE_CLEAR_LINK_SUGGESTION_IGNORE' as const; +const COMPOSE_HASHTAG_CASING_SUGGESTION_SET = 'COMPOSE_HASHTAG_CASING_SUGGESTION_SET' as const; +const COMPOSE_HASHTAG_CASING_SUGGESTION_IGNORE = 'COMPOSE_HASHTAG_CASING_SUGGESTION_IGNORE' as const; + const getAccount = makeGetAccount(); const messages = defineMessages({ @@ -984,6 +987,17 @@ const ignoreClearLinkSuggestion = (composeId: string, key: string) => ({ key, }); +const suggestHashtagCasing = (composeId: string, suggestion: string | null) => ({ + type: COMPOSE_HASHTAG_CASING_SUGGESTION_SET, + composeId, + suggestion, +}); + +const ignoreHashtagCasingSuggestion = (composeId: string) => ({ + type: COMPOSE_HASHTAG_CASING_SUGGESTION_IGNORE, + composeId, +}); + type ComposeAction = ComposeSetStatusAction | ReturnType @@ -1040,7 +1054,9 @@ type ComposeAction = | ReturnType | ReturnType | ReturnType - | ReturnType; + | ReturnType + | ReturnType + | ReturnType; export { COMPOSE_CHANGE, @@ -1099,6 +1115,8 @@ export { COMPOSE_INTERACTION_POLICY_OPTION_CHANGE, COMPOSE_CLEAR_LINK_SUGGESTION_CREATE, COMPOSE_CLEAR_LINK_SUGGESTION_IGNORE, + COMPOSE_HASHTAG_CASING_SUGGESTION_SET, + COMPOSE_HASHTAG_CASING_SUGGESTION_IGNORE, setComposeToStatus, replyCompose, cancelReplyCompose, @@ -1149,6 +1167,8 @@ export { suggestClearLink, ignoreClearLinkSuggestion, cancelPreviewCompose, + suggestHashtagCasing, + ignoreHashtagCasingSuggestion, type ComposeReplyAction, type ComposeSuggestionSelectAction, type ComposeAction, diff --git a/packages/pl-fe/src/features/compose/components/compose-form.tsx b/packages/pl-fe/src/features/compose/components/compose-form.tsx index 0e0be9432..7c421adc0 100644 --- a/packages/pl-fe/src/features/compose/components/compose-form.tsx +++ b/packages/pl-fe/src/features/compose/components/compose-form.tsx @@ -37,6 +37,7 @@ import { countableText } from '../util/counter'; import ClearLinkSuggestion from './clear-link-suggestion'; import ContentTypeButton from './content-type-button'; +import HashtagCasingSuggestion from './hashtag-casing-suggestion'; import InteractionPolicyButton from './interaction-policy-button'; import LanguageDropdown from './language-dropdown'; import PollButton from './poll-button'; @@ -380,6 +381,8 @@ const ComposeForm = ({ id, shouldCondense, autoFocus, clickab + + {composeModifiers} diff --git a/packages/pl-fe/src/features/compose/components/hashtag-casing-suggestion.tsx b/packages/pl-fe/src/features/compose/components/hashtag-casing-suggestion.tsx new file mode 100644 index 000000000..94917e40a --- /dev/null +++ b/packages/pl-fe/src/features/compose/components/hashtag-casing-suggestion.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { defineMessages, FormattedMessage } from 'react-intl'; +import { spring } from 'react-motion'; + +import { ignoreHashtagCasingSuggestion } from 'pl-fe/actions/compose'; +import { changeSetting } from 'pl-fe/actions/settings'; +import Button from 'pl-fe/components/ui/button'; +import HStack from 'pl-fe/components/ui/hstack'; +import Stack from 'pl-fe/components/ui/stack'; +import OptionalMotion from 'pl-fe/features/ui/util/optional-motion'; +import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch'; +import { useCompose } from 'pl-fe/hooks/use-compose'; +import toast from 'pl-fe/toast'; + +const messages = defineMessages({ + hashtagCasingSuggestionsDisabled: { id: 'compose.hashtag_casing_suggestion.disabled', defaultMessage: 'You will no longer receive suggestions about hashtag capitalization.' }, +}); + +interface IHashtagCasingSuggestion { + composeId: string; +} + +const HashtagCasingSuggestion = ({ + composeId, +}: IHashtagCasingSuggestion) => { + const dispatch = useAppDispatch(); + + const compose = useCompose(composeId); + const suggestion = compose.hashtag_casing_suggestion; + + const onIgnore = () => { + dispatch(ignoreHashtagCasingSuggestion(composeId)); + }; + + const onDontAskAgain = () => { + dispatch(changeSetting(['ignoreHashtagCasingSuggestions'], true, { showAlert: false, save: true })); + toast.info(messages.hashtagCasingSuggestionsDisabled); + dispatch(ignoreHashtagCasingSuggestion(composeId)); + }; + + if (!suggestion) return null; + + return ( + + {({ opacity, scaleX, scaleY }) => ( + + + {suggestion} }} + /> + + + + + + + )} + + ); +}; + +export { HashtagCasingSuggestion as default }; diff --git a/packages/pl-fe/src/features/compose/editor/plugins/state-plugin.tsx b/packages/pl-fe/src/features/compose/editor/plugins/state-plugin.tsx index 1dd0826f9..b74a57f84 100644 --- a/packages/pl-fe/src/features/compose/editor/plugins/state-plugin.tsx +++ b/packages/pl-fe/src/features/compose/editor/plugins/state-plugin.tsx @@ -1,3 +1,4 @@ +import { HashtagNode } from '@lexical/hashtag'; import { AutoLinkNode, LinkNode } from '@lexical/link'; import { $convertToMarkdownString } from '@lexical/markdown'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; @@ -6,7 +7,7 @@ import debounce from 'lodash/debounce'; import { useCallback, useEffect } from 'react'; import { useIntl } from 'react-intl'; -import { addSuggestedLanguage, addSuggestedQuote, setEditorState, suggestClearLink } from 'pl-fe/actions/compose'; +import { addSuggestedLanguage, addSuggestedQuote, setEditorState, suggestClearLink, suggestHashtagCasing } from 'pl-fe/actions/compose'; import { fetchStatus } from 'pl-fe/actions/statuses'; import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch'; import { useFeatures } from 'pl-fe/hooks/use-features'; @@ -30,7 +31,7 @@ const StatePlugin: React.FC = ({ composeId, isWysiwyg }) => { const dispatch = useAppDispatch(); const [editor] = useLexicalComposerContext(); const features = useFeatures(); - const { urlPrivacy } = useSettings(); + const { urlPrivacy, ignoreHashtagCasingSuggestions } = useSettings(); const checkUrls = useCallback(debounce((editorState: EditorState) => { dispatch(async (_, getState) => { @@ -79,6 +80,32 @@ const StatePlugin: React.FC = ({ composeId, isWysiwyg }) => { }); }, 2000), [urlPrivacy.clearLinksInCompose]); + const checkHashtagCasingSuggestions = useCallback(debounce((editorState: EditorState) => { + dispatch(async (_, getState) => { + if (ignoreHashtagCasingSuggestions) return; + + const state = getState(); + const compose = state.compose[composeId]; + + if (compose.hashtag_casing_suggestion_ignored) return; + + editorState.read(() => { + const hashtagNodes = $nodesOfType(HashtagNode); + + for (const tag of hashtagNodes) { + const text = tag.getTextContent(); + + if (text.length > 10 && text.toLowerCase() === text && !text.match(/[0-9]/)) { + dispatch(suggestHashtagCasing(composeId, text)); + return; + } + } + + dispatch(suggestHashtagCasing(composeId, null)); + }); + }); + }, 1000), [ignoreHashtagCasingSuggestions]); + const getQuoteSuggestions = useCallback(debounce((text: string) => { dispatch(async (_, getState) => { const state = getState(); @@ -147,6 +174,7 @@ const StatePlugin: React.FC = ({ composeId, isWysiwyg }) => { const data = isEmpty ? null : JSON.stringify(editorState.toJSON()); dispatch(setEditorState(composeId, data, text)); checkUrls(editorState); + checkHashtagCasingSuggestions(editorState); getQuoteSuggestions(plainText); detectLanguage(plainText); }); diff --git a/packages/pl-fe/src/locales/en.json b/packages/pl-fe/src/locales/en.json index d3dc8e147..2d60d8fbc 100644 --- a/packages/pl-fe/src/locales/en.json +++ b/packages/pl-fe/src/locales/en.json @@ -463,6 +463,10 @@ "compose.clear_link_suggestion.ignore": "Ignore", "compose.clear_link_suggestion.remove": "Remove", "compose.edit_success": "Your post was edited", + "compose.hashtag_casing_suggestion.body": "Does the hashtag {hashtag} include more than one word? Prefer capitalizing the first letter of each word for improved readability and accessibility.", + "compose.hashtag_casing_suggestion.disabled": "You will no longer receive suggestions about hashtag capitalization.", + "compose.hashtag_casing_suggestion.dont_ask_again": "Don’t ask again", + "compose.hashtag_casing_suggestion.ignore": "Ignore", "compose.invalid_schedule": "You must schedule a post at least 5 minutes out.", "compose.language_dropdown.add_language": "Add language", "compose.language_dropdown.delete_language": "Delete language", diff --git a/packages/pl-fe/src/reducers/compose.ts b/packages/pl-fe/src/reducers/compose.ts index 812cb28b3..939c5b84f 100644 --- a/packages/pl-fe/src/reducers/compose.ts +++ b/packages/pl-fe/src/reducers/compose.ts @@ -60,6 +60,8 @@ import { COMPOSE_CLEAR_LINK_SUGGESTION_IGNORE, COMPOSE_PREVIEW_SUCCESS, COMPOSE_PREVIEW_CANCEL, + COMPOSE_HASHTAG_CASING_SUGGESTION_SET, + COMPOSE_HASHTAG_CASING_SUGGESTION_IGNORE, type ComposeAction, type ComposeSuggestionSelectAction, } from '../actions/compose'; @@ -142,6 +144,8 @@ interface Compose { dismissed_clear_links_suggestions: Array; clear_link_suggestion: ClearLinkSuggestion | null; preview: Partial | null; + hashtag_casing_suggestion: string | null; + hashtag_casing_suggestion_ignored: boolean | null; } const newCompose = (params: Partial = {}): Compose => ({ @@ -186,6 +190,8 @@ const newCompose = (params: Partial = {}): Compose => ({ dismissed_clear_links_suggestions: [], clear_link_suggestion: null, preview: null, + hashtag_casing_suggestion: null, + hashtag_casing_suggestion_ignored: null, ...params, }); @@ -733,6 +739,15 @@ const compose = (state = initialState, action: ComposeAction | EventsAction | In return updateCompose(state, action.composeId, compose => { compose.preview = null; }); + case COMPOSE_HASHTAG_CASING_SUGGESTION_SET: + return updateCompose(state, action.composeId, compose => { + compose.hashtag_casing_suggestion = action.suggestion; + }); + case COMPOSE_HASHTAG_CASING_SUGGESTION_IGNORE: + return updateCompose(state, action.composeId, compose => { + compose.hashtag_casing_suggestion = null; + compose.hashtag_casing_suggestion_ignored = true; + }); default: return state; } diff --git a/packages/pl-fe/src/schemas/pl-fe/settings.ts b/packages/pl-fe/src/schemas/pl-fe/settings.ts index ebd848e60..90b03aa80 100644 --- a/packages/pl-fe/src/schemas/pl-fe/settings.ts +++ b/packages/pl-fe/src/schemas/pl-fe/settings.ts @@ -21,6 +21,7 @@ const settingsSchema = v.object({ boostModal: v.fallback(v.boolean(), false), deleteModal: v.fallback(v.boolean(), true), missingDescriptionModal: v.fallback(v.boolean(), true), + ignoreHashtagCasingSuggestions: v.fallback(v.boolean(), false), defaultPrivacy: v.fallback(v.picklist(['public', 'unlisted', 'private', 'direct']), 'public'), defaultContentType: v.fallback(v.picklist(['text/plain', 'text/markdown', 'text/html', 'wysiwyg']), 'text/plain'), themeMode: v.fallback(v.picklist(['system', 'light', 'dark', 'black']), 'system'),