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

@ -62,6 +62,7 @@ const COMPOSE_TYPE_CHANGE = 'COMPOSE_TYPE_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_LANGUAGE_CHANGE = 'COMPOSE_LANGUAGE_CHANGE' as const;
const COMPOSE_MODIFIED_LANGUAGE_CHANGE = 'COMPOSE_MODIFIED_LANGUAGE_CHANGE' 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;
@ -387,6 +388,22 @@ const submitCompose = (composeId: string, opts: SubmitComposeOpts = {}) =>
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') {
params.group_id = compose.group_id;
}
@ -743,13 +760,19 @@ const changeComposeLanguage = (composeId: string, value: Language | null) => ({
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,
id: composeId,
value,
});
const deleteComposeLanguage = (composeId: string, value: Language | null) => ({
const deleteComposeLanguage = (composeId: string, value: Language) => ({
type: COMPOSE_LANGUAGE_DELETE,
id: composeId,
value,
@ -945,6 +968,7 @@ type ComposeAction =
| ReturnType<typeof changeComposeSpoilerText>
| ReturnType<typeof changeComposeVisibility>
| ReturnType<typeof changeComposeLanguage>
| ReturnType<typeof changeComposeModifiedLanguage>
| ReturnType<typeof addComposeLanguage>
| ReturnType<typeof deleteComposeLanguage>
| ReturnType<typeof insertEmojiCompose>
@ -994,6 +1018,7 @@ export {
COMPOSE_SPOILER_TEXT_CHANGE,
COMPOSE_VISIBILITY_CHANGE,
COMPOSE_LANGUAGE_CHANGE,
COMPOSE_MODIFIED_LANGUAGE_CHANGE,
COMPOSE_LANGUAGE_ADD,
COMPOSE_LANGUAGE_DELETE,
COMPOSE_LISTABILITY_CHANGE,
@ -1057,6 +1082,7 @@ export {
changeComposeSpoilerText,
changeComposeVisibility,
changeComposeLanguage,
changeComposeModifiedLanguage,
addComposeLanguage,
deleteComposeLanguage,
insertEmojiCompose,

View file

@ -49,6 +49,8 @@ const STATUS_TRANSLATE_UNDO = 'STATUS_TRANSLATE_UNDO';
const STATUS_UNFILTER = 'STATUS_UNFILTER';
const STATUS_LANGUAGE_CHANGE = 'STATUS_LANGUAGE_CHANGE';
const statusExists = (getState: () => RootState, statusId: string) =>
(getState().statuses.get(statusId) || null) !== null;
@ -286,6 +288,12 @@ const unfilterStatus = (id: string) => ({
id,
});
const changeStatusLanguage = (id: string, language: string) => ({
type: STATUS_LANGUAGE_CHANGE,
id,
language,
});
export {
STATUS_CREATE_REQUEST,
STATUS_CREATE_SUCCESS,
@ -315,6 +323,7 @@ export {
STATUS_TRANSLATE_FAIL,
STATUS_TRANSLATE_UNDO,
STATUS_UNFILTER,
STATUS_LANGUAGE_CHANGE,
createStatus,
editStatus,
fetchStatus,
@ -331,4 +340,5 @@ export {
translateStatus,
undoStatusTranslation,
unfilterStatus,
changeStatusLanguage,
};

View file

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

View file

@ -23,7 +23,6 @@ interface IInstanceFavicon {
const messages = defineMessages({
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 }) => {
@ -91,11 +90,11 @@ interface IAccount {
withLinkToProfile?: boolean;
withRelationship?: boolean;
showEdit?: boolean;
showMultiLanguage?: boolean;
approvalStatus?: StatusApprovalStatus;
emoji?: string;
emojiUrl?: string;
note?: string;
items?: React.ReactNode;
}
const Account = ({
@ -118,11 +117,11 @@ const Account = ({
withLinkToProfile = true,
withRelationship = true,
showEdit = false,
showMultiLanguage = false,
approvalStatus,
emoji,
emojiUrl,
note,
items,
}: IAccount) => {
const overflowRef = useRef<HTMLDivElement>(null);
const actionRef = useRef<HTMLDivElement>(null);
@ -271,16 +270,6 @@ const Account = ({
</>
) : 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 ? (
<>
<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>
</>
) : null}
{items}
</HStack>
{note ? (

View file

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

View file

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

View file

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

View file

@ -77,8 +77,12 @@ const StatusContent: React.FC<IStatusContent> = React.memo(({
});
const parsedHtml = useMemo(
(): string => translatable && status.translation ? status.translation.get('content')! : status.contentHtml,
[status.contentHtml, status.translation],
(): string => translatable && 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) {
@ -160,7 +164,7 @@ const StatusContent: React.FC<IStatusContent> = React.memo(({
const hasPoll = status.poll && typeof status.poll === 'string';
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>;
@ -182,7 +186,7 @@ const StatusContent: React.FC<IStatusContent> = React.memo(({
];
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}</>;

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

View file

@ -47,6 +47,10 @@ const SensitiveContentOverlay = React.forwardRef<HTMLDivElement, ISensitiveConte
if (visible && !showHideButton) return null;
const spoilerText = status.currentLanguage
? status.spoilerMapHtml!.get(status.currentLanguage, status.spoilerHtml)
: status.spoilerHtml;
return (
<div
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='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'>
<Text theme='white' weight='semibold'>
{intl.formatMessage(messages.sensitiveTitle)}
@ -79,7 +83,7 @@ const SensitiveContentOverlay = React.forwardRef<HTMLDivElement, ISensitiveConte
{status.spoiler_text && (
<div className='py-4 italic'>
<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>
</div>
)}

View file

@ -27,7 +27,7 @@ const TranslateButton: React.FC<ITranslateButton> = ({ status }) => {
target_languages: targetLanguages,
} = 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));

View file

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

View file

@ -7,7 +7,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
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 { type Language, languages as languagesObject } from 'soapbox/features/preferences';
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<
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 value = e.currentTarget.getAttribute('data-index');
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 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();
setIsOpen(false);
handleChange(value);
};
const handleAddLanguageClick: React.EventHandler<any> = (e: MouseEvent | KeyboardEvent) => {
@ -288,7 +298,7 @@ const LanguageDropdown: React.FC<ILanguageDropdown> = ({ composeId }) => {
let buttonLabel = intl.formatMessage(messages.languagePrompt);
if (language) {
const list: string[] = [languagesObject[language]];
const list: string[] = [languagesObject[modifiedLanguage || language]];
if (textMap.size) list.push(intl.formatMessage(messages.multipleLanguages, {
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}>
{results.map(([code, name]) => {
const active = code === language;
const modified = code === modifiedLanguage;
return (
<div
@ -357,15 +368,20 @@ const LanguageDropdown: React.FC<ILanguageDropdown> = ({ composeId }) => {
onKeyDown={handleOptionKeyDown}
onClick={handleOptionClick}
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',
{ 'bg-gray-100 dark:bg-gray-800 black:bg-gray-900 hover:bg-gray-200 dark:hover:bg-gray-700': active },
'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 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}
ref={active ? focusedItem : null}
>
<div
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}

View file

@ -48,7 +48,7 @@ const Option: React.FC<IOption> = ({
const dispatch = useAppDispatch();
const intl = useIntl();
const suggestions = useCompose(composeId).suggestions;
const { suggestions } = useCompose(composeId);
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 { configuration } = useInstance();
const compose = useCompose(composeId);
const { poll, language, modified_language: modifiedLanguage } = useCompose(composeId);
const options = compose.poll?.options;
const expiresIn = compose.poll?.expires_in;
const isMultiple = compose.poll?.multiple;
const options = !modifiedLanguage || modifiedLanguage === language ? poll?.options : poll?.options_map.map((option, key) => option.get(modifiedLanguage, poll.options.get(key)!));
const expiresIn = poll?.expires_in;
const isMultiple = poll?.multiple;
const {
max_options: maxOptions,

View file

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

View file

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

View file

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

View file

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

View file

@ -34,6 +34,10 @@ import {
COMPOSE_SPOILER_TEXT_CHANGE,
COMPOSE_VISIBILITY_CHANGE,
COMPOSE_LANGUAGE_CHANGE,
COMPOSE_MODIFIED_LANGUAGE_CHANGE,
COMPOSE_LANGUAGE_ADD,
COMPOSE_LANGUAGE_DELETE,
COMPOSE_ADD_SUGGESTED_LANGUAGE,
COMPOSE_EMOJI_INSERT,
COMPOSE_UPLOAD_CHANGE_REQUEST,
COMPOSE_UPLOAD_CHANGE_SUCCESS,
@ -56,9 +60,6 @@ import {
COMPOSE_CHANGE_MEDIA_ORDER,
COMPOSE_ADD_SUGGESTED_QUOTE,
ComposeAction,
COMPOSE_ADD_SUGGESTED_LANGUAGE,
COMPOSE_LANGUAGE_ADD,
COMPOSE_LANGUAGE_DELETE,
} from '../actions/compose';
import { EVENT_COMPOSE_CANCEL, EVENT_FORM_SET, type EventsAction } from '../actions/events';
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({
options: ImmutableList(['', '']),
options_map: ImmutableList<ImmutableMap<Language, string>>([ImmutableMap(), ImmutableMap()]),
expires_in: 24 * 3600,
multiple: false,
});
@ -90,6 +92,7 @@ const ReducerCompose = ImmutableRecord({
content_type: 'text/plain',
draft_id: null as string | null,
editorState: null as string | null,
editorStateMap: ImmutableMap<Language, string | null>(),
focusDate: null as Date | null,
group_id: null as string | null,
idempotencyKey: '',
@ -109,6 +112,7 @@ const ReducerCompose = ImmutableRecord({
sensitive: false,
spoiler: false,
spoiler_text: '',
spoilerTextMap: ImmutableMap<Language, string>(),
suggestions: ImmutableList<string>(),
suggestion_token: null as string | null,
tagHistory: ImmutableList<string>(),
@ -118,6 +122,7 @@ const ReducerCompose = ImmutableRecord({
parent_reblogged_by: null as string | null,
dismissed_quotes: ImmutableOrderedSet<string>(),
language: null as Language | null,
modified_language: null as Language | null,
suggested_language: null as string | null,
});
@ -302,9 +307,11 @@ const compose = (state = initialState, action: ComposeAction | EventsAction | Me
map.set('idempotencyKey', uuid());
}));
case COMPOSE_SPOILER_TEXT_CHANGE:
return updateCompose(state, action.id, compose => compose
.set('spoiler_text', action.text)
.set('idempotencyKey', uuid()));
return updateCompose(state, action.id, compose => {
return compose
.setIn(compose.modified_language === compose.language ? ['spoiler_text'] : ['spoilerTextMap', compose.modified_language], action.text)
.set('idempotencyKey', uuid());
});
case COMPOSE_VISIBILITY_CHANGE:
return updateCompose(state, action.id, compose => compose
.set('privacy', action.value)
@ -312,6 +319,12 @@ const compose = (state = initialState, action: ComposeAction | EventsAction | Me
case COMPOSE_LANGUAGE_CHANGE:
return updateCompose(state, action.id, compose => compose.withMutations(map => {
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());
}));
case COMPOSE_CHANGE:
@ -513,11 +526,21 @@ const compose = (state = initialState, action: ComposeAction | EventsAction | Me
case COMPOSE_SCHEDULE_REMOVE:
return updateCompose(state, action.id, compose => compose.set('schedule', null));
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:
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:
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:
return updateCompose(state, action.id, compose => compose.update('poll', poll => {
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));
case COMPOSE_EDITOR_STATE_SET:
return updateCompose(state, action.id, compose => compose
.set('editorState', action.editorState as string)
.set('text', action.text as string));
.setIn(!compose.modified_language || compose.modified_language === compose.language ? ['editorState'] : ['editorStateMap', compose.modified_language], action.editorState as string)
.setIn(!compose.modified_language || compose.modified_language === compose.language ? ['text'] : ['textMap', compose.modified_language], action.text as string));
case EVENT_COMPOSE_CANCEL:
return updateCompose(state, 'event-compose-modal', compose => compose.set('text', ''));
case EVENT_FORM_SET:
@ -560,9 +583,21 @@ const compose = (state = initialState, action: ComposeAction | EventsAction | Me
case COMPOSE_ADD_SUGGESTED_LANGUAGE:
return updateCompose(state, action.id, compose => compose.set('suggested_language', action.language));
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:
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:
return updateCompose(state, action.id, compose => compose
.update('dismissed_quotes', quotes => compose.quote ? quotes.add(compose.quote) : quotes)

View file

@ -43,6 +43,7 @@ import {
STATUS_TRANSLATE_SUCCESS,
STATUS_TRANSLATE_UNDO,
STATUS_UNFILTER,
STATUS_LANGUAGE_CHANGE,
} from '../actions/statuses';
import { TIMELINE_DELETE } from '../actions/timelines';
@ -95,6 +96,9 @@ const buildSearchContent = (status: StatusRecord): string => {
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
// Otherwise keep the ones already in the reducer
const calculateStatus = (
@ -102,21 +106,23 @@ const calculateStatus = (
oldStatus?: StatusRecord,
): StatusRecord => {
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({
search_index: oldStatus.search_index,
contentHtml: oldStatus.contentHtml,
spoilerHtml: oldStatus.spoilerHtml,
hidden: oldStatus.hidden,
search_index, contentHtml, spoilerHtml, contentMapHtml, spoilerMapHtml, hidden, translation, currentLanguage,
});
} else {
const spoilerText = status.spoiler_text;
const searchContent = buildSearchContent(status);
const emojiMap = makeEmojiMap(status.emojis);
return status.merge({
search_index: domParser.parseFromString(searchContent, 'text/html').documentElement.textContent || '',
contentHtml: DOMPurify.sanitize(stripCompatibilityFeatures(emojify(status.content, emojiMap)), { USE_PROFILES: { html: true } }),
spoilerHtml: DOMPurify.sanitize(emojify(escapeTextContentForBrowser(spoilerText), emojiMap), { USE_PROFILES: { html: true } }),
contentHtml: calculateContent(status.content, emojiMap),
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);
case STATUS_UNFILTER:
return state.setIn([action.id, 'showFiltered'], false);
case STATUS_LANGUAGE_CHANGE:
return state.setIn([action.id, 'currentLanguage'], action.language);
case TIMELINE_DELETE:
return deleteStatus(state, action.id, action.references);
case EVENT_JOIN_REQUEST:

View file

@ -7,8 +7,11 @@ import emojify from 'soapbox/features/emoji';
import { customEmojiSchema } from './custom-emoji';
import { filteredArray, makeCustomEmojiMap } from './utils';
const sanitizeTitle = (text: string, emojiMap: any) => DOMPurify.sanitize(emojify(escapeTextContentForBrowser(text), emojiMap), { ALLOWED_TAGS: [] });
const pollOptionSchema = z.object({
title: z.string().catch(''),
title_map: z.record(z.string(), z.string()).nullable().catch(null),
votes_count: z.number().catch(0),
});
@ -31,7 +34,10 @@ const pollSchema = z.object({
const emojifiedOptions = poll.options.map((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.

View file

@ -7,7 +7,7 @@ import {
} from 'immutable';
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 { type MRFSimple } from 'soapbox/schemas/pleroma';
import { getDomain } from 'soapbox/utils/accounts';
@ -136,9 +136,10 @@ const makeGetStatus = () => createSelector(
getFilters,
(state: RootState) => state.me,
(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;
const { account } = statusBase;
const accountUsername = account.acct;
@ -151,6 +152,14 @@ const makeGetStatus = () => createSelector(
return statusBase.withMutations((map: Status) => {
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) {
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.
* @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.