diff --git a/app/javascript/flavours/glitch/actions/statuses_typed.ts b/app/javascript/flavours/glitch/actions/statuses_typed.ts index b79d98df07..4472cbad25 100644 --- a/app/javascript/flavours/glitch/actions/statuses_typed.ts +++ b/app/javascript/flavours/glitch/actions/statuses_typed.ts @@ -1,8 +1,10 @@ import { createAction } from '@reduxjs/toolkit'; -import { apiGetContext } from 'flavours/glitch/api/statuses'; +import { apiGetContext, apiSetQuotePolicy } from 'flavours/glitch/api/statuses'; import { createDataLoadingThunk } from 'flavours/glitch/store/typed_functions'; +import type { ApiQuotePolicy } from '../api_types/quotes'; + import { importFetchedStatuses } from './importer'; export const fetchContext = createDataLoadingThunk( @@ -23,3 +25,10 @@ export const fetchContext = createDataLoadingThunk( export const completeContextRefresh = createAction<{ statusId: string }>( 'status/context/complete', ); + +export const setStatusQuotePolicy = createDataLoadingThunk( + 'status/setQuotePolicy', + ({ statusId, policy }: { statusId: string; policy: ApiQuotePolicy }) => { + return apiSetQuotePolicy(statusId, policy); + }, +); diff --git a/app/javascript/flavours/glitch/api/statuses.ts b/app/javascript/flavours/glitch/api/statuses.ts index 7e2a9ee8e0..4e164b3a4f 100644 --- a/app/javascript/flavours/glitch/api/statuses.ts +++ b/app/javascript/flavours/glitch/api/statuses.ts @@ -1,5 +1,10 @@ -import api, { getAsyncRefreshHeader } from 'flavours/glitch/api'; -import type { ApiContextJSON } from 'flavours/glitch/api_types/statuses'; +import api, { apiRequestPut, getAsyncRefreshHeader } from 'flavours/glitch/api'; +import type { + ApiContextJSON, + ApiStatusJSON, +} from 'flavours/glitch/api_types/statuses'; + +import type { ApiQuotePolicy } from '../api_types/quotes'; export const apiGetContext = async (statusId: string) => { const response = await api().request({ @@ -12,3 +17,15 @@ export const apiGetContext = async (statusId: string) => { refresh: getAsyncRefreshHeader(response), }; }; + +export const apiSetQuotePolicy = async ( + statusId: string, + policy: ApiQuotePolicy, +) => { + return apiRequestPut( + `v1/statuses/${statusId}/interaction_policy`, + { + quote_approval_policy: policy, + }, + ); +}; diff --git a/app/javascript/flavours/glitch/api_types/quotes.ts b/app/javascript/flavours/glitch/api_types/quotes.ts index 8c0ea10fc3..981c047c13 100644 --- a/app/javascript/flavours/glitch/api_types/quotes.ts +++ b/app/javascript/flavours/glitch/api_types/quotes.ts @@ -1,7 +1,7 @@ import type { ApiStatusJSON } from './statuses'; export type ApiQuoteState = 'accepted' | 'pending' | 'revoked' | 'unauthorized'; -export type ApiQuotePolicy = 'public' | 'followers' | 'nobody'; +export type ApiQuotePolicy = 'public' | 'followers' | 'nobody' | 'unknown'; interface ApiQuoteEmptyJSON { state: Exclude; @@ -21,3 +21,13 @@ interface ApiQuoteAcceptedJSON { } export type ApiQuoteJSON = ApiQuoteAcceptedJSON | ApiQuoteEmptyJSON; + +export interface ApiQuotePolicyJSON { + automatic: ApiQuotePolicy[]; + manual: ApiQuotePolicy[]; + current_user: ApiQuotePolicy; +} + +export function isQuotePolicy(policy: string): policy is ApiQuotePolicy { + return ['public', 'followers', 'nobody'].includes(policy); +} diff --git a/app/javascript/flavours/glitch/api_types/statuses.ts b/app/javascript/flavours/glitch/api_types/statuses.ts index 85357a13a4..4b90e53a53 100644 --- a/app/javascript/flavours/glitch/api_types/statuses.ts +++ b/app/javascript/flavours/glitch/api_types/statuses.ts @@ -4,7 +4,7 @@ import type { ApiAccountJSON } from './accounts'; import type { ApiCustomEmojiJSON } from './custom_emoji'; import type { ApiMediaAttachmentJSON } from './media_attachments'; import type { ApiPollJSON } from './polls'; -import type { ApiQuoteJSON } from './quotes'; +import type { ApiQuoteJSON, ApiQuotePolicyJSON } from './quotes'; // See app/modals/status.rb export type StatusVisibility = @@ -120,6 +120,7 @@ export interface ApiStatusJSON { card?: ApiPreviewCardJSON; poll?: ApiPollJSON; quote?: ApiQuoteJSON; + quote_approval?: ApiQuotePolicyJSON; // glitch-soc additions local_only?: boolean; @@ -130,3 +131,9 @@ export interface ApiContextJSON { ancestors: ApiStatusJSON[]; descendants: ApiStatusJSON[]; } + +export interface ApiStatusSourceJSON { + id: string; + text: string; + spoiler_text: string; +} diff --git a/app/javascript/flavours/glitch/components/dropdown/index.tsx b/app/javascript/flavours/glitch/components/dropdown/index.tsx new file mode 100644 index 0000000000..1e442f8159 --- /dev/null +++ b/app/javascript/flavours/glitch/components/dropdown/index.tsx @@ -0,0 +1,114 @@ +import { useCallback, useId, useMemo, useRef, useState } from 'react'; +import type { ComponentPropsWithoutRef, FC } from 'react'; + +import { FormattedMessage } from 'react-intl'; +import type { MessageDescriptor } from 'react-intl'; + +import classNames from 'classnames'; + +import Overlay from 'react-overlays/Overlay'; + +import type { SelectItem } from '../dropdown_selector'; +import { DropdownSelector } from '../dropdown_selector'; + +interface DropdownProps { + title: string; + disabled?: boolean; + items: SelectItem[]; + onChange: (value: string) => void; + current: string; + emptyText?: MessageDescriptor; + classPrefix: string; +} + +export const Dropdown: FC< + DropdownProps & Omit, keyof DropdownProps> +> = ({ + title, + disabled, + items, + current, + onChange, + classPrefix, + className, + ...buttonProps +}) => { + const buttonRef = useRef(null); + const accessibilityId = useId(); + + const [open, setOpen] = useState(false); + const handleToggle = useCallback(() => { + if (!disabled) { + setOpen((prevOpen) => !prevOpen); + } + }, [disabled]); + const handleClose = useCallback(() => { + setOpen(false); + }, []); + const currentText = useMemo( + () => items.find((i) => i.value === current)?.text, + [current, items], + ); + return ( + <> + + + + {({ props, placement }) => ( +
+
+ +
+
+ )} +
+ + ); +}; diff --git a/app/javascript/flavours/glitch/components/dropdown_selector.tsx b/app/javascript/flavours/glitch/components/dropdown_selector.tsx index 99bbd182e5..9299e7d6bd 100644 --- a/app/javascript/flavours/glitch/components/dropdown_selector.tsx +++ b/app/javascript/flavours/glitch/components/dropdown_selector.tsx @@ -13,8 +13,8 @@ const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true; -export interface SelectItem { - value: string; +export interface SelectItem { + value: Value; icon?: string; iconComponent?: IconProp; text: string; @@ -24,7 +24,7 @@ export interface SelectItem { interface Props { value: string; - classNamePrefix: string; + classNamePrefix?: string; style?: React.CSSProperties; items: SelectItem[]; onChange: (value: string) => void; diff --git a/app/javascript/flavours/glitch/components/status_action_bar.jsx b/app/javascript/flavours/glitch/components/status_action_bar.jsx index 4f06839956..e1e919e09a 100644 --- a/app/javascript/flavours/glitch/components/status_action_bar.jsx +++ b/app/javascript/flavours/glitch/components/status_action_bar.jsx @@ -32,6 +32,7 @@ import { me } from '../initial_state'; import { IconButton } from './icon_button'; import { RelativeTimestamp } from './relative_timestamp'; +import { isFeatureEnabled } from '../utils/environment'; const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, @@ -69,6 +70,7 @@ const messages = defineMessages({ filter: { id: 'status.filter', defaultMessage: 'Filter this post' }, openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' }, revokeQuote: { id: 'status.revoke_quote', defaultMessage: 'Remove my post from @{name}’s post' }, + quotePolicyChange: { id: 'status.quote_policy_change', defaultMessage: 'Change who can quote' }, }); const mapStateToProps = (state, { status }) => { @@ -88,6 +90,7 @@ class StatusActionBar extends ImmutablePureComponent { onReblog: PropTypes.func, onDelete: PropTypes.func, onRevokeQuote: PropTypes.func, + onQuotePolicyChange: PropTypes.func, onDirect: PropTypes.func, onMention: PropTypes.func, onMute: PropTypes.func, @@ -190,7 +193,11 @@ class StatusActionBar extends ImmutablePureComponent { handleRevokeQuoteClick = () => { this.props.onRevokeQuote(this.props.status); - } + }; + + handleQuotePolicyChange = () => { + this.props.onQuotePolicyChange(this.props.status); + }; handleBlockClick = () => { this.props.onBlock(this.props.status); @@ -264,6 +271,9 @@ class StatusActionBar extends ImmutablePureComponent { if (writtenByMe || withDismiss) { menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); + if (writtenByMe && isFeatureEnabled('outgoing_quotes') && !['private', 'direct'].includes(status.get('visibility'))) { + menu.push({ text: intl.formatMessage(messages.quotePolicyChange), action: this.handleQuotePolicyChange }); + } menu.push(null); } diff --git a/app/javascript/flavours/glitch/containers/status_container.js b/app/javascript/flavours/glitch/containers/status_container.js index 20e73218d1..261d6afb20 100644 --- a/app/javascript/flavours/glitch/containers/status_container.js +++ b/app/javascript/flavours/glitch/containers/status_container.js @@ -123,6 +123,10 @@ const mapDispatchToProps = (dispatch, { contextType }) => ({ dispatch(openModal({ modalType: 'CONFIRM_REVOKE_QUOTE', modalProps: { statusId: status.get('id'), quotedStatusId: status.getIn(['quote', 'quoted_status']) }})); }, + onQuotePolicyChange(status) { + dispatch(openModal({ modalType: 'COMPOSE_PRIVACY', modalProps: { statusId: status.get('id') } })); + }, + onEdit (status) { dispatch((_, getState) => { let state = getState(); diff --git a/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.jsx b/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.jsx index 6b0c6db3ec..85006d211a 100644 --- a/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.jsx +++ b/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.jsx @@ -14,7 +14,7 @@ import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react'; import { DropdownSelector } from 'flavours/glitch/components/dropdown_selector'; import { Icon } from 'flavours/glitch/components/icon'; -const messages = defineMessages({ +export const messages = defineMessages({ public_short: { id: 'privacy.public.short', defaultMessage: 'Public' }, public_long: { id: 'privacy.public.long', defaultMessage: 'Anyone on and off Mastodon' }, unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Quiet public' }, diff --git a/app/javascript/flavours/glitch/features/status/components/action_bar.jsx b/app/javascript/flavours/glitch/features/status/components/action_bar.jsx index 6dd829e3ec..a4c0b8f274 100644 --- a/app/javascript/flavours/glitch/features/status/components/action_bar.jsx +++ b/app/javascript/flavours/glitch/features/status/components/action_bar.jsx @@ -27,6 +27,7 @@ import { accountAdminLink, statusAdminLink } from 'flavours/glitch/utils/backend import { IconButton } from '../../../components/icon_button'; import { Dropdown } from 'flavours/glitch/components/dropdown_menu'; import { me } from '../../../initial_state'; +import { isFeatureEnabled } from '@/flavours/glitch/utils/environment'; const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, @@ -59,6 +60,7 @@ const messages = defineMessages({ copy: { id: 'status.copy', defaultMessage: 'Copy link to post' }, openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' }, revokeQuote: { id: 'status.revoke_quote', defaultMessage: 'Remove my post from @{name}’s post' }, + quotePolicyChange: { id: 'status.quote_policy_change', defaultMessage: 'Change who can quote' }, }); const mapStateToProps = (state, { status }) => { @@ -79,6 +81,7 @@ class ActionBar extends PureComponent { onBookmark: PropTypes.func.isRequired, onDelete: PropTypes.func.isRequired, onRevokeQuote: PropTypes.func, + onQuotePolicyChange: PropTypes.func, onEdit: PropTypes.func.isRequired, onDirect: PropTypes.func.isRequired, onMention: PropTypes.func.isRequired, @@ -113,7 +116,11 @@ class ActionBar extends PureComponent { handleRevokeQuoteClick = () => { this.props.onRevokeQuote(this.props.status); - } + }; + + handleQuotePolicyChange = () => { + this.props.onQuotePolicyChange(this.props.status); + }; handleRedraftClick = () => { this.props.onDelete(this.props.status, true); @@ -202,6 +209,9 @@ class ActionBar extends PureComponent { } menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); + if (isFeatureEnabled('outgoing_quotes') && !['private', 'direct'].includes(status.get('visibility'))) { + menu.push({ text: intl.formatMessage(messages.quotePolicyChange), action: this.handleQuotePolicyChange }); + } menu.push(null); menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick }); menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick, dangerous: true }); diff --git a/app/javascript/flavours/glitch/features/status/index.jsx b/app/javascript/flavours/glitch/features/status/index.jsx index b0742ff576..d3618efdb5 100644 --- a/app/javascript/flavours/glitch/features/status/index.jsx +++ b/app/javascript/flavours/glitch/features/status/index.jsx @@ -295,6 +295,11 @@ class Status extends ImmutablePureComponent { dispatch(openModal({ modalType: 'CONFIRM_REVOKE_QUOTE', modalProps: { statusId: status.get('id'), quotedStatusId: status.getIn(['quote', 'quoted_status']) }})); }; + handleQuotePolicyChange = (status) => { + const { dispatch } = this.props; + dispatch(openModal({ modalType: 'COMPOSE_PRIVACY', modalProps: { statusId: status.get('id') } })); + }; + handleEditClick = (status) => { const { dispatch, askReplyConfirmation } = this.props; @@ -675,6 +680,7 @@ class Status extends ImmutablePureComponent { onBookmark={this.handleBookmarkClick} onDelete={this.handleDeleteClick} onRevokeQuote={this.handleRevokeQuoteClick} + onQuotePolicyChange={this.handleQuotePolicyChange} onEdit={this.handleEditClick} onDirect={this.handleDirectClick} onMention={this.handleMentionClick} diff --git a/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx b/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx index b0ecaf5e4f..cf0d8d5e86 100644 --- a/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx +++ b/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx @@ -47,6 +47,7 @@ import { ImageModal } from './image_modal'; import MediaModal from './media_modal'; import { ModalPlaceholder } from './modal_placeholder'; import VideoModal from './video_modal'; +import { VisibilityModal } from './visibility_modal'; export const MODAL_COMPONENTS = { 'MEDIA': () => Promise.resolve({ default: MediaModal }), @@ -84,6 +85,7 @@ export const MODAL_COMPONENTS = { 'CLOSED_REGISTRATIONS': ClosedRegistrationsModal, 'IGNORE_NOTIFICATIONS': IgnoreNotificationsModal, 'ANNUAL_REPORT': AnnualReportModal, + 'COMPOSE_PRIVACY': () => Promise.resolve({ default: VisibilityModal }), }; export default class ModalRoot extends PureComponent { diff --git a/app/javascript/flavours/glitch/features/ui/components/visibility_modal.tsx b/app/javascript/flavours/glitch/features/ui/components/visibility_modal.tsx new file mode 100644 index 0000000000..a873254142 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/visibility_modal.tsx @@ -0,0 +1,293 @@ +import { forwardRef, useCallback, useId, useMemo } from 'react'; +import type { FC } from 'react'; + +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import classNames from 'classnames'; + +import { changeComposeVisibility } from '@/flavours/glitch/actions/compose'; +import { setStatusQuotePolicy } from '@/flavours/glitch/actions/statuses_typed'; +import type { ApiQuotePolicy } from '@/flavours/glitch/api_types/quotes'; +import { isQuotePolicy } from '@/flavours/glitch/api_types/quotes'; +import type { StatusVisibility } from '@/flavours/glitch/api_types/statuses'; +import { Dropdown } from '@/flavours/glitch/components/dropdown'; +import type { SelectItem } from '@/flavours/glitch/components/dropdown_selector'; +import { IconButton } from '@/flavours/glitch/components/icon_button'; +import { messages as privacyMessages } from '@/flavours/glitch/features/compose/components/privacy_dropdown'; +import { + createAppSelector, + useAppDispatch, + useAppSelector, +} from '@/flavours/glitch/store'; +import CloseIcon from '@/material-icons/400-24px/close.svg?react'; + +import type { BaseConfirmationModalProps } from './confirmation_modals/confirmation_modal'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, + buttonTitle: { + id: 'visibility_modal.button_title', + defaultMessage: 'Set visibility', + }, + quotePublic: { + id: 'visibility_modal.quote_public', + defaultMessage: 'Anyone', + }, + quoteFollowers: { + id: 'visibility_modal.quote_followers', + defaultMessage: 'Followers only', + }, + quoteNobody: { + id: 'visibility_modal.quote_nobody', + defaultMessage: 'No one', + }, +}); + +interface VisibilityModalProps extends BaseConfirmationModalProps { + statusId: string; +} + +const selectStatusPolicy = createAppSelector( + [(state) => state.statuses, (_state, statusId: string) => statusId], + (statuses, statusId) => { + const status = statuses.get(statusId); + if (!status) { + return 'public'; + } + const policy = + (status.getIn(['quote_approval', 'automatic', 0]) as string) || 'nobody'; + const visibility = status.get('visibility') as StatusVisibility; + + // If the status is private or direct, it cannot be quoted by anyone. + if (visibility === 'private' || visibility === 'direct') { + return 'nobody'; + } + + // If the status has a specific quote policy, return it. + if (isQuotePolicy(policy)) { + return policy; + } + + // Otherwise, return the default based on visibility. + if (visibility === 'unlisted') { + return 'followers'; + } + return 'public'; + }, +); + +export const VisibilityModal: FC = forwardRef( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + ({ onClose, statusId }, ref) => { + const intl = useIntl(); + const currentVisibility = useAppSelector( + (state) => + (state.statuses.getIn([statusId, 'visibility'], 'public') as + | StatusVisibility + | undefined) ?? 'public', + ); + const currentQuotePolicy = useAppSelector((state) => + selectStatusPolicy(state, statusId), + ); + const disableQuotePolicy = + currentVisibility === 'private' || currentVisibility === 'direct'; + const isSaving = useAppSelector( + (state) => + state.statuses.getIn([statusId, 'isSavingQuotePolicy']) === true, + ); + + const visibilityItems = useMemo[]>( + () => [ + { + value: 'public', + text: intl.formatMessage(privacyMessages.public_short), + meta: intl.formatMessage(privacyMessages.public_long), + }, + { + value: 'unlisted', + text: intl.formatMessage(privacyMessages.unlisted_short), + meta: intl.formatMessage(privacyMessages.unlisted_long), + }, + { + value: 'private', + text: intl.formatMessage(privacyMessages.private_short), + meta: intl.formatMessage(privacyMessages.private_long), + }, + { + value: 'direct', + text: intl.formatMessage(privacyMessages.direct_short), + meta: intl.formatMessage(privacyMessages.direct_long), + }, + ], + [intl], + ); + const quoteItems = useMemo[]>( + () => [ + { value: 'public', text: intl.formatMessage(messages.quotePublic) }, + { + value: 'followers', + text: intl.formatMessage(messages.quoteFollowers), + }, + { value: 'nobody', text: intl.formatMessage(messages.quoteNobody) }, + ], + [intl], + ); + + const dispatch = useAppDispatch(); + const handleVisibilityChange = useCallback( + (value: string) => { + // Published statuses cannot change visibility. + if (statusId) { + return; + } + dispatch(changeComposeVisibility(value)); + }, + [dispatch, statusId], + ); + const handleQuotePolicyChange = useCallback( + (value: string) => { + if (isQuotePolicy(value) && !disableQuotePolicy) { + void dispatch(setStatusQuotePolicy({ policy: value, statusId })); + } + }, + [disableQuotePolicy, dispatch, statusId], + ); + + const privacyDropdownId = useId(); + const quoteDropdownId = useId(); + + return ( +
+
+ + + {(chunks) => ( + {chunks} + )} + +
+
+
+ ( + {chunks} + ), + }} + tagName='p' + /> +
+
+ + + +
+
+
+ ); + }, +); +VisibilityModal.displayName = 'VisibilityModal'; + +const QuotePolicyHelper: FC<{ + policy: ApiQuotePolicy; + visibility: StatusVisibility; +}> = ({ policy, visibility }) => { + if (visibility === 'unlisted' && policy !== 'nobody') { + return ( +

+ +

+ ); + } + + if (visibility === 'private') { + return ( +

+ +

+ ); + } + + if (visibility === 'direct') { + return ( +

+ +

+ ); + } + + return null; +}; diff --git a/app/javascript/flavours/glitch/reducers/statuses.js b/app/javascript/flavours/glitch/reducers/statuses.js index c2d88b13d1..538d33a9ac 100644 --- a/app/javascript/flavours/glitch/reducers/statuses.js +++ b/app/javascript/flavours/glitch/reducers/statuses.js @@ -29,6 +29,7 @@ import { STATUS_FETCH_REQUEST, STATUS_FETCH_FAIL, } from '../actions/statuses'; +import { setStatusQuotePolicy } from '../actions/statuses_typed'; const importStatus = (state, status) => state.set(status.id, fromJS(status)); @@ -70,6 +71,22 @@ const initialState = ImmutableMap(); /** @type {import('@reduxjs/toolkit').Reducer} */ export default function statuses(state = initialState, action) { + if (setStatusQuotePolicy.pending.match(action)) { + const status = state.get(action.meta.arg.statusId); + if (status) { + return state.setIn([action.meta.arg.statusId, 'isSavingQuotePolicy'], true); + } + } else if (setStatusQuotePolicy.fulfilled.match(action)) { + const status = state.get(action.payload.id); + if (status) { + return state + .setIn([action.payload.id, 'quote_approval'], action.payload.quote_approval) + .deleteIn([action.payload.id, 'isSavingQuotePolicy']); + } + } else if (setStatusQuotePolicy.rejected.match(action)) { + return state.deleteIn([action.meta.arg.statusId, 'isSavingQuotePolicy']); + } + switch(action.type) { case STATUS_FETCH_REQUEST: return state.setIn([action.id, 'isLoading'], true); diff --git a/app/javascript/flavours/glitch/store/typed_functions.ts b/app/javascript/flavours/glitch/store/typed_functions.ts index 69f6028be2..3204d13ee4 100644 --- a/app/javascript/flavours/glitch/store/typed_functions.ts +++ b/app/javascript/flavours/glitch/store/typed_functions.ts @@ -40,7 +40,10 @@ interface AppThunkConfig { fulfilledMeta: AppMeta; rejectedMeta: AppMeta; } -type AppThunkApi = Pick, 'getState' | 'dispatch'>; +export type AppThunkApi = Pick< + GetThunkAPI, + 'getState' | 'dispatch' +>; interface AppThunkOptions { useLoadingBar?: boolean; diff --git a/app/javascript/flavours/glitch/styles/components.scss b/app/javascript/flavours/glitch/styles/components.scss index 6c5179b5f3..131ec95808 100644 --- a/app/javascript/flavours/glitch/styles/components.scss +++ b/app/javascript/flavours/glitch/styles/components.scss @@ -5534,7 +5534,8 @@ a.status-card { } .privacy-dropdown__dropdown, -.language-dropdown__dropdown { +.language-dropdown__dropdown, +.visibility-dropdown__dropdown { box-shadow: var(--dropdown-shadow); background: var(--dropdown-background-color); backdrop-filter: $backdrop-blur-filter; @@ -5563,7 +5564,8 @@ a.status-card { z-index: 9999; } -.privacy-dropdown__option { +.privacy-dropdown__option, +.visibility-dropdown__option { font-size: 14px; line-height: 20px; letter-spacing: 0.25px; @@ -5814,6 +5816,39 @@ a.status-card { } } +.visibility-dropdown { + &__overlay[data-popper-placement] { + z-index: 9999; + } + + &__label.disabled { + cursor: default; + opacity: 0.5; + } + + &__button { + color: $primary-text-color; + background: var(--dropdown-background-color); + border: 1px solid var(--dropdown-border-color); + padding: 8px 12px; + width: 100%; + text-align: left; + border-radius: 4px; + font-size: 14px; + height: 40px; + + &:disabled { + cursor: default; + } + } + + &__helper { + margin-top: 4px; + font-size: 0.8em; + color: $dark-text-color; + } +} + .search { margin-bottom: 32px; position: relative; @@ -6106,6 +6141,17 @@ a.status-card { } } +.modal-root label { + cursor: pointer; + display: block; + + > span { + display: block; + font-weight: 500; + margin-bottom: 8px; + } +} + .video-modal .video-player { max-height: 80vh; max-width: 100vw; @@ -6612,6 +6658,15 @@ a.status-card { letter-spacing: 0.25px; overflow-y: auto; + &__description { + margin: 24px 24px 0; + color: $darker-text-color; + + a { + color: inherit; + } + } + &__form { display: flex; flex-direction: column; diff --git a/app/javascript/flavours/glitch/utils/environment.ts b/app/javascript/flavours/glitch/utils/environment.ts index c5fe46bc93..fc4448740f 100644 --- a/app/javascript/flavours/glitch/utils/environment.ts +++ b/app/javascript/flavours/glitch/utils/environment.ts @@ -12,7 +12,11 @@ export function isProduction() { else return import.meta.env.PROD; } -export type Features = 'modern_emojis'; +export type Features = + | 'modern_emojis' + | 'outgoing_quotes' + | 'fasp' + | 'http_message_signatures'; export function isFeatureEnabled(feature: Features) { return initialState?.features.includes(feature) ?? false;