Multilanguage posting

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak 2024-05-28 00:11:28 +02:00
parent a20e57e062
commit ed9dc9eee3
25 changed files with 323 additions and 136 deletions

View file

@ -31,24 +31,24 @@ import type { History } from 'soapbox/types/history';
let cancelFetchComposeSuggestions = new AbortController(); let cancelFetchComposeSuggestions = new AbortController();
const COMPOSE_CHANGE = 'COMPOSE_CHANGE' as const; const COMPOSE_CHANGE = 'COMPOSE_CHANGE' as const;
const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST' as const; const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST' as const;
const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS' as const; const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS' as const;
const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL' as const; const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL' as const;
const COMPOSE_REPLY = 'COMPOSE_REPLY' as const; const COMPOSE_REPLY = 'COMPOSE_REPLY' as const;
const COMPOSE_EVENT_REPLY = 'COMPOSE_EVENT_REPLY' as const; const COMPOSE_EVENT_REPLY = 'COMPOSE_EVENT_REPLY' as const;
const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL' as const; const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL' as const;
const COMPOSE_QUOTE = 'COMPOSE_QUOTE' as const; const COMPOSE_QUOTE = 'COMPOSE_QUOTE' as const;
const COMPOSE_QUOTE_CANCEL = 'COMPOSE_QUOTE_CANCEL' as const; const COMPOSE_QUOTE_CANCEL = 'COMPOSE_QUOTE_CANCEL' as const;
const COMPOSE_DIRECT = 'COMPOSE_DIRECT' as const; const COMPOSE_DIRECT = 'COMPOSE_DIRECT' as const;
const COMPOSE_MENTION = 'COMPOSE_MENTION' as const; const COMPOSE_MENTION = 'COMPOSE_MENTION' as const;
const COMPOSE_RESET = 'COMPOSE_RESET' as const; const COMPOSE_RESET = 'COMPOSE_RESET' as const;
const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST' as const; const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST' as const;
const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS' as const; const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS' as const;
const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL' as const; const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL' as const;
const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS' as const; const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS' as const;
const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO' as const; const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO' as const;
const COMPOSE_GROUP_POST = 'COMPOSE_GROUP_POST' as const; const COMPOSE_GROUP_POST = 'COMPOSE_GROUP_POST' as const;
const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR' as const; const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR' as const;
const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY' as const; const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY' as const;
@ -60,27 +60,28 @@ const COMPOSE_TAG_HISTORY_UPDATE = 'COMPOSE_TAG_HISTORY_UPDATE' as const;
const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE' as const; const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE' as const;
const COMPOSE_TYPE_CHANGE = 'COMPOSE_TYPE_CHANGE' as const; const COMPOSE_TYPE_CHANGE = 'COMPOSE_TYPE_CHANGE' as const;
const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE' as const; const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE' as const;
const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE' as const; const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE' as const;
const COMPOSE_LANGUAGE_CHANGE = 'COMPOSE_LANGUAGE_CHANGE' as const; const COMPOSE_LANGUAGE_CHANGE = 'COMPOSE_LANGUAGE_CHANGE' as const;
const COMPOSE_LANGUAGE_ADD = 'COMPOSE_LANGUAGE_ADD' as const; const COMPOSE_MODIFIED_LANGUAGE_CHANGE = 'COMPOSE_MODIFIED_LANGUAGE_CHANGE' as const;
const COMPOSE_LANGUAGE_DELETE = 'COMPOSE_LANGUAGE_DELETE' as const; const COMPOSE_LANGUAGE_ADD = 'COMPOSE_LANGUAGE_ADD' as const;
const COMPOSE_LANGUAGE_DELETE = 'COMPOSE_LANGUAGE_DELETE' as const;
const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE' as const; const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE' as const;
const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT' as const; const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT' as const;
const COMPOSE_UPLOAD_CHANGE_REQUEST = 'COMPOSE_UPLOAD_UPDATE_REQUEST' as const; const COMPOSE_UPLOAD_CHANGE_REQUEST = 'COMPOSE_UPLOAD_UPDATE_REQUEST' as const;
const COMPOSE_UPLOAD_CHANGE_SUCCESS = 'COMPOSE_UPLOAD_UPDATE_SUCCESS' as const; const COMPOSE_UPLOAD_CHANGE_SUCCESS = 'COMPOSE_UPLOAD_UPDATE_SUCCESS' as const;
const COMPOSE_UPLOAD_CHANGE_FAIL = 'COMPOSE_UPLOAD_UPDATE_FAIL' as const; const COMPOSE_UPLOAD_CHANGE_FAIL = 'COMPOSE_UPLOAD_UPDATE_FAIL' as const;
const COMPOSE_POLL_ADD = 'COMPOSE_POLL_ADD' as const; const COMPOSE_POLL_ADD = 'COMPOSE_POLL_ADD' as const;
const COMPOSE_POLL_REMOVE = 'COMPOSE_POLL_REMOVE' as const; const COMPOSE_POLL_REMOVE = 'COMPOSE_POLL_REMOVE' as const;
const COMPOSE_POLL_OPTION_ADD = 'COMPOSE_POLL_OPTION_ADD' as const; const COMPOSE_POLL_OPTION_ADD = 'COMPOSE_POLL_OPTION_ADD' as const;
const COMPOSE_POLL_OPTION_CHANGE = 'COMPOSE_POLL_OPTION_CHANGE' as const; const COMPOSE_POLL_OPTION_CHANGE = 'COMPOSE_POLL_OPTION_CHANGE' as const;
const COMPOSE_POLL_OPTION_REMOVE = 'COMPOSE_POLL_OPTION_REMOVE' as const; const COMPOSE_POLL_OPTION_REMOVE = 'COMPOSE_POLL_OPTION_REMOVE' as const;
const COMPOSE_POLL_SETTINGS_CHANGE = 'COMPOSE_POLL_SETTINGS_CHANGE' as const; const COMPOSE_POLL_SETTINGS_CHANGE = 'COMPOSE_POLL_SETTINGS_CHANGE' as const;
const COMPOSE_SCHEDULE_ADD = 'COMPOSE_SCHEDULE_ADD' as const; const COMPOSE_SCHEDULE_ADD = 'COMPOSE_SCHEDULE_ADD' as const;
const COMPOSE_SCHEDULE_SET = 'COMPOSE_SCHEDULE_SET' as const; const COMPOSE_SCHEDULE_SET = 'COMPOSE_SCHEDULE_SET' as const;
const COMPOSE_SCHEDULE_REMOVE = 'COMPOSE_SCHEDULE_REMOVE' as const; const COMPOSE_SCHEDULE_REMOVE = 'COMPOSE_SCHEDULE_REMOVE' as const;
const COMPOSE_ADD_TO_MENTIONS = 'COMPOSE_ADD_TO_MENTIONS' as const; const COMPOSE_ADD_TO_MENTIONS = 'COMPOSE_ADD_TO_MENTIONS' as const;
@ -92,7 +93,7 @@ const COMPOSE_EDITOR_STATE_SET = 'COMPOSE_EDITOR_STATE_SET' as const;
const COMPOSE_CHANGE_MEDIA_ORDER = 'COMPOSE_CHANGE_MEDIA_ORDER' as const; const COMPOSE_CHANGE_MEDIA_ORDER = 'COMPOSE_CHANGE_MEDIA_ORDER' as const;
const COMPOSE_ADD_SUGGESTED_QUOTE = 'COMPOSE_ADD_SUGGESTED_QUOTE' as const; const COMPOSE_ADD_SUGGESTED_QUOTE = 'COMPOSE_ADD_SUGGESTED_QUOTE' as const;
const COMPOSE_ADD_SUGGESTED_LANGUAGE = 'COMPOSE_ADD_SUGGESTED_LANGUAGE' as const; const COMPOSE_ADD_SUGGESTED_LANGUAGE = 'COMPOSE_ADD_SUGGESTED_LANGUAGE' as const;
const getAccount = makeGetAccount(); const getAccount = makeGetAccount();
@ -301,7 +302,7 @@ const handleComposeSubmit = (dispatch: AppDispatch, getState: () => RootState, c
}; };
const needsDescriptions = (state: RootState, composeId: string) => { const needsDescriptions = (state: RootState, composeId: string) => {
const media = state.compose.get(composeId)!.media_attachments; const media = state.compose.get(composeId)!.media_attachments;
const missingDescriptionModal = getSettings(state).get('missingDescriptionModal'); const missingDescriptionModal = getSettings(state).get('missingDescriptionModal');
const hasMissing = media.filter(item => !item.description).size > 0; const hasMissing = media.filter(item => !item.description).size > 0;
@ -332,10 +333,10 @@ const submitCompose = (composeId: string, opts: SubmitComposeOpts = {}) =>
const compose = state.compose.get(composeId)!; const compose = state.compose.get(composeId)!;
const status = compose.text; const status = compose.text;
const media = compose.media_attachments; const media = compose.media_attachments;
const statusId = compose.id; const statusId = compose.id;
let to = compose.to; let to = compose.to;
if (!validateSchedule(state, composeId)) { if (!validateSchedule(state, composeId)) {
toast.error(messages.scheduleError); toast.error(messages.scheduleError);
@ -387,6 +388,22 @@ const submitCompose = (composeId: string, opts: SubmitComposeOpts = {}) =>
to, to,
}; };
if (compose.language && compose.textMap.size) {
params.status_map = compose.textMap.toJS();
params.status_map[compose.language] = status;
if (params.spoiler_text) {
params.spoiler_text_map = compose.spoilerTextMap.toJS();
params.spoiler_text_map[compose.language] = compose.spoiler_text;
}
if (params.poll) {
const poll = params.poll.toJS();
poll.options.forEach((option: any, index: number) => poll.options_map[index][compose.language!] = option);
params.poll = poll;
}
}
if (compose.privacy === 'group') { if (compose.privacy === 'group') {
params.group_id = compose.group_id; params.group_id = compose.group_id;
} }
@ -425,7 +442,7 @@ const uploadCompose = (composeId: string, files: FileList, intl: IntlShape) =>
if (!isLoggedIn(getState)) return; if (!isLoggedIn(getState)) return;
const attachmentLimit = getState().instance.configuration.statuses.max_media_attachments; const attachmentLimit = getState().instance.configuration.statuses.max_media_attachments;
const media = getState().compose.get(composeId)?.media_attachments; const media = getState().compose.get(composeId)?.media_attachments;
const progress = new Array(files.length).fill(0); const progress = new Array(files.length).fill(0);
let total = Array.from(files).reduce((a, v) => a + v.size, 0); let total = Array.from(files).reduce((a, v) => a + v.size, 0);
@ -438,7 +455,7 @@ const uploadCompose = (composeId: string, files: FileList, intl: IntlShape) =>
dispatch(uploadComposeRequest(composeId)); dispatch(uploadComposeRequest(composeId));
Array.from(files).forEach(async(f, i) => { Array.from(files).forEach(async (f, i) => {
if (mediaCount + i > attachmentLimit - 1) return; if (mediaCount + i > attachmentLimit - 1) return;
dispatch(uploadFile( dispatch(uploadFile(
@ -659,15 +676,15 @@ const selectComposeSuggestion = (composeId: string, position: number, token: str
let completion = '', startPosition = position; let completion = '', startPosition = position;
if (typeof suggestion === 'object' && suggestion.id) { if (typeof suggestion === 'object' && suggestion.id) {
completion = isNativeEmoji(suggestion) ? suggestion.native : suggestion.colons; completion = isNativeEmoji(suggestion) ? suggestion.native : suggestion.colons;
startPosition = position - 1; startPosition = position - 1;
dispatch(chooseEmoji(suggestion)); dispatch(chooseEmoji(suggestion));
} else if (typeof suggestion === 'string' && suggestion[0] === '#') { } else if (typeof suggestion === 'string' && suggestion[0] === '#') {
completion = suggestion; completion = suggestion;
startPosition = position - 1; startPosition = position - 1;
} else if (typeof suggestion === 'string') { } else if (typeof suggestion === 'string') {
completion = selectAccount(getState(), suggestion)!.acct; completion = selectAccount(getState(), suggestion)!.acct;
startPosition = position; startPosition = position;
} }
@ -743,13 +760,19 @@ const changeComposeLanguage = (composeId: string, value: Language | null) => ({
value, value,
}); });
const addComposeLanguage = (composeId: string, value: Language | null) => ({ const changeComposeModifiedLanguage = (composeId: string, value: Language | null) => ({
type: COMPOSE_MODIFIED_LANGUAGE_CHANGE,
id: composeId,
value,
});
const addComposeLanguage = (composeId: string, value: Language) => ({
type: COMPOSE_LANGUAGE_ADD, type: COMPOSE_LANGUAGE_ADD,
id: composeId, id: composeId,
value, value,
}); });
const deleteComposeLanguage = (composeId: string, value: Language | null) => ({ const deleteComposeLanguage = (composeId: string, value: Language) => ({
type: COMPOSE_LANGUAGE_DELETE, type: COMPOSE_LANGUAGE_DELETE,
id: composeId, id: composeId,
value, value,
@ -945,6 +968,7 @@ type ComposeAction =
| ReturnType<typeof changeComposeSpoilerText> | ReturnType<typeof changeComposeSpoilerText>
| ReturnType<typeof changeComposeVisibility> | ReturnType<typeof changeComposeVisibility>
| ReturnType<typeof changeComposeLanguage> | ReturnType<typeof changeComposeLanguage>
| ReturnType<typeof changeComposeModifiedLanguage>
| ReturnType<typeof addComposeLanguage> | ReturnType<typeof addComposeLanguage>
| ReturnType<typeof deleteComposeLanguage> | ReturnType<typeof deleteComposeLanguage>
| ReturnType<typeof insertEmojiCompose> | ReturnType<typeof insertEmojiCompose>
@ -994,6 +1018,7 @@ export {
COMPOSE_SPOILER_TEXT_CHANGE, COMPOSE_SPOILER_TEXT_CHANGE,
COMPOSE_VISIBILITY_CHANGE, COMPOSE_VISIBILITY_CHANGE,
COMPOSE_LANGUAGE_CHANGE, COMPOSE_LANGUAGE_CHANGE,
COMPOSE_MODIFIED_LANGUAGE_CHANGE,
COMPOSE_LANGUAGE_ADD, COMPOSE_LANGUAGE_ADD,
COMPOSE_LANGUAGE_DELETE, COMPOSE_LANGUAGE_DELETE,
COMPOSE_LISTABILITY_CHANGE, COMPOSE_LISTABILITY_CHANGE,
@ -1057,6 +1082,7 @@ export {
changeComposeSpoilerText, changeComposeSpoilerText,
changeComposeVisibility, changeComposeVisibility,
changeComposeLanguage, changeComposeLanguage,
changeComposeModifiedLanguage,
addComposeLanguage, addComposeLanguage,
deleteComposeLanguage, deleteComposeLanguage,
insertEmojiCompose, insertEmojiCompose,

View file

@ -13,42 +13,44 @@ import type { APIEntity, Status } from 'soapbox/types/entities';
const STATUS_CREATE_REQUEST = 'STATUS_CREATE_REQUEST'; const STATUS_CREATE_REQUEST = 'STATUS_CREATE_REQUEST';
const STATUS_CREATE_SUCCESS = 'STATUS_CREATE_SUCCESS'; const STATUS_CREATE_SUCCESS = 'STATUS_CREATE_SUCCESS';
const STATUS_CREATE_FAIL = 'STATUS_CREATE_FAIL'; const STATUS_CREATE_FAIL = 'STATUS_CREATE_FAIL';
const STATUS_FETCH_SOURCE_REQUEST = 'STATUS_FETCH_SOURCE_REQUEST'; const STATUS_FETCH_SOURCE_REQUEST = 'STATUS_FETCH_SOURCE_REQUEST';
const STATUS_FETCH_SOURCE_SUCCESS = 'STATUS_FETCH_SOURCE_SUCCESS'; const STATUS_FETCH_SOURCE_SUCCESS = 'STATUS_FETCH_SOURCE_SUCCESS';
const STATUS_FETCH_SOURCE_FAIL = 'STATUS_FETCH_SOURCE_FAIL'; const STATUS_FETCH_SOURCE_FAIL = 'STATUS_FETCH_SOURCE_FAIL';
const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST'; const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS'; const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
const STATUS_FETCH_FAIL = 'STATUS_FETCH_FAIL'; const STATUS_FETCH_FAIL = 'STATUS_FETCH_FAIL';
const STATUS_DELETE_REQUEST = 'STATUS_DELETE_REQUEST'; const STATUS_DELETE_REQUEST = 'STATUS_DELETE_REQUEST';
const STATUS_DELETE_SUCCESS = 'STATUS_DELETE_SUCCESS'; const STATUS_DELETE_SUCCESS = 'STATUS_DELETE_SUCCESS';
const STATUS_DELETE_FAIL = 'STATUS_DELETE_FAIL'; const STATUS_DELETE_FAIL = 'STATUS_DELETE_FAIL';
const CONTEXT_FETCH_REQUEST = 'CONTEXT_FETCH_REQUEST'; const CONTEXT_FETCH_REQUEST = 'CONTEXT_FETCH_REQUEST';
const CONTEXT_FETCH_SUCCESS = 'CONTEXT_FETCH_SUCCESS'; const CONTEXT_FETCH_SUCCESS = 'CONTEXT_FETCH_SUCCESS';
const CONTEXT_FETCH_FAIL = 'CONTEXT_FETCH_FAIL'; const CONTEXT_FETCH_FAIL = 'CONTEXT_FETCH_FAIL';
const STATUS_MUTE_REQUEST = 'STATUS_MUTE_REQUEST'; const STATUS_MUTE_REQUEST = 'STATUS_MUTE_REQUEST';
const STATUS_MUTE_SUCCESS = 'STATUS_MUTE_SUCCESS'; const STATUS_MUTE_SUCCESS = 'STATUS_MUTE_SUCCESS';
const STATUS_MUTE_FAIL = 'STATUS_MUTE_FAIL'; const STATUS_MUTE_FAIL = 'STATUS_MUTE_FAIL';
const STATUS_UNMUTE_REQUEST = 'STATUS_UNMUTE_REQUEST'; const STATUS_UNMUTE_REQUEST = 'STATUS_UNMUTE_REQUEST';
const STATUS_UNMUTE_SUCCESS = 'STATUS_UNMUTE_SUCCESS'; const STATUS_UNMUTE_SUCCESS = 'STATUS_UNMUTE_SUCCESS';
const STATUS_UNMUTE_FAIL = 'STATUS_UNMUTE_FAIL'; const STATUS_UNMUTE_FAIL = 'STATUS_UNMUTE_FAIL';
const STATUS_REVEAL = 'STATUS_REVEAL'; const STATUS_REVEAL = 'STATUS_REVEAL';
const STATUS_HIDE = 'STATUS_HIDE'; const STATUS_HIDE = 'STATUS_HIDE';
const STATUS_TRANSLATE_REQUEST = 'STATUS_TRANSLATE_REQUEST'; const STATUS_TRANSLATE_REQUEST = 'STATUS_TRANSLATE_REQUEST';
const STATUS_TRANSLATE_SUCCESS = 'STATUS_TRANSLATE_SUCCESS'; const STATUS_TRANSLATE_SUCCESS = 'STATUS_TRANSLATE_SUCCESS';
const STATUS_TRANSLATE_FAIL = 'STATUS_TRANSLATE_FAIL'; const STATUS_TRANSLATE_FAIL = 'STATUS_TRANSLATE_FAIL';
const STATUS_TRANSLATE_UNDO = 'STATUS_TRANSLATE_UNDO'; const STATUS_TRANSLATE_UNDO = 'STATUS_TRANSLATE_UNDO';
const STATUS_UNFILTER = 'STATUS_UNFILTER'; const STATUS_UNFILTER = 'STATUS_UNFILTER';
const STATUS_LANGUAGE_CHANGE = 'STATUS_LANGUAGE_CHANGE';
const statusExists = (getState: () => RootState, statusId: string) => const statusExists = (getState: () => RootState, statusId: string) =>
(getState().statuses.get(statusId) || null) !== null; (getState().statuses.get(statusId) || null) !== null;
@ -185,7 +187,7 @@ const fetchContext = (id: string) =>
}; };
const fetchStatusWithContext = (id: string) => const fetchStatusWithContext = (id: string) =>
async(dispatch: AppDispatch, getState: () => RootState) => { async (dispatch: AppDispatch, getState: () => RootState) => {
await Promise.all([ await Promise.all([
dispatch(fetchContext(id)), dispatch(fetchContext(id)),
dispatch(fetchStatus(id)), dispatch(fetchStatus(id)),
@ -286,6 +288,12 @@ const unfilterStatus = (id: string) => ({
id, id,
}); });
const changeStatusLanguage = (id: string, language: string) => ({
type: STATUS_LANGUAGE_CHANGE,
id,
language,
});
export { export {
STATUS_CREATE_REQUEST, STATUS_CREATE_REQUEST,
STATUS_CREATE_SUCCESS, STATUS_CREATE_SUCCESS,
@ -315,6 +323,7 @@ export {
STATUS_TRANSLATE_FAIL, STATUS_TRANSLATE_FAIL,
STATUS_TRANSLATE_UNDO, STATUS_TRANSLATE_UNDO,
STATUS_UNFILTER, STATUS_UNFILTER,
STATUS_LANGUAGE_CHANGE,
createStatus, createStatus,
editStatus, editStatus,
fetchStatus, fetchStatus,
@ -331,4 +340,5 @@ export {
translateStatus, translateStatus,
undoStatusTranslation, undoStatusTranslation,
unfilterStatus, unfilterStatus,
changeStatusLanguage,
}; };

View file

@ -192,8 +192,6 @@ const expandTimeline = (timelineId: string, path: string, params: Record<string,
const statuses = deduplicateStatuses(response.json); const statuses = deduplicateStatuses(response.json);
dispatch(importFetchedStatuses(statuses.filter(status => status.accounts))); dispatch(importFetchedStatuses(statuses.filter(status => status.accounts)));
console.log(response);
dispatch(expandTimelineSuccess( dispatch(expandTimelineSuccess(
timelineId, timelineId,
statuses, statuses,

View file

@ -23,7 +23,6 @@ interface IInstanceFavicon {
const messages = defineMessages({ const messages = defineMessages({
bot: { id: 'account.badges.bot', defaultMessage: 'Bot' }, bot: { id: 'account.badges.bot', defaultMessage: 'Bot' },
languageVersions: { id: 'status.language_versions', defaultMessage: 'The post has multiple language versions.' },
}); });
const InstanceFavicon: React.FC<IInstanceFavicon> = ({ account, disabled }) => { const InstanceFavicon: React.FC<IInstanceFavicon> = ({ account, disabled }) => {
@ -91,11 +90,11 @@ interface IAccount {
withLinkToProfile?: boolean; withLinkToProfile?: boolean;
withRelationship?: boolean; withRelationship?: boolean;
showEdit?: boolean; showEdit?: boolean;
showMultiLanguage?: boolean;
approvalStatus?: StatusApprovalStatus; approvalStatus?: StatusApprovalStatus;
emoji?: string; emoji?: string;
emojiUrl?: string; emojiUrl?: string;
note?: string; note?: string;
items?: React.ReactNode;
} }
const Account = ({ const Account = ({
@ -118,11 +117,11 @@ const Account = ({
withLinkToProfile = true, withLinkToProfile = true,
withRelationship = true, withRelationship = true,
showEdit = false, showEdit = false,
showMultiLanguage = false,
approvalStatus, approvalStatus,
emoji, emoji,
emojiUrl, emojiUrl,
note, note,
items,
}: IAccount) => { }: IAccount) => {
const overflowRef = useRef<HTMLDivElement>(null); const overflowRef = useRef<HTMLDivElement>(null);
const actionRef = useRef<HTMLDivElement>(null); const actionRef = useRef<HTMLDivElement>(null);
@ -271,16 +270,6 @@ const Account = ({
</> </>
) : null} ) : null}
{showMultiLanguage ? (
<>
<Text tag='span' theme='muted' size='sm'>&middot;</Text>
<button title={intl.formatMessage(messages.languageVersions)}>
<Icon className='h-5 w-5 text-gray-700 dark:text-gray-600' src={require('@tabler/icons/outline/language.svg')} />
</button>
</>
) : null}
{actionType === 'muting' && account.mute_expires_at ? ( {actionType === 'muting' && account.mute_expires_at ? (
<> <>
<Text tag='span' theme='muted' size='sm'>&middot;</Text> <Text tag='span' theme='muted' size='sm'>&middot;</Text>
@ -288,6 +277,8 @@ const Account = ({
<Text theme='muted' size='sm'><RelativeTimestamp timestamp={account.mute_expires_at} futureDate /></Text> <Text theme='muted' size='sm'><RelativeTimestamp timestamp={account.mute_expires_at} futureDate /></Text>
</> </>
) : null} ) : null}
{items}
</HStack> </HStack>
{note ? ( {note ? (

View file

@ -242,10 +242,6 @@ const DropdownMenu = (props: IDropdownMenu) => {
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
if (refs.floating.current) {
(refs.floating.current?.querySelector('li a[role=\'button\']') as HTMLAnchorElement)?.focus();
}
document.addEventListener('click', handleDocumentClick, false); document.addEventListener('click', handleDocumentClick, false);
document.addEventListener('keydown', handleKeyDown, false); document.addEventListener('keydown', handleKeyDown, false);
document.addEventListener('touchend', handleDocumentClick, listenerOptions); document.addEventListener('touchend', handleDocumentClick, listenerOptions);

View file

@ -100,10 +100,11 @@ interface IPollOption {
showResults?: boolean; showResults?: boolean;
active: boolean; active: boolean;
onToggle: (value: number) => void; onToggle: (value: number) => void;
language?: string | null;
} }
const PollOption: React.FC<IPollOption> = (props): JSX.Element | null => { const PollOption: React.FC<IPollOption> = (props): JSX.Element | null => {
const { index, poll, option, showResults } = props; const { index, poll, option, showResults, language } = props;
const intl = useIntl(); const intl = useIntl();
@ -133,7 +134,7 @@ const PollOption: React.FC<IPollOption> = (props): JSX.Element | null => {
<Text <Text
theme='inherit' theme='inherit'
weight='medium' weight='medium'
dangerouslySetInnerHTML={{ __html: option.title_emojified }} dangerouslySetInnerHTML={{ __html: (language && option.title_map_emojified) && option.title_map_emojified[language] || option.title_emojified }}
className='relative' className='relative'
/> />
</div> </div>

View file

@ -10,11 +10,13 @@ import { Stack, Text } from '../ui';
import PollFooter from './poll-footer'; import PollFooter from './poll-footer';
import PollOption from './poll-option'; import PollOption from './poll-option';
import type { Status } from 'soapbox/types/entities';
type Selected = Record<number, boolean>; type Selected = Record<number, boolean>;
interface IPoll { interface IPoll {
id: string; id: string;
status?: string; status?: Status;
} }
const messages = defineMessages({ const messages = defineMessages({
@ -33,7 +35,7 @@ const Poll: React.FC<IPoll> = ({ id, status }): JSX.Element | null => {
const openUnauthorizedModal = () => const openUnauthorizedModal = () =>
dispatch(openModal('UNAUTHORIZED', { dispatch(openModal('UNAUTHORIZED', {
action: 'POLL_VOTE', action: 'POLL_VOTE',
ap_id: status, ap_id: status?.url,
})); }));
const handleVote = (selectedId: number) => dispatch(vote(id, [String(selectedId)])); const handleVote = (selectedId: number) => dispatch(vote(id, [String(selectedId)]));
@ -83,6 +85,7 @@ const Poll: React.FC<IPoll> = ({ id, status }): JSX.Element | null => {
showResults={showResults} showResults={showResults}
active={!!selected[i]} active={!!selected[i]}
onToggle={toggleOption} onToggle={toggleOption}
language={status?.currentLanguage}
/> />
))} ))}
</Stack> </Stack>

View file

@ -77,8 +77,12 @@ const StatusContent: React.FC<IStatusContent> = React.memo(({
}); });
const parsedHtml = useMemo( const parsedHtml = useMemo(
(): string => translatable && status.translation ? status.translation.get('content')! : status.contentHtml, (): string => translatable && status.translation
[status.contentHtml, status.translation], ? status.translation.get('content')!
: (status.contentMapHtml && status.currentLanguage)
? status.contentMapHtml.get(status.currentLanguage, status.contentHtml)
: status.contentHtml,
[status.contentHtml, status.translation, status.currentLanguage],
); );
if (status.content.length === 0) { if (status.content.length === 0) {
@ -160,7 +164,7 @@ const StatusContent: React.FC<IStatusContent> = React.memo(({
const hasPoll = status.poll && typeof status.poll === 'string'; const hasPoll = status.poll && typeof status.poll === 'string';
if (hasPoll) { if (hasPoll) {
output.push(<Poll id={status.poll} key='poll' status={status.url} />); output.push(<Poll id={status.poll} key='poll' status={status} />);
} }
return <div className={clsx({ 'bg-gray-100 dark:bg-primary-800 rounded-md p-4': hasPoll })}>{output}</div>; return <div className={clsx({ 'bg-gray-100 dark:bg-primary-800 rounded-md p-4': hasPoll })}>{output}</div>;
@ -182,7 +186,7 @@ const StatusContent: React.FC<IStatusContent> = React.memo(({
]; ];
if (status.poll && typeof status.poll === 'string') { if (status.poll && typeof status.poll === 'string') {
output.push(<Poll id={status.poll} key='poll' status={status.url} />); output.push(<Poll id={status.poll} key='poll' status={status} />);
} }
return <>{output}</>; return <>{output}</>;

View file

@ -0,0 +1,58 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { changeStatusLanguage } from 'soapbox/actions/statuses';
import { type Language, languages } from 'soapbox/features/preferences';
import { useAppDispatch } from 'soapbox/hooks';
import DropdownMenu from './dropdown-menu';
import { HStack, Icon, Text } from './ui';
import type { Status } from 'soapbox/types/entities';
const messages = defineMessages({
languageVersions: { id: 'status.language_versions', defaultMessage: 'The post has multiple language versions.' },
});
interface IStatusLanguagePicker {
status: Status;
showLabel?: boolean;
}
const StatusLanguagePicker: React.FC<IStatusLanguagePicker> = ({ status, showLabel }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
if (!status.contentMapHtml || status.contentMapHtml.isEmpty()) return null;
const icon = <Icon className='h-5 w-5 text-gray-700 dark:text-gray-600' src={require('@tabler/icons/outline/language.svg')} />;
return (
<>
<Text tag='span' theme='muted' size='sm'>&middot;</Text>
<DropdownMenu
items={status.contentMapHtml.keySeq().toJS().map((language) => ({
text: languages[language as Language] || language,
action: () => dispatch(changeStatusLanguage(status.id, language)),
active: language === status.currentLanguage,
}))}
>
<button title={intl.formatMessage(messages.languageVersions)} className='hover:underline'>
{showLabel ? (
<HStack space={1} alignItems='center'>
{icon}
<Text tag='span' theme='muted' size='sm'>
{languages[status.currentLanguage as Language] || status.currentLanguage}
</Text>
</HStack>
) : icon}
</button>
</DropdownMenu>
</>
);
};
export {
StatusLanguagePicker as default,
};

View file

@ -18,6 +18,7 @@ import { textForScreenReader, getActualStatus } from 'soapbox/utils/status';
import EventPreview from './event-preview'; import EventPreview from './event-preview';
import StatusActionBar from './status-action-bar'; import StatusActionBar from './status-action-bar';
import StatusContent from './status-content'; import StatusContent from './status-content';
import StatusLanguagePicker from './status-language-picker';
import StatusMedia from './status-media'; import StatusMedia from './status-media';
import StatusReplyMentions from './status-reply-mentions'; import StatusReplyMentions from './status-reply-mentions';
import SensitiveContentOverlay from './statuses/sensitive-content-overlay'; import SensitiveContentOverlay from './statuses/sensitive-content-overlay';
@ -428,11 +429,11 @@ const Status: React.FC<IStatus> = (props) => {
action={accountAction} action={accountAction}
hideActions={!accountAction} hideActions={!accountAction}
showEdit={!!actualStatus.edited_at} showEdit={!!actualStatus.edited_at}
showMultiLanguage={!!actualStatus.content_map && actualStatus.content_map?.count() > 1}
showProfileHoverCard={hoverable} showProfileHoverCard={hoverable}
withLinkToProfile={hoverable} withLinkToProfile={hoverable}
approvalStatus={actualStatus.approval_status} approvalStatus={actualStatus.approval_status}
avatarSize={avatarSize} avatarSize={avatarSize}
items={<StatusLanguagePicker status={status} />}
/> />
<div className='status__content-wrapper'> <div className='status__content-wrapper'>

View file

@ -47,6 +47,10 @@ const SensitiveContentOverlay = React.forwardRef<HTMLDivElement, ISensitiveConte
if (visible && !showHideButton) return null; if (visible && !showHideButton) return null;
const spoilerText = status.currentLanguage
? status.spoilerMapHtml!.get(status.currentLanguage, status.spoilerHtml)
: status.spoilerHtml;
return ( return (
<div <div
className={clsx('absolute z-40', { className={clsx('absolute z-40', {
@ -66,7 +70,7 @@ const SensitiveContentOverlay = React.forwardRef<HTMLDivElement, ISensitiveConte
/> />
) : ( ) : (
<div className='flex max-h-screen items-center justify-center'> <div className='flex max-h-screen items-center justify-center'>
<div className='mx-auto w-3/4 space-y-4 text-center' ref={ref}> <div className='mx-auto space-y-4 text-center' ref={ref}>
<div className='space-y-1'> <div className='space-y-1'>
<Text theme='white' weight='semibold'> <Text theme='white' weight='semibold'>
{intl.formatMessage(messages.sensitiveTitle)} {intl.formatMessage(messages.sensitiveTitle)}
@ -79,7 +83,7 @@ const SensitiveContentOverlay = React.forwardRef<HTMLDivElement, ISensitiveConte
{status.spoiler_text && ( {status.spoiler_text && (
<div className='py-4 italic'> <div className='py-4 italic'>
<Text className='line-clamp-6' theme='white' size='md' weight='medium'> <Text className='line-clamp-6' theme='white' size='md' weight='medium'>
&ldquo;<span dangerouslySetInnerHTML={{ __html: status.spoilerHtml }} />&rdquo; &ldquo;<span dangerouslySetInnerHTML={{ __html: spoilerText }} />&rdquo;
</Text> </Text>
</div> </div>
)} )}

View file

@ -27,7 +27,7 @@ const TranslateButton: React.FC<ITranslateButton> = ({ status }) => {
target_languages: targetLanguages, target_languages: targetLanguages,
} = instance.pleroma.metadata.translation; } = instance.pleroma.metadata.translation;
const renderTranslate = (me || allowUnauthenticated) && (allowRemote || status.account.local) && ['public', 'unlisted'].includes(status.visibility) && status.contentHtml.length > 0 && status.language !== null && intl.locale !== status.language; const renderTranslate = (me || allowUnauthenticated) && (allowRemote || status.account.local) && ['public', 'unlisted'].includes(status.visibility) && status.contentHtml.length > 0 && status.language !== null && intl.locale !== status.language && !status.contentMapHtml?.has(intl.locale);
const supportsLanguages = (!sourceLanguages || sourceLanguages.includes(status.language!)) && (!targetLanguages || targetLanguages.includes(intl.locale)); const supportsLanguages = (!sourceLanguages || sourceLanguages.includes(status.language!)) && (!targetLanguages || targetLanguages.includes(intl.locale));

View file

@ -87,6 +87,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
schedule: scheduledAt, schedule: scheduledAt,
group_id: groupId, group_id: groupId,
text, text,
modified_language: modifiedLanguage,
} = compose; } = compose;
const prevSpoiler = usePrevious(spoiler); const prevSpoiler = usePrevious(spoiler);
@ -282,6 +283,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
<div> <div>
<Suspense> <Suspense>
<ComposeEditor <ComposeEditor
key={modifiedLanguage}
ref={editorRef} ref={editorRef}
className='mt-2' className='mt-2'
composeId={id} composeId={id}

View file

@ -7,7 +7,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { addComposeLanguage, changeComposeLanguage, deleteComposeLanguage } from 'soapbox/actions/compose'; import { addComposeLanguage, changeComposeLanguage, changeComposeModifiedLanguage, deleteComposeLanguage } from 'soapbox/actions/compose';
import { Button, Icon, Input, Portal } from 'soapbox/components/ui'; import { Button, Icon, Input, Portal } from 'soapbox/components/ui';
import { type Language, languages as languagesObject } from 'soapbox/features/preferences'; import { type Language, languages as languagesObject } from 'soapbox/features/preferences';
import { useAppDispatch, useAppSelector, useCompose, useFeatures } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector, useCompose, useFeatures } from 'soapbox/hooks';
@ -66,7 +66,12 @@ const LanguageDropdown: React.FC<ILanguageDropdown> = ({ composeId }) => {
], ],
}); });
const { language, suggested_language: suggestedLanguage, textMap } = useCompose(composeId); const {
language,
modified_language: modifiedLanguage,
suggested_language: suggestedLanguage,
textMap,
} = useCompose(composeId);
const handleClick: React.EventHandler< const handleClick: React.EventHandler<
React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement> React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>
@ -87,8 +92,6 @@ const LanguageDropdown: React.FC<ILanguageDropdown> = ({ composeId }) => {
} }
}; };
const handleChange = (language: Language | null) => dispatch(changeComposeLanguage(composeId, language));
const handleOptionKeyDown: React.KeyboardEventHandler = e => { const handleOptionKeyDown: React.KeyboardEventHandler = e => {
const value = e.currentTarget.getAttribute('data-index'); const value = e.currentTarget.getAttribute('data-index');
const index = results.findIndex(([key]) => key === value); const index = results.findIndex(([key]) => key === value);
@ -125,10 +128,17 @@ const LanguageDropdown: React.FC<ILanguageDropdown> = ({ composeId }) => {
const handleOptionClick: React.EventHandler<any> = (e: MouseEvent | KeyboardEvent) => { const handleOptionClick: React.EventHandler<any> = (e: MouseEvent | KeyboardEvent) => {
const value = (e.currentTarget as HTMLElement)?.getAttribute('data-index') as Language; const value = (e.currentTarget as HTMLElement)?.getAttribute('data-index') as Language;
if (textMap.size) {
if (!(textMap.has(value) || language === value)) return;
dispatch(changeComposeModifiedLanguage(composeId, value));
} else {
dispatch(changeComposeLanguage(composeId, value));
}
e.preventDefault(); e.preventDefault();
setIsOpen(false); setIsOpen(false);
handleChange(value);
}; };
const handleAddLanguageClick: React.EventHandler<any> = (e: MouseEvent | KeyboardEvent) => { const handleAddLanguageClick: React.EventHandler<any> = (e: MouseEvent | KeyboardEvent) => {
@ -288,7 +298,7 @@ const LanguageDropdown: React.FC<ILanguageDropdown> = ({ composeId }) => {
let buttonLabel = intl.formatMessage(messages.languagePrompt); let buttonLabel = intl.formatMessage(messages.languagePrompt);
if (language) { if (language) {
const list: string[] = [languagesObject[language]]; const list: string[] = [languagesObject[modifiedLanguage || language]];
if (textMap.size) list.push(intl.formatMessage(messages.multipleLanguages, { if (textMap.size) list.push(intl.formatMessage(messages.multipleLanguages, {
count: textMap.size, count: textMap.size,
})); }));
@ -347,6 +357,7 @@ const LanguageDropdown: React.FC<ILanguageDropdown> = ({ composeId }) => {
<div className='h-96 w-full overflow-scroll' ref={node} tabIndex={-1}> <div className='h-96 w-full overflow-scroll' ref={node} tabIndex={-1}>
{results.map(([code, name]) => { {results.map(([code, name]) => {
const active = code === language; const active = code === language;
const modified = code === modifiedLanguage;
return ( return (
<div <div
@ -357,15 +368,20 @@ const LanguageDropdown: React.FC<ILanguageDropdown> = ({ composeId }) => {
onKeyDown={handleOptionKeyDown} onKeyDown={handleOptionKeyDown}
onClick={handleOptionClick} onClick={handleOptionClick}
className={clsx( className={clsx(
'flex cursor-pointer gap-2 p-2.5 text-sm text-gray-700 hover:bg-gray-100 black:hover:bg-gray-900 dark:text-gray-400 dark:hover:bg-gray-800', 'flex gap-2 p-2.5 text-sm text-gray-700 dark:text-gray-400',
{ 'bg-gray-100 dark:bg-gray-800 black:bg-gray-900 hover:bg-gray-200 dark:hover:bg-gray-700': active }, {
'bg-gray-100 dark:bg-gray-800 black:bg-gray-900 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700': modified,
'cursor-pointer hover:bg-gray-100 black:hover:bg-gray-900 dark:hover:bg-gray-800': !textMap.size || textMap.has(code),
'cursor-pointer': active,
'cursor-default': !active && !(!textMap.size || textMap.has(code)),
},
)} )}
aria-selected={active} aria-selected={active}
ref={active ? focusedItem : null} ref={active ? focusedItem : null}
> >
<div <div
className={clsx('flex-auto grow text-primary-600 dark:text-primary-400', { className={clsx('flex-auto grow text-primary-600 dark:text-primary-400', {
'text-black dark:text-white': active, 'text-black dark:text-white': modified,
})} })}
> >
{name} {name}

View file

@ -48,7 +48,7 @@ const Option: React.FC<IOption> = ({
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const intl = useIntl(); const intl = useIntl();
const suggestions = useCompose(composeId).suggestions; const { suggestions } = useCompose(composeId);
const handleOptionTitleChange = (event: React.ChangeEvent<HTMLInputElement>) => onChange(index, event.target.value); const handleOptionTitleChange = (event: React.ChangeEvent<HTMLInputElement>) => onChange(index, event.target.value);
@ -112,11 +112,11 @@ const PollForm: React.FC<IPollForm> = ({ composeId }) => {
const intl = useIntl(); const intl = useIntl();
const { configuration } = useInstance(); const { configuration } = useInstance();
const compose = useCompose(composeId); const { poll, language, modified_language: modifiedLanguage } = useCompose(composeId);
const options = compose.poll?.options; const options = !modifiedLanguage || modifiedLanguage === language ? poll?.options : poll?.options_map.map((option, key) => option.get(modifiedLanguage, poll.options.get(key)!));
const expiresIn = compose.poll?.expires_in; const expiresIn = poll?.expires_in;
const isMultiple = compose.poll?.multiple; const isMultiple = poll?.multiple;
const { const {
max_options: maxOptions, max_options: maxOptions,

View file

@ -26,7 +26,7 @@ const SpoilerInput = React.forwardRef<AutosuggestInput, ISpoilerInput>(({
}, ref) => { }, ref) => {
const intl = useIntl(); const intl = useIntl();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const compose = useCompose(composeId); const { language, modified_language, spoiler, spoiler_text: spoilerText, spoilerTextMap, suggestions } = useCompose(composeId);
const handleChangeSpoilerText: React.ChangeEventHandler<HTMLInputElement> = (e) => { const handleChangeSpoilerText: React.ChangeEventHandler<HTMLInputElement> = (e) => {
dispatch(changeComposeSpoilerText(composeId, e.target.value)); dispatch(changeComposeSpoilerText(composeId, e.target.value));
@ -36,12 +36,14 @@ const SpoilerInput = React.forwardRef<AutosuggestInput, ISpoilerInput>(({
dispatch(changeComposeSpoilerness(composeId)); dispatch(changeComposeSpoilerness(composeId));
}; };
const value = !modified_language || modified_language === language ? spoilerText : spoilerTextMap.get(modified_language, '');
return ( return (
<Stack <Stack
space={4} space={4}
className={clsx({ className={clsx({
'relative transition-height': true, 'relative transition-height': true,
'hidden': !compose.spoiler, 'hidden': !spoiler,
})} })}
> >
<Divider /> <Divider />
@ -53,10 +55,10 @@ const SpoilerInput = React.forwardRef<AutosuggestInput, ISpoilerInput>(({
<AutosuggestInput <AutosuggestInput
placeholder={intl.formatMessage(messages.placeholder)} placeholder={intl.formatMessage(messages.placeholder)}
value={compose.spoiler_text} value={value}
onChange={handleChangeSpoilerText} onChange={handleChangeSpoilerText}
disabled={!compose.spoiler} disabled={!spoiler}
suggestions={compose.suggestions} suggestions={suggestions}
onSuggestionsFetchRequested={onSuggestionsFetchRequested} onSuggestionsFetchRequested={onSuggestionsFetchRequested}
onSuggestionsClearRequested={onSuggestionsClearRequested} onSuggestionsClearRequested={onSuggestionsClearRequested}
onSuggestionSelected={onSuggestionSelected} onSuggestionSelected={onSuggestionSelected}

View file

@ -90,7 +90,8 @@ const ComposeEditor = React.forwardRef<LexicalEditor, IComposeEditor>(({
placeholder, placeholder,
}, ref) => { }, ref) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const isWysiwyg = useCompose(composeId).content_type === 'wysiwyg'; const { content_type: contentType } = useCompose(composeId);
const isWysiwyg = contentType === 'wysiwyg';
const nodes = useNodes(isWysiwyg); const nodes = useNodes(isWysiwyg);
const [suggestionsHidden, setSuggestionsHidden] = useState(true); const [suggestionsHidden, setSuggestionsHidden] = useState(true);
@ -106,20 +107,28 @@ const ComposeEditor = React.forwardRef<LexicalEditor, IComposeEditor>(({
if (!compose) return; if (!compose) return;
if (compose.editorState) { const editorState = !compose.modified_language || compose.modified_language === compose.language
return compose.editorState; ? compose.editorState
: compose.editorStateMap.get(compose.modified_language, '');
if (editorState) {
return editorState;
} }
return () => { return () => {
const text = !compose.modified_language || compose.modified_language === compose.language
? compose.text
: compose.textMap.get(compose.modified_language, '');
if (isWysiwyg) { if (isWysiwyg) {
$createRemarkImport({ $createRemarkImport({
handlers: { handlers: {
image: importImage, image: importImage,
}, },
})(compose.text); })(text);
} else { } else {
const paragraph = $createParagraphNode(); const paragraph = $createParagraphNode();
const textNode = $createTextNode(compose.text); const textNode = $createTextNode(text);
paragraph.append(textNode); paragraph.append(textNode);

View file

@ -75,7 +75,7 @@ const DeleteAccount = () => {
</Form> </Form>
</Stack> </Stack>
</CardBody> </CardBody>
</Card > </Card>
); );
}; };

View file

@ -4,6 +4,7 @@ import { Link } from 'react-router-dom';
import Account from 'soapbox/components/account'; import Account from 'soapbox/components/account';
import StatusContent from 'soapbox/components/status-content'; import StatusContent from 'soapbox/components/status-content';
import StatusLanguagePicker from 'soapbox/components/status-language-picker';
import StatusMedia from 'soapbox/components/status-media'; import StatusMedia from 'soapbox/components/status-media';
import StatusReplyMentions from 'soapbox/components/status-reply-mentions'; import StatusReplyMentions from 'soapbox/components/status-reply-mentions';
import SensitiveContentOverlay from 'soapbox/components/statuses/sensitive-content-overlay'; import SensitiveContentOverlay from 'soapbox/components/statuses/sensitive-content-overlay';
@ -181,7 +182,10 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
</> </>
)} )}
</span> </span>
<StatusLanguagePicker status={status} showLabel />
</HStack> </HStack>
</HStack> </HStack>
</div> </div>
</div> </div>

View file

@ -79,6 +79,7 @@ const StatusRecord = ImmutableRecord({
replies_count: 0, replies_count: 0,
sensitive: false, sensitive: false,
spoiler_text: '', spoiler_text: '',
spoiler_text_map: null as ImmutableMap<string, string> | null,
tags: ImmutableList<ImmutableMap<string, any>>(), tags: ImmutableList<ImmutableMap<string, any>>(),
tombstone: null as Tombstone | null, tombstone: null as Tombstone | null,
uri: '', uri: '',
@ -88,12 +89,15 @@ const StatusRecord = ImmutableRecord({
// Internal fields // Internal fields
contentHtml: '', contentHtml: '',
spoilerHtml: '',
contentMapHtml: null as ImmutableMap<string, string> | null,
spoilerMapHtml: null as ImmutableMap<string, string> | null,
expectsCard: false, expectsCard: false,
hidden: null as boolean | null, hidden: null as boolean | null,
search_index: '', search_index: '',
showFiltered: true, showFiltered: true,
spoilerHtml: '',
translation: null as ImmutableMap<string, string> | null, translation: null as ImmutableMap<string, string> | null,
currentLanguage: null as string | null,
}); });
const normalizeAttachments = (status: ImmutableMap<string, any>) => const normalizeAttachments = (status: ImmutableMap<string, any>) =>

View file

@ -34,6 +34,10 @@ import {
COMPOSE_SPOILER_TEXT_CHANGE, COMPOSE_SPOILER_TEXT_CHANGE,
COMPOSE_VISIBILITY_CHANGE, COMPOSE_VISIBILITY_CHANGE,
COMPOSE_LANGUAGE_CHANGE, COMPOSE_LANGUAGE_CHANGE,
COMPOSE_MODIFIED_LANGUAGE_CHANGE,
COMPOSE_LANGUAGE_ADD,
COMPOSE_LANGUAGE_DELETE,
COMPOSE_ADD_SUGGESTED_LANGUAGE,
COMPOSE_EMOJI_INSERT, COMPOSE_EMOJI_INSERT,
COMPOSE_UPLOAD_CHANGE_REQUEST, COMPOSE_UPLOAD_CHANGE_REQUEST,
COMPOSE_UPLOAD_CHANGE_SUCCESS, COMPOSE_UPLOAD_CHANGE_SUCCESS,
@ -56,9 +60,6 @@ import {
COMPOSE_CHANGE_MEDIA_ORDER, COMPOSE_CHANGE_MEDIA_ORDER,
COMPOSE_ADD_SUGGESTED_QUOTE, COMPOSE_ADD_SUGGESTED_QUOTE,
ComposeAction, ComposeAction,
COMPOSE_ADD_SUGGESTED_LANGUAGE,
COMPOSE_LANGUAGE_ADD,
COMPOSE_LANGUAGE_DELETE,
} from '../actions/compose'; } from '../actions/compose';
import { EVENT_COMPOSE_CANCEL, EVENT_FORM_SET, type EventsAction } from '../actions/events'; import { EVENT_COMPOSE_CANCEL, EVENT_FORM_SET, type EventsAction } from '../actions/events';
import { ME_FETCH_SUCCESS, ME_PATCH_SUCCESS, MeAction } from '../actions/me'; import { ME_FETCH_SUCCESS, ME_PATCH_SUCCESS, MeAction } from '../actions/me';
@ -81,6 +82,7 @@ const getResetFileKey = () => Math.floor((Math.random() * 0x10000));
const PollRecord = ImmutableRecord({ const PollRecord = ImmutableRecord({
options: ImmutableList(['', '']), options: ImmutableList(['', '']),
options_map: ImmutableList<ImmutableMap<Language, string>>([ImmutableMap(), ImmutableMap()]),
expires_in: 24 * 3600, expires_in: 24 * 3600,
multiple: false, multiple: false,
}); });
@ -90,6 +92,7 @@ const ReducerCompose = ImmutableRecord({
content_type: 'text/plain', content_type: 'text/plain',
draft_id: null as string | null, draft_id: null as string | null,
editorState: null as string | null, editorState: null as string | null,
editorStateMap: ImmutableMap<Language, string | null>(),
focusDate: null as Date | null, focusDate: null as Date | null,
group_id: null as string | null, group_id: null as string | null,
idempotencyKey: '', idempotencyKey: '',
@ -109,6 +112,7 @@ const ReducerCompose = ImmutableRecord({
sensitive: false, sensitive: false,
spoiler: false, spoiler: false,
spoiler_text: '', spoiler_text: '',
spoilerTextMap: ImmutableMap<Language, string>(),
suggestions: ImmutableList<string>(), suggestions: ImmutableList<string>(),
suggestion_token: null as string | null, suggestion_token: null as string | null,
tagHistory: ImmutableList<string>(), tagHistory: ImmutableList<string>(),
@ -118,6 +122,7 @@ const ReducerCompose = ImmutableRecord({
parent_reblogged_by: null as string | null, parent_reblogged_by: null as string | null,
dismissed_quotes: ImmutableOrderedSet<string>(), dismissed_quotes: ImmutableOrderedSet<string>(),
language: null as Language | null, language: null as Language | null,
modified_language: null as Language | null,
suggested_language: null as string | null, suggested_language: null as string | null,
}); });
@ -302,9 +307,11 @@ const compose = (state = initialState, action: ComposeAction | EventsAction | Me
map.set('idempotencyKey', uuid()); map.set('idempotencyKey', uuid());
})); }));
case COMPOSE_SPOILER_TEXT_CHANGE: case COMPOSE_SPOILER_TEXT_CHANGE:
return updateCompose(state, action.id, compose => compose return updateCompose(state, action.id, compose => {
.set('spoiler_text', action.text) return compose
.set('idempotencyKey', uuid())); .setIn(compose.modified_language === compose.language ? ['spoiler_text'] : ['spoilerTextMap', compose.modified_language], action.text)
.set('idempotencyKey', uuid());
});
case COMPOSE_VISIBILITY_CHANGE: case COMPOSE_VISIBILITY_CHANGE:
return updateCompose(state, action.id, compose => compose return updateCompose(state, action.id, compose => compose
.set('privacy', action.value) .set('privacy', action.value)
@ -312,6 +319,12 @@ const compose = (state = initialState, action: ComposeAction | EventsAction | Me
case COMPOSE_LANGUAGE_CHANGE: case COMPOSE_LANGUAGE_CHANGE:
return updateCompose(state, action.id, compose => compose.withMutations(map => { return updateCompose(state, action.id, compose => compose.withMutations(map => {
map.set('language', action.value); map.set('language', action.value);
map.set('modified_language', action.value);
map.set('idempotencyKey', uuid());
}));
case COMPOSE_MODIFIED_LANGUAGE_CHANGE:
return updateCompose(state, action.id, compose => compose.withMutations(map => {
map.set('modified_language', action.value);
map.set('idempotencyKey', uuid()); map.set('idempotencyKey', uuid());
})); }));
case COMPOSE_CHANGE: case COMPOSE_CHANGE:
@ -513,11 +526,21 @@ const compose = (state = initialState, action: ComposeAction | EventsAction | Me
case COMPOSE_SCHEDULE_REMOVE: case COMPOSE_SCHEDULE_REMOVE:
return updateCompose(state, action.id, compose => compose.set('schedule', null)); return updateCompose(state, action.id, compose => compose.set('schedule', null));
case COMPOSE_POLL_OPTION_ADD: case COMPOSE_POLL_OPTION_ADD:
return updateCompose(state, action.id, compose => compose.updateIn(['poll', 'options'], options => (options as ImmutableList<string>).push(action.title))); return updateCompose(state, action.id, compose =>
compose
.updateIn(['poll', 'options'], options => (options as ImmutableList<string>).push(action.title))
.updateIn(['poll', 'options_map'], options_map => (options_map as ImmutableList<ImmutableMap<Language, string>>).push(ImmutableMap(compose.textMap.map(_ => action.title)))),
);
case COMPOSE_POLL_OPTION_CHANGE: case COMPOSE_POLL_OPTION_CHANGE:
return updateCompose(state, action.id, compose => compose.setIn(['poll', 'options', action.index], action.title)); return updateCompose(state, action.id, compose =>
compose.setIn(!compose.modified_language || compose.modified_language === compose.language ? ['poll', 'options', action.index] : ['poll', 'options_map', action.index, compose.modified_language], action.title),
);
case COMPOSE_POLL_OPTION_REMOVE: case COMPOSE_POLL_OPTION_REMOVE:
return updateCompose(state, action.id, compose => compose.updateIn(['poll', 'options'], options => (options as ImmutableList<string>).delete(action.index))); return updateCompose(state, action.id, compose =>
compose
.updateIn(['poll', 'options'], options => (options as ImmutableList<string>).delete(action.index))
.updateIn(['poll', 'options_map'], options_map => (options_map as ImmutableList<ImmutableMap<Language, string>>).delete(action.index)),
);
case COMPOSE_POLL_SETTINGS_CHANGE: case COMPOSE_POLL_SETTINGS_CHANGE:
return updateCompose(state, action.id, compose => compose.update('poll', poll => { return updateCompose(state, action.id, compose => compose.update('poll', poll => {
if (!poll) return null; if (!poll) return null;
@ -541,8 +564,8 @@ const compose = (state = initialState, action: ComposeAction | EventsAction | Me
return updateCompose(state, 'default', compose => updateSetting(compose, action.path, action.value)); return updateCompose(state, 'default', compose => updateSetting(compose, action.path, action.value));
case COMPOSE_EDITOR_STATE_SET: case COMPOSE_EDITOR_STATE_SET:
return updateCompose(state, action.id, compose => compose return updateCompose(state, action.id, compose => compose
.set('editorState', action.editorState as string) .setIn(!compose.modified_language || compose.modified_language === compose.language ? ['editorState'] : ['editorStateMap', compose.modified_language], action.editorState as string)
.set('text', action.text as string)); .setIn(!compose.modified_language || compose.modified_language === compose.language ? ['text'] : ['textMap', compose.modified_language], action.text as string));
case EVENT_COMPOSE_CANCEL: case EVENT_COMPOSE_CANCEL:
return updateCompose(state, 'event-compose-modal', compose => compose.set('text', '')); return updateCompose(state, 'event-compose-modal', compose => compose.set('text', ''));
case EVENT_FORM_SET: case EVENT_FORM_SET:
@ -560,9 +583,21 @@ const compose = (state = initialState, action: ComposeAction | EventsAction | Me
case COMPOSE_ADD_SUGGESTED_LANGUAGE: case COMPOSE_ADD_SUGGESTED_LANGUAGE:
return updateCompose(state, action.id, compose => compose.set('suggested_language', action.language)); return updateCompose(state, action.id, compose => compose.set('suggested_language', action.language));
case COMPOSE_LANGUAGE_ADD: case COMPOSE_LANGUAGE_ADD:
return updateCompose(state, action.id, compose => compose.setIn(['textMap', action.value], '')); return updateCompose(state, action.id, compose =>
compose
.setIn(['editorStateMap', action.value], compose.editorState)
.setIn(['textMap', action.value], compose.text)
.setIn(['spoilerTextMap', action.value], compose.spoiler_text)
.update('poll', poll => {
if (!poll) return poll;
return poll.update('options_map', optionsMap => optionsMap.map((option, key) => option.set(action.value, poll.options.get(key)!)));
}),
);
case COMPOSE_LANGUAGE_DELETE: case COMPOSE_LANGUAGE_DELETE:
return updateCompose(state, action.id, compose => compose.removeIn(['textMap', action.value])); return updateCompose(state, action.id, compose => compose
.removeIn(['editorStateMap', action.value])
.removeIn(['textMap', action.value])
.removeIn(['spoilerTextMap', action.value]));
case COMPOSE_QUOTE_CANCEL: case COMPOSE_QUOTE_CANCEL:
return updateCompose(state, action.id, compose => compose return updateCompose(state, action.id, compose => compose
.update('dismissed_quotes', quotes => compose.quote ? quotes.add(compose.quote) : quotes) .update('dismissed_quotes', quotes => compose.quote ? quotes.add(compose.quote) : quotes)

View file

@ -43,6 +43,7 @@ import {
STATUS_TRANSLATE_SUCCESS, STATUS_TRANSLATE_SUCCESS,
STATUS_TRANSLATE_UNDO, STATUS_TRANSLATE_UNDO,
STATUS_UNFILTER, STATUS_UNFILTER,
STATUS_LANGUAGE_CHANGE,
} from '../actions/statuses'; } from '../actions/statuses';
import { TIMELINE_DELETE } from '../actions/timelines'; import { TIMELINE_DELETE } from '../actions/timelines';
@ -95,6 +96,9 @@ const buildSearchContent = (status: StatusRecord): string => {
return unescapeHTML(fields.join('\n\n')) || ''; return unescapeHTML(fields.join('\n\n')) || '';
}; };
const calculateContent = (text: string, emojiMap: any) => DOMPurify.sanitize(stripCompatibilityFeatures(emojify(text, emojiMap)), { USE_PROFILES: { html: true } });
const calculateSpoiler = (text: string, emojiMap: any) => DOMPurify.sanitize(emojify(escapeTextContentForBrowser(text), emojiMap), { USE_PROFILES: { html: true } });
// Only calculate these values when status first encountered // Only calculate these values when status first encountered
// Otherwise keep the ones already in the reducer // Otherwise keep the ones already in the reducer
const calculateStatus = ( const calculateStatus = (
@ -102,21 +106,23 @@ const calculateStatus = (
oldStatus?: StatusRecord, oldStatus?: StatusRecord,
): StatusRecord => { ): StatusRecord => {
if (oldStatus && oldStatus.content === status.content && oldStatus.spoiler_text === status.spoiler_text) { if (oldStatus && oldStatus.content === status.content && oldStatus.spoiler_text === status.spoiler_text) {
const {
search_index, contentHtml, spoilerHtml, contentMapHtml, spoilerMapHtml, hidden, translation, currentLanguage,
} = oldStatus;
return status.merge({ return status.merge({
search_index: oldStatus.search_index, search_index, contentHtml, spoilerHtml, contentMapHtml, spoilerMapHtml, hidden, translation, currentLanguage,
contentHtml: oldStatus.contentHtml,
spoilerHtml: oldStatus.spoilerHtml,
hidden: oldStatus.hidden,
}); });
} else { } else {
const spoilerText = status.spoiler_text;
const searchContent = buildSearchContent(status); const searchContent = buildSearchContent(status);
const emojiMap = makeEmojiMap(status.emojis); const emojiMap = makeEmojiMap(status.emojis);
return status.merge({ return status.merge({
search_index: domParser.parseFromString(searchContent, 'text/html').documentElement.textContent || '', search_index: domParser.parseFromString(searchContent, 'text/html').documentElement.textContent || '',
contentHtml: DOMPurify.sanitize(stripCompatibilityFeatures(emojify(status.content, emojiMap)), { USE_PROFILES: { html: true } }), contentHtml: calculateContent(status.content, emojiMap),
spoilerHtml: DOMPurify.sanitize(emojify(escapeTextContentForBrowser(spoilerText), emojiMap), { USE_PROFILES: { html: true } }), spoilerHtml: calculateSpoiler(status.spoiler_text, emojiMap),
contentMapHtml: status.content_map?.map(value => calculateContent(value, emojiMap)),
spoilerMapHtml: status.spoiler_text_map?.map(value => calculateSpoiler(value, emojiMap)),
}); });
} }
}; };
@ -318,6 +324,8 @@ const statuses = (state = initialState, action: AnyAction): State => {
return deleteTranslation(state, action.id); return deleteTranslation(state, action.id);
case STATUS_UNFILTER: case STATUS_UNFILTER:
return state.setIn([action.id, 'showFiltered'], false); return state.setIn([action.id, 'showFiltered'], false);
case STATUS_LANGUAGE_CHANGE:
return state.setIn([action.id, 'currentLanguage'], action.language);
case TIMELINE_DELETE: case TIMELINE_DELETE:
return deleteStatus(state, action.id, action.references); return deleteStatus(state, action.id, action.references);
case EVENT_JOIN_REQUEST: case EVENT_JOIN_REQUEST:

View file

@ -7,8 +7,11 @@ import emojify from 'soapbox/features/emoji';
import { customEmojiSchema } from './custom-emoji'; import { customEmojiSchema } from './custom-emoji';
import { filteredArray, makeCustomEmojiMap } from './utils'; import { filteredArray, makeCustomEmojiMap } from './utils';
const sanitizeTitle = (text: string, emojiMap: any) => DOMPurify.sanitize(emojify(escapeTextContentForBrowser(text), emojiMap), { ALLOWED_TAGS: [] });
const pollOptionSchema = z.object({ const pollOptionSchema = z.object({
title: z.string().catch(''), title: z.string().catch(''),
title_map: z.record(z.string(), z.string()).nullable().catch(null),
votes_count: z.number().catch(0), votes_count: z.number().catch(0),
}); });
@ -31,7 +34,10 @@ const pollSchema = z.object({
const emojifiedOptions = poll.options.map((option) => ({ const emojifiedOptions = poll.options.map((option) => ({
...option, ...option,
title_emojified: DOMPurify.sanitize(emojify(escapeTextContentForBrowser(option.title), emojiMap), { ALLOWED_TAGS: [] }), title_emojified: sanitizeTitle(option.title, emojiMap),
title_map_emojified: option.title_map
? Object.fromEntries(Object.entries(option.title_map).map(([key, title]) => [key, sanitizeTitle(title, emojiMap)]))
: null,
})); }));
// If the user has votes, they have certainly voted. // If the user has votes, they have certainly voted.

View file

@ -7,7 +7,7 @@ import {
} from 'immutable'; } from 'immutable';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { getSettings } from 'soapbox/actions/settings'; import { getLocale, getSettings } from 'soapbox/actions/settings';
import { Entities } from 'soapbox/entity-store/entities'; import { Entities } from 'soapbox/entity-store/entities';
import { type MRFSimple } from 'soapbox/schemas/pleroma'; import { type MRFSimple } from 'soapbox/schemas/pleroma';
import { getDomain } from 'soapbox/utils/accounts'; import { getDomain } from 'soapbox/utils/accounts';
@ -136,9 +136,10 @@ const makeGetStatus = () => createSelector(
getFilters, getFilters,
(state: RootState) => state.me, (state: RootState) => state.me,
(state: RootState) => getFeatures(state.instance), (state: RootState) => getFeatures(state.instance),
(state: RootState) => getLocale(state, 'en'),
], ],
(statusBase, statusReblog, username, filters, me, features) => { (statusBase, statusReblog, username, filters, me, features, locale) => {
if (!statusBase) return null; if (!statusBase) return null;
const { account } = statusBase; const { account } = statusBase;
const accountUsername = account.acct; const accountUsername = account.acct;
@ -151,6 +152,14 @@ const makeGetStatus = () => createSelector(
return statusBase.withMutations((map: Status) => { return statusBase.withMutations((map: Status) => {
map.set('reblog', statusReblog || null); map.set('reblog', statusReblog || null);
if (map.currentLanguage === null && map.content_map?.size) {
let currentLanguage: string | null = null;
if (map.content_map.has(locale)) currentLanguage = locale;
else if (map.language && map.content_map.has(map.language)) currentLanguage = map.language;
else currentLanguage = map.content_map.keySeq().first();
map.set('currentLanguage', currentLanguage);
}
if ((features.filters) && account.id !== me) { if ((features.filters) && account.id !== me) {
const filtered = checkFiltered(statusReblog?.search_index || statusBase.search_index, filters); const filtered = checkFiltered(statusReblog?.search_index || statusBase.search_index, filters);

View file

@ -626,7 +626,7 @@ const getInstanceFeatures = (instance: Instance) => {
* Ability to include multiple language variants for a post. * Ability to include multiple language variants for a post.
* @see POST /api/v1/statuses * @see POST /api/v1/statuses
*/ */
multiLanguage: false, // features.includes('pleroma:multi_language'), multiLanguage: features.includes('pleroma:multi_language'),
/** /**
* Ability to hide notifications from people you don't follow. * Ability to hide notifications from people you don't follow.