From f476e743c285d071696ab7b2f69a9bb9e76ad14f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Fri, 26 Jul 2024 21:55:17 +0200 Subject: [PATCH] Support Pleroma/GoToSocial-specific status visibilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- src/actions/auth.ts | 6 +- src/actions/compose.ts | 12 ++- src/components/status-action-bar.tsx | 4 +- .../auth-login/components/login-page.tsx | 2 +- .../compose/components/compose-form.tsx | 2 +- .../compose/components/privacy-dropdown.tsx | 91 +++++++++++++++---- src/features/compose/components/warning.tsx | 2 +- .../compose/containers/warning-container.tsx | 2 +- .../status/components/detailed-status.tsx | 2 +- src/locales/en.json | 5 + src/normalizers/status.ts | 2 +- src/reducers/compose.ts | 6 +- src/utils/features.ts | 20 +++- 13 files changed, 125 insertions(+), 31 deletions(-) diff --git a/src/actions/auth.ts b/src/actions/auth.ts index 4b5a7bd47..7d6856451 100644 --- a/src/actions/auth.ts +++ b/src/actions/auth.ts @@ -76,10 +76,10 @@ const getAuthApp = () => const createAuthApp = () => (dispatch: AppDispatch, getState: () => RootState) => { const params = { - client_name: sourceCode.displayName, + client_name: sourceCode.displayName, redirect_uris: 'urn:ietf:wg:oauth:2.0:oob', - scopes: getScopes(getState()), - website: sourceCode.homepage, + scopes: getScopes(getState()), + website: sourceCode.homepage, }; return dispatch(createApp(params)).then((app: Record) => diff --git a/src/actions/compose.ts b/src/actions/compose.ts index c09eb40df..5808dc345 100644 --- a/src/actions/compose.ts +++ b/src/actions/compose.ts @@ -65,7 +65,7 @@ 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; +const COMPOSE_FEDERATED_CHANGE = 'COMPOSE_FEDERATED_CHANGE' as const; const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT' as const; @@ -387,6 +387,7 @@ const submitCompose = (composeId: string, opts: SubmitComposeOpts = {}) => scheduled_at: compose.schedule, language: compose.language || compose.suggested_language, to, + federated: compose.federated, }; if (compose.language && compose.textMap.size) { @@ -937,6 +938,11 @@ const addSuggestedLanguage = (composeId: string, language: string) => ({ language, }); +const changeComposeFederated = (composeId: string) => ({ + type: COMPOSE_FEDERATED_CHANGE, + id: composeId, +}); + type ComposeAction = ComposeSetStatusAction | ReturnType @@ -989,6 +995,7 @@ type ComposeAction = | ReturnType | ReturnType | ReturnType + | ReturnType export { COMPOSE_CHANGE, @@ -1022,7 +1029,6 @@ export { COMPOSE_MODIFIED_LANGUAGE_CHANGE, COMPOSE_LANGUAGE_ADD, COMPOSE_LANGUAGE_DELETE, - COMPOSE_LISTABILITY_CHANGE, COMPOSE_EMOJI_INSERT, COMPOSE_UPLOAD_CHANGE_REQUEST, COMPOSE_UPLOAD_CHANGE_SUCCESS, @@ -1043,6 +1049,7 @@ export { COMPOSE_CHANGE_MEDIA_ORDER, COMPOSE_ADD_SUGGESTED_QUOTE, COMPOSE_ADD_SUGGESTED_LANGUAGE, + COMPOSE_FEDERATED_CHANGE, setComposeToStatus, changeCompose, replyCompose, @@ -1104,5 +1111,6 @@ export { changeMediaOrder, addSuggestedQuote, addSuggestedLanguage, + changeComposeFederated, type ComposeAction, }; diff --git a/src/components/status-action-bar.tsx b/src/components/status-action-bar.tsx index c10826dbe..a6788616e 100644 --- a/src/components/status-action-bar.tsx +++ b/src/components/status-action-bar.tsx @@ -463,7 +463,7 @@ const StatusActionBar: React.FC = ({ icon: status.pinned ? require('@tabler/icons/outline/pinned-off.svg') : require('@tabler/icons/outline/pin.svg'), }); } else { - if (status.visibility === 'private') { + if (status.visibility === 'private' || status.visibility === 'mutuals_only') { menu.push({ text: intl.formatMessage(status.reblogged ? messages.cancel_reblog_private : messages.reblog_private), action: handleReblogClick, @@ -656,7 +656,7 @@ const StatusActionBar: React.FC = ({ if (status.visibility === 'direct') { reblogIcon = require('@tabler/icons/outline/mail.svg'); - } else if (status.visibility === 'private') { + } else if (status.visibility === 'private' || status.visibility === 'mutuals_only') { reblogIcon = require('@tabler/icons/outline/lock.svg'); } diff --git a/src/features/auth-login/components/login-page.tsx b/src/features/auth-login/components/login-page.tsx index b9704000b..987f37527 100644 --- a/src/features/auth-login/components/login-page.tsx +++ b/src/features/auth-login/components/login-page.tsx @@ -78,7 +78,7 @@ const LoginPage = () => { -
+
diff --git a/src/features/compose/components/compose-form.tsx b/src/features/compose/components/compose-form.tsx index 513727960..0129cfe5f 100644 --- a/src/features/compose/components/compose-form.tsx +++ b/src/features/compose/components/compose-form.tsx @@ -229,7 +229,7 @@ const ComposeForm = ({ id, shouldCondense, autoFocus, clickab } else if (privacy === 'direct') { publishIcon = require('@tabler/icons/outline/mail.svg'); publishText = intl.formatMessage(messages.message); - } else if (privacy === 'private') { + } else if (privacy === 'private' || privacy === 'mutuals_only') { publishIcon = require('@tabler/icons/outline/lock.svg'); publishText = intl.formatMessage(messages.publish); } else { diff --git a/src/features/compose/components/privacy-dropdown.tsx b/src/features/compose/components/privacy-dropdown.tsx index 9a5a2ecd1..19a0e3637 100644 --- a/src/features/compose/components/privacy-dropdown.tsx +++ b/src/features/compose/components/privacy-dropdown.tsx @@ -1,17 +1,18 @@ import clsx from 'clsx'; import { supportsPassiveEvents } from 'detect-passive-events'; import React, { useState, useRef, useEffect } from 'react'; -import { useIntl, defineMessages } from 'react-intl'; +import { useIntl, defineMessages, FormattedMessage } from 'react-intl'; import { spring } from 'react-motion'; // @ts-ignore import Overlay from 'react-overlays/lib/Overlay'; -import { changeComposeVisibility } from 'soapbox/actions/compose'; +import { changeComposeFederated, changeComposeVisibility } from 'soapbox/actions/compose'; import { closeModal, openModal } from 'soapbox/actions/modals'; import Icon from 'soapbox/components/icon'; -import { Button } from 'soapbox/components/ui'; -import { useAppDispatch, useCompose } from 'soapbox/hooks'; +import { Button, Toggle } from 'soapbox/components/ui'; +import { useAppDispatch, useCompose, useFeatures, useInstance } from 'soapbox/hooks'; import { userTouching } from 'soapbox/is-mobile'; +import { GOTOSOCIAL, parseVersion, PLEROMA } from 'soapbox/utils/features'; import Motion from '../../ui/util/optional-motion'; @@ -22,13 +23,26 @@ const messages = defineMessages({ unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Do not post to public timelines' }, private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' }, private_long: { id: 'privacy.private.long', defaultMessage: 'Post to followers only' }, + mutuals_only_short: { id: 'privacy.mutuals_only.short', defaultMessage: 'Mutuals-only' }, + mutuals_only_long: { id: 'privacy.mutuals_only.long', defaultMessage: 'Post to mutually followed users only' }, direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' }, direct_long: { id: 'privacy.direct.long', defaultMessage: 'Post to mentioned users only' }, + local_short: { id: 'privacy.local.short', defaultMessage: 'Local-only' }, + local_long: { id: 'privacy.local.long', defaultMessage: 'Only visible on your instance' }, + change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust post privacy' }, + local: { id: 'privacy.local', defaultMessage: '{privacy} (local-only)' }, }); const listenerOptions = supportsPassiveEvents ? { passive: true } : false; +interface Option { + icon: string; + value: string; + text: string; + meta: string; +} + interface IPrivacyDropdownMenu { style?: React.CSSProperties; items: any[]; @@ -37,9 +51,14 @@ interface IPrivacyDropdownMenu { onClose: () => void; onChange: (value: string | null) => void; unavailable?: boolean; + showFederated?: boolean; + federated?: boolean; + onChangeFederated: () => void; } -const PrivacyDropdownMenu: React.FC = ({ style, items, placement, value, onClose, onChange }) => { +const PrivacyDropdownMenu: React.FC = ({ + style, items, placement, value, onClose, onChange, showFederated, federated, onChangeFederated, +}) => { const node = useRef(null); const focusedItem = useRef(null); @@ -52,9 +71,7 @@ const PrivacyDropdownMenu: React.FC = ({ style, items, pla }; const handleKeyDown: React.KeyboardEventHandler = e => { - const value = e.currentTarget.getAttribute('data-index'); - const index = items.findIndex(item => item.value === value); - let element: ChildNode | null | undefined = null; + const index = [...e.currentTarget.parentElement!.children].indexOf(e.currentTarget); let element: ChildNode | null | undefined = null; switch (e.key) { case 'Escape': @@ -86,7 +103,8 @@ const PrivacyDropdownMenu: React.FC = ({ style, items, pla if (element) { (element as HTMLElement).focus(); - onChange((element as HTMLElement).getAttribute('data-index')); + const value = (element as HTMLElement).getAttribute('data-index'); + if (value !== 'local_switch') onChange(value); e.preventDefault(); e.stopPropagation(); } @@ -97,8 +115,11 @@ const PrivacyDropdownMenu: React.FC = ({ style, items, pla e.preventDefault(); - onClose(); - onChange(value); + if (value === 'local_switch') onChangeFederated(); + else { + onClose(); + onChange(value); + } }; useEffect(() => { @@ -143,7 +164,7 @@ const PrivacyDropdownMenu: React.FC = ({ style, items, pla onKeyDown={handleKeyDown} onClick={handleClick} 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 items-center p-2.5 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} @@ -154,16 +175,41 @@ const PrivacyDropdownMenu: React.FC = ({ style, items, pla
- {item.text} + {item.text} {item.meta}
); })} + {showFederated && ( +
+
+ +
+ +
+ + + + +
+ + +
+ )} )} @@ -181,6 +227,10 @@ const PrivacyDropdown: React.FC = ({ const intl = useIntl(); const node = useRef(null); const activeElement = useRef(null); + const instance = useInstance(); + const features = useFeatures(); + + const v = parseVersion(instance.version); const compose = useCompose(composeId); @@ -194,11 +244,15 @@ const PrivacyDropdown: React.FC = ({ { icon: require('@tabler/icons/outline/world.svg'), value: 'public', text: intl.formatMessage(messages.public_short), meta: intl.formatMessage(messages.public_long) }, { icon: require('@tabler/icons/outline/lock-open.svg'), value: 'unlisted', text: intl.formatMessage(messages.unlisted_short), meta: intl.formatMessage(messages.unlisted_long) }, { icon: require('@tabler/icons/outline/lock.svg'), value: 'private', text: intl.formatMessage(messages.private_short), meta: intl.formatMessage(messages.private_long) }, + features.mutualsOnlyStatuses ? { icon: require('@tabler/icons/outline/users-group.svg'), value: 'mutuals_only', text: intl.formatMessage(messages.mutuals_only_short), meta: intl.formatMessage(messages.mutuals_only_long) } : undefined, { icon: require('@tabler/icons/outline/mail.svg'), value: 'direct', text: intl.formatMessage(messages.direct_short), meta: intl.formatMessage(messages.direct_long) }, - ]; + features.localOnlyStatuses && v.software === PLEROMA ? { icon: require('@tabler/icons/outline/affiliate.svg'), value: 'local', text: intl.formatMessage(messages.local_short), meta: intl.formatMessage(messages.local_long) } : undefined, + ].filter((option): option is Option => !!option); const onChange = (value: string | null) => value && dispatch(changeComposeVisibility(composeId, value)); + const onChangeFederated = () => dispatch(changeComposeFederated(composeId)); + const onModalOpen = (props: Record) => dispatch(openModal('ACTIONS', props)); const onModalClose = () => dispatch(closeModal('ACTIONS')); @@ -280,7 +334,9 @@ const PrivacyDropdown: React.FC = ({