mirror of
https://github.com/mastodon/mastodon.git
synced 2026-09-12 21:06:11 -05:00
Composer redesign: Hints (#40212)
This commit is contained in:
145
app/javascript/mastodon/features/compose/redesign/hints.tsx
Normal file
145
app/javascript/mastodon/features/compose/redesign/hints.tsx
Normal 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 you’re 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 post’s language to {language}?'
|
||||
values={{
|
||||
language: language[1],
|
||||
}}
|
||||
/>
|
||||
</Callout>
|
||||
);
|
||||
};
|
||||
41
app/javascript/mastodon/features/compose/redesign/hooks.ts
Normal file
41
app/javascript/mastodon/features/compose/redesign/hooks.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user