WIP multilang posting

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak
2024-05-23 17:50:12 +02:00
parent 8536bf6f67
commit 8cff66736d
7 changed files with 105 additions and 11 deletions

View File

@ -62,6 +62,8 @@ 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_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_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT' as const;
@ -741,6 +743,18 @@ const changeComposeLanguage = (composeId: string, value: Language | null) => ({
value,
});
const addComposeLanguage = (composeId: string, value: Language | null) => ({
type: COMPOSE_LANGUAGE_ADD,
id: composeId,
value,
});
const deleteComposeLanguage = (composeId: string, value: Language | null) => ({
type: COMPOSE_LANGUAGE_DELETE,
id: composeId,
value,
});
const insertEmojiCompose = (composeId: string, position: number, emoji: Emoji, needsSpace: boolean) => ({
type: COMPOSE_EMOJI_INSERT,
id: composeId,
@ -931,6 +945,8 @@ type ComposeAction =
| ReturnType<typeof changeComposeSpoilerText>
| ReturnType<typeof changeComposeVisibility>
| ReturnType<typeof changeComposeLanguage>
| ReturnType<typeof addComposeLanguage>
| ReturnType<typeof deleteComposeLanguage>
| ReturnType<typeof insertEmojiCompose>
| ReturnType<typeof addPoll>
| ReturnType<typeof removePoll>
@ -978,6 +994,8 @@ export {
COMPOSE_SPOILER_TEXT_CHANGE,
COMPOSE_VISIBILITY_CHANGE,
COMPOSE_LANGUAGE_CHANGE,
COMPOSE_LANGUAGE_ADD,
COMPOSE_LANGUAGE_DELETE,
COMPOSE_LISTABILITY_CHANGE,
COMPOSE_EMOJI_INSERT,
COMPOSE_UPLOAD_CHANGE_REQUEST,
@ -1039,6 +1057,8 @@ export {
changeComposeSpoilerText,
changeComposeVisibility,
changeComposeLanguage,
addComposeLanguage,
deleteComposeLanguage,
insertEmojiCompose,
addPoll,
removePoll,

View File

@ -23,6 +23,7 @@ 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 }) => {
@ -90,6 +91,7 @@ interface IAccount {
withLinkToProfile?: boolean;
withRelationship?: boolean;
showEdit?: boolean;
showMultiLanguage?: boolean;
approvalStatus?: StatusApprovalStatus;
emoji?: string;
emojiUrl?: string;
@ -116,6 +118,7 @@ const Account = ({
withLinkToProfile = true,
withRelationship = true,
showEdit = false,
showMultiLanguage = false,
approvalStatus,
emoji,
emojiUrl,
@ -268,6 +271,16 @@ 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>

View File

@ -428,6 +428,7 @@ 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}

View File

@ -7,10 +7,10 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { createSelector } from 'reselect';
import { changeComposeLanguage } from 'soapbox/actions/compose';
import { addComposeLanguage, changeComposeLanguage, 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 } from 'soapbox/hooks';
import { useAppDispatch, useAppSelector, useCompose, useFeatures } from 'soapbox/hooks';
const getFrequentlyUsedLanguages = createSelector([
state => state.settings.get('frequentlyUsedLanguages', ImmutableMap()),
@ -28,7 +28,10 @@ const languages = Object.entries(languagesObject) as Array<[Language, string]>;
const messages = defineMessages({
languagePrompt: { id: 'compose.language_dropdown.prompt', defaultMessage: 'Select language' },
languageSuggestion: { id: 'compose.language_dropdown.suggestion', defaultMessage: '{language} (detected)' },
multipleLanguages: { id: 'compose.language_dropdown.more_languages', defaultMessage: '{count, plural, one {# more language} other {# more languages}}' },
search: { id: 'compose.language_dropdown.search', defaultMessage: 'Search language…' },
addLanguage: { id: 'compose.language_dropdown.add_language', defaultMessage: 'Add language' },
deleteLanguage: { id: 'compose.language_dropdown.delete_language', defaultMessage: 'DElete language' },
});
interface ILanguageDropdown {
@ -37,6 +40,7 @@ interface ILanguageDropdown {
const LanguageDropdown: React.FC<ILanguageDropdown> = ({ composeId }) => {
const intl = useIntl();
const features = useFeatures();
const dispatch = useAppDispatch();
const frequentlyUsedLanguages = useAppSelector(getFrequentlyUsedLanguages);
@ -62,7 +66,7 @@ const LanguageDropdown: React.FC<ILanguageDropdown> = ({ composeId }) => {
],
});
const { language, suggested_language: suggestedLanguage } = useCompose(composeId);
const { language, suggested_language: suggestedLanguage, textMap } = useCompose(composeId);
const handleClick: React.EventHandler<
React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>
@ -127,6 +131,26 @@ const LanguageDropdown: React.FC<ILanguageDropdown> = ({ composeId }) => {
handleChange(value);
};
const handleAddLanguageClick: React.EventHandler<any> = (e: MouseEvent | KeyboardEvent) => {
const value = (e.currentTarget as HTMLElement)?.parentElement?.getAttribute('data-index') as Language;
e.preventDefault();
e.stopPropagation();
setIsOpen(false);
dispatch(addComposeLanguage(composeId, value));
};
const handleDeleteLanguageClick: React.EventHandler<any> = (e: MouseEvent | KeyboardEvent) => {
const value = (e.currentTarget as HTMLElement)?.parentElement?.getAttribute('data-index') as Language;
e.preventDefault();
e.stopPropagation();
setIsOpen(false);
dispatch(deleteComposeLanguage(composeId, value));
};
const handleClear: React.MouseEventHandler = (e) => {
e.preventDefault();
e.stopPropagation();
@ -139,6 +163,14 @@ const LanguageDropdown: React.FC<ILanguageDropdown> = ({ composeId }) => {
return [...languages].sort((a, b) => {
// Push current selection to the top of the list
if (textMap.has(a[0])) {
if (b[0] === language) return 1;
return -1;
}
if (textMap.has(b[0])) {
if (a[0] === language) return -1;
return 1;
}
if (a[0] === language) {
return -1;
} else if (b[0] === language) {
@ -255,8 +287,13 @@ const LanguageDropdown: React.FC<ILanguageDropdown> = ({ composeId }) => {
const results = search();
let buttonLabel = intl.formatMessage(messages.languagePrompt);
if (language) buttonLabel = languagesObject[language];
else if (suggestedLanguage) buttonLabel = intl.formatMessage(messages.languageSuggestion, {
if (language) {
const list: string[] = [languagesObject[language]];
if (textMap.size) list.push(intl.formatMessage(messages.multipleLanguages, {
count: textMap.size,
}));
buttonLabel = intl.formatList(list);
} else if (suggestedLanguage) buttonLabel = intl.formatMessage(messages.languageSuggestion, {
language: languagesObject[suggestedLanguage as Language] || suggestedLanguage,
});
@ -320,19 +357,30 @@ const LanguageDropdown: React.FC<ILanguageDropdown> = ({ composeId }) => {
onKeyDown={handleOptionKeyDown}
onClick={handleOptionClick}
className={clsx(
'flex cursor-pointer 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 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 },
)}
aria-selected={active}
ref={active ? focusedItem : null}
>
<div
className={clsx('flex-auto 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,
})}
>
{name}
</div>
{features.multiLanguage && !!language && !active && (
textMap.has(code) ? (
<button title={intl.formatMessage(messages.deleteLanguage)} onClick={handleDeleteLanguageClick}>
<Icon className='h-4 w-4' src={require('@tabler/icons/outline/minus.svg')} />
</button>
) : (
<button title={intl.formatMessage(messages.addLanguage)} onClick={handleAddLanguageClick}>
<Icon className='h-4 w-4' src={require('@tabler/icons/outline/plus.svg')} />
</button>
)
)}
</div>
);
})}

View File

@ -50,6 +50,7 @@ const StatusRecord = ImmutableRecord({
bookmarked: false,
card: null as Card | null,
content: '',
content_map: null as ImmutableMap<string, string> | null,
created_at: '',
dislikes_count: 0,
disliked: false,

View File

@ -57,6 +57,8 @@ import {
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';
@ -111,6 +113,7 @@ const ReducerCompose = ImmutableRecord({
suggestion_token: null as string | null,
tagHistory: ImmutableList<string>(),
text: '',
textMap: ImmutableMap<Language, string>(),
to: ImmutableOrderedSet<string>(),
parent_reblogged_by: null as string | null,
dismissed_quotes: ImmutableOrderedSet<string>(),
@ -553,11 +556,13 @@ const compose = (state = initialState, action: ComposeAction | EventsAction | Me
return list.splice(indexA, 1).splice(indexB, 0, moveItem);
}));
case COMPOSE_ADD_SUGGESTED_QUOTE:
return updateCompose(state, action.id, compose => compose
.set('quote', action.quoteId));
return updateCompose(state, action.id, compose => compose.set('quote', action.quoteId));
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:
return updateCompose(state, action.id, compose => compose.setIn(['textMap', action.value], ''));
case COMPOSE_LANGUAGE_DELETE:
return updateCompose(state, action.id, compose => compose.removeIn(['textMap', 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

@ -622,6 +622,12 @@ const getInstanceFeatures = (instance: Instance) => {
// v.software === PLEROMA && gte(v.version, '2.1.0'),
]),
/**
* Ability to include multiple language variants for a post.
* @see POST /api/v1/statuses
*/
multiLanguage: false, // features.includes('pleroma:multi_language'),
/**
* Ability to hide notifications from people you don't follow.
* @see PUT /api/pleroma/notification_settings