Composer redesign: Hints (#40212)

This commit is contained in:
Echo
2026-08-19 20:58:36 +00:00
committed by GitHub
parent 78e33538d1
commit 2fa679f4a4
6 changed files with 202 additions and 32 deletions

View File

@@ -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(
<FormattedMessage
id='compose.hints.followers-reply'
defaultMessage="You're replying to a followers-only post. People not following {user} might see your reply without the context of what youre replying to."
values={{ user: `@${replyFollowersHandle}` }}
/>,
),
);
}
if (isDifferentLanguage) {
messages.push(<LanguageHint guess={guess} />);
}
if (missingAlt && count === 1) {
messages.push(
defaultWrapper(
<FormattedMessage
id='compose.hints.missing-alt'
defaultMessage='Your attachment is missing alt text.'
/>,
),
);
} else if (missingAlt) {
messages.push(
defaultWrapper(
<FormattedMessage
id='compose.hints.missing-alt-many'
defaultMessage='One or more of your attachments are missing alt text.'
/>,
),
);
}
if (messages.length === 0) {
return null;
}
return <div>{messages}</div>;
};
const defaultWrapper = (children: React.ReactNode) => (
<Callout icon={WarningIcon}>{children}</Callout>
);
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 (
<Callout
icon={WarningIcon}
actionClick={handleChange}
actionText={
<FormattedMessage
id='compose.hints.language.change'
defaultMessage='Change'
description='Label on button to accept language change prompt'
/>
}
secondaryActionClick={dismiss}
secondaryActionText={
<FormattedMessage
id='compose.hints.language.dismiss'
defaultMessage='Dismiss'
description='Label on button to dismiss language change prompt'
/>
}
>
<FormattedMessage
id='compose.hints.language'
defaultMessage='Change this posts language to {language}?'
values={{
language: language[1],
}}
/>
</Callout>
);
};

View File

@@ -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;
}

View File

@@ -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<RedesignComposeFormProps> = ({
<ComposeAttachments />
</div>
<ComposeHints />
<ComposeFooter onEmojiPick={onEmojiPick} />
</form>
);

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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 youre replying to.",
"compose.hints.language": "Change this posts 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",