diff --git a/app/javascript/mastodon/features/compose/redesign/hints.tsx b/app/javascript/mastodon/features/compose/redesign/hints.tsx new file mode 100644 index 00000000000..60ae65b6f7a --- /dev/null +++ b/app/javascript/mastodon/features/compose/redesign/hints.tsx @@ -0,0 +1,145 @@ +import type React from 'react'; +import { useCallback } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { WarningIcon } from '@phosphor-icons/react'; + +import { changeComposeLanguage } from '@/mastodon/actions/compose'; +import { Callout } from '@/mastodon/components/callout/redesign'; +import { useDismissible } from '@/mastodon/hooks/useDismissible'; +import { selectAccountStatus } from '@/mastodon/selectors/statuses'; +import { + createAppSelector, + useAppDispatch, + useAppSelector, +} from '@/mastodon/store'; + +import { useLanguageGuess, useLanguages } from './hooks'; +import { selectComposeAttachments } from './selectors'; + +const selectComposeAttachmentsWithoutAlt = createAppSelector( + [selectComposeAttachments], + (attachments) => ({ + count: attachments.length, + missingAlt: attachments.some((attachment) => !attachment.description), + }), +); + +const selectIsFollowersReply = createAppSelector( + [ + (state) => + selectAccountStatus( + state, + state.compose.get('in_reply_to') as null | string, + ), + ], + (status) => (status?.visibility === 'private' ? status.account.acct : null), +); + +export const ComposeHints = () => { + const { count, missingAlt } = useAppSelector( + selectComposeAttachmentsWithoutAlt, + ); + const replyFollowersHandle = useAppSelector(selectIsFollowersReply); + + const lang = useAppSelector( + (state) => state.compose.get('language') as string, + ); + const guess = useLanguageGuess(); + const isDifferentLanguage = lang && guess && lang !== guess; + + const messages: React.ReactNode[] = []; + + if (replyFollowersHandle) { + messages.push( + defaultWrapper( + , + ), + ); + } + + if (isDifferentLanguage) { + messages.push(); + } + + if (missingAlt && count === 1) { + messages.push( + defaultWrapper( + , + ), + ); + } else if (missingAlt) { + messages.push( + defaultWrapper( + , + ), + ); + } + + if (messages.length === 0) { + return null; + } + + return
{messages}
; +}; + +const defaultWrapper = (children: React.ReactNode) => ( + {children} +); + +const LanguageHint: React.FC<{ guess: string }> = ({ guess }) => { + const languages = useLanguages(); + const language = languages.find(([lang]) => lang === guess); + + const { wasDismissed, dismiss } = useDismissible('compose_language_hint'); + + const dispatch = useAppDispatch(); + const handleChange = useCallback(() => { + dispatch(changeComposeLanguage(guess)); + }, [dispatch, guess]); + + if (!language || wasDismissed) { + return null; + } + + return ( + + } + secondaryActionClick={dismiss} + secondaryActionText={ + + } + > + + + ); +}; diff --git a/app/javascript/mastodon/features/compose/redesign/hooks.ts b/app/javascript/mastodon/features/compose/redesign/hooks.ts new file mode 100644 index 00000000000..d3c1ce14f06 --- /dev/null +++ b/app/javascript/mastodon/features/compose/redesign/hooks.ts @@ -0,0 +1,41 @@ +import { useEffect, useState } from 'react'; + +import type { InitialStateLanguage } from '@/mastodon/initial_state'; +import { languages } from '@/mastodon/initial_state'; +import { useAppSelector } from '@/mastodon/store'; + +const emptyArray: InitialStateLanguage[] = []; + +export function useLanguages() { + return languages ?? emptyArray; +} + +export function useLanguageGuess() { + const text = useAppSelector((state) => state.compose.get('text') as string); + const [guess, setGuess] = useState(''); + + useEffect(() => { + void import('../util/language_detection').then(({ debouncedGuess }) => { + if (text.length > 20) { + debouncedGuess(text, setGuess); + } else { + debouncedGuess.cancel(); + } + }); + }, [text]); + + // Keeping track of the previous render's text length here + // to be able to reset the guess when the text length drops + // below the threshold needed to make a guess + const isLongText = text.length > 20; + const [wasLongText, setWasLongText] = useState(() => isLongText); + if (wasLongText !== isLongText) { + setWasLongText(isLongText); + + if (wasLongText) { + setGuess(''); + } + } + + return guess; +} diff --git a/app/javascript/mastodon/features/compose/redesign/index.tsx b/app/javascript/mastodon/features/compose/redesign/index.tsx index 1aad10855aa..8095836a841 100644 --- a/app/javascript/mastodon/features/compose/redesign/index.tsx +++ b/app/javascript/mastodon/features/compose/redesign/index.tsx @@ -30,6 +30,7 @@ import { ComposeAttachments } from './attachments'; import type { OnEmojiPick } from './emoji'; import { ComposeFooter } from './footer'; import { ComposeFormHeader } from './header'; +import { ComposeHints } from './hints'; import { LanguageButton } from './language'; import { ComposeReply } from './reply'; import { @@ -140,6 +141,8 @@ export const RedesignComposeForm: React.FC = ({ + + ); diff --git a/app/javascript/mastodon/features/compose/redesign/language.tsx b/app/javascript/mastodon/features/compose/redesign/language.tsx index 374b49f4809..ab57a404572 100644 --- a/app/javascript/mastodon/features/compose/redesign/language.tsx +++ b/app/javascript/mastodon/features/compose/redesign/language.tsx @@ -1,5 +1,5 @@ import type React from 'react'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import { FormattedMessage } from 'react-intl'; @@ -12,6 +12,7 @@ import { useAppDispatch, useAppSelector } from '@/mastodon/store'; import { LanguageDropdownMenu } from '../components/language_dropdown'; +import { useLanguageGuess } from './hooks'; import classes from './styles.module.scss'; export const LanguageButton: React.FC = () => { @@ -96,33 +97,3 @@ export const LanguageDropdown: React.FC<{ onClose: () => void }> = ({ /> ); }; - -function useLanguageGuess() { - const text = useAppSelector((state) => state.compose.get('text') as string); - const [guess, setGuess] = useState(''); - - useEffect(() => { - void import('../util/language_detection').then(({ debouncedGuess }) => { - if (text.length > 20) { - debouncedGuess(text, setGuess); - } else { - debouncedGuess.cancel(); - } - }); - }, [text]); - - // Keeping track of the previous render's text length here - // to be able to reset the guess when the text length drops - // below the threshold needed to make a guess - const isLongText = text.length > 20; - const [wasLongText, setWasLongText] = useState(() => isLongText); - if (wasLongText !== isLongText) { - setWasLongText(isLongText); - - if (wasLongText) { - setGuess(''); - } - } - - return guess; -} diff --git a/app/javascript/mastodon/initial_state.ts b/app/javascript/mastodon/initial_state.ts index 29bad1d6729..d452ad88614 100644 --- a/app/javascript/mastodon/initial_state.ts +++ b/app/javascript/mastodon/initial_state.ts @@ -1,7 +1,11 @@ import type { ApiAnnualReportState } from './api/annual_report'; import type { ApiAccountJSON } from './api_types/accounts'; -type InitialStateLanguage = [code: string, name: string, localName: string]; +export type InitialStateLanguage = [ + code: string, + name: string, + localName: string, +]; interface InitialStateMeta { access_token: string; diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index e88f0c730d2..544c131701e 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -494,6 +494,12 @@ "compose.discoverable": "Discoverable in public feeds & search results", "compose.error.blank_post": "Post can't be blank.", "compose.expand": "Show composer", + "compose.hints.followers-reply": "You're replying to a followers-only post. People not following {user} might see your reply without the context of what you’re replying to.", + "compose.hints.language": "Change this post’s language to {language}?", + "compose.hints.language.change": "Change", + "compose.hints.language.dismiss": "Dismiss", + "compose.hints.missing-alt": "Your attachment is missing alt text.", + "compose.hints.missing-alt-many": "One or more of your attachments are missing alt text.", "compose.language.change": "Change language", "compose.language.search": "Search languages...", "compose.message.notice": "Messages are not end-to-end encrypted",