pl-fe: Show suggestions about hashtag accessibility

Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
nicole mikołajczyk
2025-06-12 18:36:25 +02:00
parent 8d6a3ae290
commit cea35bfb1c
7 changed files with 150 additions and 3 deletions

View File

@ -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<typeof changeCompose>
@ -1040,7 +1054,9 @@ type ComposeAction =
| ReturnType<typeof changeComposeFederated>
| ReturnType<typeof changeComposeInteractionPolicyOption>
| ReturnType<typeof suggestClearLink>
| ReturnType<typeof ignoreClearLinkSuggestion>;
| ReturnType<typeof ignoreClearLinkSuggestion>
| ReturnType<typeof suggestHashtagCasing>
| ReturnType<typeof ignoreHashtagCasingSuggestion>;
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,

View File

@ -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 extends string>({ id, shouldCondense, autoFocus, clickab
<ClearLinkSuggestion composeId={id} handleAccept={onAcceptClearLinkSuggestion} handleReject={onRejectClearLinkSuggestion} />
<HashtagCasingSuggestion composeId={id} />
{composeModifiers}
<QuotedStatusContainer composeId={id} />

View File

@ -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 (
<OptionalMotion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
{({ opacity, scaleX, scaleY }) => (
<Stack space={1} className='rounded border border-solid border-gray-400 bg-transparent px-2.5 py-2 text-xs text-gray-900 dark:border-gray-800 dark:text-white' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
<span>
<FormattedMessage
id='compose.hashtag_casing_suggestion.body'
defaultMessage='Does the hashtag {hashtag} include more than one word? Prefer capitalizing the first letter of each word for improved readability and accessibility.'
values={{ hashtag: <span className='font-medium'>{suggestion}</span> }}
/>
</span>
<HStack space={2} justifyContent='end'>
<Button
theme='muted'
size='xs'
onClick={onIgnore}
>
<FormattedMessage id='compose.hashtag_casing_suggestion.ignore' defaultMessage='Ignore' />
</Button>
<Button
theme='muted'
size='xs'
onClick={onDontAskAgain}
>
<FormattedMessage id='compose.hashtag_casing_suggestion.dont_ask_again' defaultMessage='Dont ask again' />
</Button>
</HStack>
</Stack>
)}
</OptionalMotion>
);
};
export { HashtagCasingSuggestion as default };

View File

@ -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<IStatePlugin> = ({ 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<IStatePlugin> = ({ 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<IStatePlugin> = ({ composeId, isWysiwyg }) => {
const data = isEmpty ? null : JSON.stringify(editorState.toJSON());
dispatch(setEditorState(composeId, data, text));
checkUrls(editorState);
checkHashtagCasingSuggestions(editorState);
getQuoteSuggestions(plainText);
detectLanguage(plainText);
});

View File

@ -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": "Dont 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",

View File

@ -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<string>;
clear_link_suggestion: ClearLinkSuggestion | null;
preview: Partial<BaseStatus> | null;
hashtag_casing_suggestion: string | null;
hashtag_casing_suggestion_ignored: boolean | null;
}
const newCompose = (params: Partial<Compose> = {}): Compose => ({
@ -186,6 +190,8 @@ const newCompose = (params: Partial<Compose> = {}): 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;
}

View File

@ -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'),