diff --git a/src/actions/compose.ts b/src/actions/compose.ts index 2bc67c8e6..8c54fc871 100644 --- a/src/actions/compose.ts +++ b/src/actions/compose.ts @@ -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 | ReturnType | ReturnType + | ReturnType + | ReturnType | ReturnType | ReturnType | ReturnType @@ -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, diff --git a/src/components/account.tsx b/src/components/account.tsx index ad3679eb4..41404fd86 100644 --- a/src/components/account.tsx +++ b/src/components/account.tsx @@ -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 = ({ 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 ? ( + <> + · + + + + ) : null} + {actionType === 'muting' && account.mute_expires_at ? ( <> · diff --git a/src/components/status.tsx b/src/components/status.tsx index 059d01fe2..4a2920290 100644 --- a/src/components/status.tsx +++ b/src/components/status.tsx @@ -428,6 +428,7 @@ const Status: React.FC = (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} diff --git a/src/features/compose/components/language-dropdown.tsx b/src/features/compose/components/language-dropdown.tsx index 8ed307393..d75972e27 100644 --- a/src/features/compose/components/language-dropdown.tsx +++ b/src/features/compose/components/language-dropdown.tsx @@ -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 = ({ composeId }) => { const intl = useIntl(); + const features = useFeatures(); const dispatch = useAppDispatch(); const frequentlyUsedLanguages = useAppSelector(getFrequentlyUsedLanguages); @@ -62,7 +66,7 @@ const LanguageDropdown: React.FC = ({ composeId }) => { ], }); - const { language, suggested_language: suggestedLanguage } = useCompose(composeId); + const { language, suggested_language: suggestedLanguage, textMap } = useCompose(composeId); const handleClick: React.EventHandler< React.MouseEvent | React.KeyboardEvent @@ -127,6 +131,26 @@ const LanguageDropdown: React.FC = ({ composeId }) => { handleChange(value); }; + const handleAddLanguageClick: React.EventHandler = (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 = (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 = ({ 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 = ({ 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 = ({ 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} >
{name}
+ {features.multiLanguage && !!language && !active && ( + textMap.has(code) ? ( + + ) : ( + + ) + )} ); })} diff --git a/src/normalizers/status.ts b/src/normalizers/status.ts index 0f655c283..78f5ea3d3 100644 --- a/src/normalizers/status.ts +++ b/src/normalizers/status.ts @@ -50,6 +50,7 @@ const StatusRecord = ImmutableRecord({ bookmarked: false, card: null as Card | null, content: '', + content_map: null as ImmutableMap | null, created_at: '', dislikes_count: 0, disliked: false, diff --git a/src/reducers/compose.ts b/src/reducers/compose.ts index 2b5eeb02c..a47aef0fb 100644 --- a/src/reducers/compose.ts +++ b/src/reducers/compose.ts @@ -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(), text: '', + textMap: ImmutableMap(), to: ImmutableOrderedSet(), parent_reblogged_by: null as string | null, dismissed_quotes: ImmutableOrderedSet(), @@ -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) diff --git a/src/utils/features.ts b/src/utils/features.ts index 404966e6d..d1ca5d69c 100644 --- a/src/utils/features.ts +++ b/src/utils/features.ts @@ -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