2025-08-22 14:34:37 +02:00
import { forwardRef , useCallback , useId , useMemo , useState } from 'react' ;
2025-08-14 17:04:32 +02:00
import type { FC } from 'react' ;
import { defineMessages , FormattedMessage , useIntl } from 'react-intl' ;
import classNames from 'classnames' ;
import type { ApiQuotePolicy } from '@/mastodon/api_types/quotes' ;
import { isQuotePolicy } from '@/mastodon/api_types/quotes' ;
2025-08-21 16:07:31 +02:00
import { isStatusVisibility } from '@/mastodon/api_types/statuses' ;
2025-08-14 17:04:32 +02:00
import type { StatusVisibility } from '@/mastodon/api_types/statuses' ;
2025-08-22 14:34:37 +02:00
import { Button } from '@/mastodon/components/button' ;
2025-08-14 17:04:32 +02:00
import { Dropdown } from '@/mastodon/components/dropdown' ;
import type { SelectItem } from '@/mastodon/components/dropdown_selector' ;
import { IconButton } from '@/mastodon/components/icon_button' ;
import { messages as privacyMessages } from '@/mastodon/features/compose/components/privacy_dropdown' ;
2025-08-21 16:07:31 +02:00
import { createAppSelector , useAppSelector } from '@/mastodon/store' ;
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react' ;
2025-08-14 17:04:32 +02:00
import CloseIcon from '@/material-icons/400-24px/close.svg?react' ;
2025-08-21 16:07:31 +02:00
import LockIcon from '@/material-icons/400-24px/lock.svg?react' ;
import PublicIcon from '@/material-icons/400-24px/public.svg?react' ;
import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react' ;
2025-08-14 17:04:32 +02:00
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' ,
2025-08-25 14:30:37 +02:00
defaultMessage : 'Just me' ,
2025-08-14 17:04:32 +02:00
} ,
} ) ;
2025-08-21 16:07:31 +02:00
export type VisibilityModalCallback = (
visibility : StatusVisibility ,
quotePolicy : ApiQuotePolicy ,
) = > void ;
2025-08-14 17:04:32 +02:00
interface VisibilityModalProps extends BaseConfirmationModalProps {
2025-08-21 16:07:31 +02:00
statusId? : string ;
onChange : VisibilityModalCallback ;
2025-08-14 17:04:32 +02:00
}
const selectStatusPolicy = createAppSelector (
2025-08-21 16:07:31 +02:00
[
( state ) = > state . statuses ,
( _state , statusId? : string ) = > statusId ,
( state ) = > state . compose . get ( 'quote_policy' ) as ApiQuotePolicy ,
] ,
( statuses , statusId , composeQuotePolicy ) = > {
if ( ! statusId ) {
return composeQuotePolicy ;
}
2025-08-14 17:04:32 +02:00
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' ;
} ,
) ;
2025-09-02 17:48:37 +02:00
const selectDisablePublicVisibilities = createAppSelector (
[
( state ) = > state . statuses ,
( _state , statusId? : string ) = > ! ! statusId ,
( state ) = > state . compose . get ( 'quoted_status_id' ) as string | null ,
] ,
( statuses , isEditing , statusId ) = > {
if ( isEditing || ! statusId ) return false ;
const status = statuses . get ( statusId ) ;
if ( ! status ) {
return false ;
}
return status . get ( 'visibility' ) === 'private' ;
} ,
) ;
2025-08-14 17:04:32 +02:00
export const VisibilityModal : FC < VisibilityModalProps > = forwardRef (
2025-08-22 14:34:37 +02:00
// eslint-disable-next-line @typescript-eslint/no-unused-vars
( { onClose , onChange , statusId } , _ref ) = > {
2025-08-14 17:04:32 +02:00
const intl = useIntl ( ) ;
2025-08-21 16:07:31 +02:00
const currentVisibility = useAppSelector ( ( state ) = >
statusId
? ( ( state . statuses . getIn ( [ statusId , 'visibility' ] , 'public' ) as
| StatusVisibility
| undefined ) ? ? 'public' )
: ( state . compose . get ( 'privacy' ) as StatusVisibility ) ,
2025-08-14 17:04:32 +02:00
) ;
const currentQuotePolicy = useAppSelector ( ( state ) = >
selectStatusPolicy ( state , statusId ) ,
) ;
2025-08-21 16:07:31 +02:00
const [ visibility , setVisibility ] = useState ( currentVisibility ) ;
const [ quotePolicy , setQuotePolicy ] = useState ( currentQuotePolicy ) ;
const disableVisibility = ! ! statusId ;
2025-08-14 17:04:32 +02:00
const disableQuotePolicy =
2025-08-21 16:07:31 +02:00
visibility === 'private' || visibility === 'direct' ;
2025-11-04 17:32:52 +01:00
const disablePublicVisibilities = useAppSelector (
2025-09-02 17:48:37 +02:00
selectDisablePublicVisibilities ,
) ;
2025-11-04 17:32:52 +01:00
const isQuotePost = useAppSelector (
( state ) = > state . compose . get ( 'quoted_status_id' ) !== null ,
) ;
2025-08-14 17:04:32 +02:00
2025-09-02 17:48:37 +02:00
const visibilityItems = useMemo < SelectItem < StatusVisibility > [ ] > ( ( ) = > {
const items : SelectItem < StatusVisibility > [ ] = [
2025-08-14 17:04:32 +02:00
{
value : 'private' ,
text : intl.formatMessage ( privacyMessages . private_short ) ,
meta : intl.formatMessage ( privacyMessages . private_long ) ,
2025-08-21 16:07:31 +02:00
icon : 'lock' ,
iconComponent : LockIcon ,
2025-08-14 17:04:32 +02:00
} ,
{
value : 'direct' ,
text : intl.formatMessage ( privacyMessages . direct_short ) ,
meta : intl.formatMessage ( privacyMessages . direct_long ) ,
2025-08-21 16:07:31 +02:00
icon : 'at' ,
iconComponent : AlternateEmailIcon ,
2025-08-14 17:04:32 +02:00
} ,
2025-09-02 17:48:37 +02:00
] ;
if ( ! disablePublicVisibilities ) {
items . unshift (
{
value : 'public' ,
text : intl.formatMessage ( privacyMessages . public_short ) ,
meta : intl.formatMessage ( privacyMessages . public_long ) ,
icon : 'globe' ,
iconComponent : PublicIcon ,
} ,
{
value : 'unlisted' ,
text : intl.formatMessage ( privacyMessages . unlisted_short ) ,
meta : intl.formatMessage ( privacyMessages . unlisted_long ) ,
icon : 'unlock' ,
iconComponent : QuietTimeIcon ,
} ,
) ;
}
return items ;
} , [ intl , disablePublicVisibilities ] ) ;
2025-08-14 17:04:32 +02:00
const quoteItems = useMemo < SelectItem < ApiQuotePolicy > [ ] > (
( ) = > [
{ value : 'public' , text : intl.formatMessage ( messages . quotePublic ) } ,
{
value : 'followers' ,
text : intl.formatMessage ( messages . quoteFollowers ) ,
} ,
{ value : 'nobody' , text : intl.formatMessage ( messages . quoteNobody ) } ,
] ,
[ intl ] ,
) ;
2025-08-21 16:07:31 +02:00
const handleVisibilityChange = useCallback ( ( value : string ) = > {
if ( isStatusVisibility ( value ) ) {
setVisibility ( value ) ;
}
} , [ ] ) ;
const handleQuotePolicyChange = useCallback ( ( value : string ) = > {
if ( isQuotePolicy ( value ) ) {
setQuotePolicy ( value ) ;
}
} , [ ] ) ;
2025-08-22 14:34:37 +02:00
const handleSave = useCallback ( ( ) = > {
onChange ( visibility , quotePolicy ) ;
onClose ( ) ;
} , [ onChange , onClose , visibility , quotePolicy ] ) ;
2025-08-14 17:04:32 +02:00
2025-09-09 19:44:43 +02:00
const uniqueId = useId ( ) ;
const visibilityLabelId = ` ${ uniqueId } -visibility-label ` ;
const visibilityDescriptionId = ` ${ uniqueId } -visibility-desc ` ;
const quoteLabelId = ` ${ uniqueId } -quote-label ` ;
const quoteDescriptionId = ` ${ uniqueId } -quote-desc ` ;
2025-08-14 17:04:32 +02:00
return (
< div className = 'modal-root__modal dialog-modal visibility-modal' >
< div className = 'dialog-modal__header' >
< IconButton
className = 'dialog-modal__header__close'
title = { intl . formatMessage ( messages . close ) }
icon = 'times'
iconComponent = { CloseIcon }
onClick = { onClose }
/ >
< FormattedMessage
id = 'visibility_modal.header'
defaultMessage = 'Visibility and interaction'
>
{ ( chunks ) = > (
< span className = 'dialog-modal__header__title' > { chunks } < / span >
) }
< / FormattedMessage >
< / div >
< div className = 'dialog-modal__content' >
< div className = 'dialog-modal__content__description' >
< FormattedMessage
id = 'visibility_modal.instructions'
2025-09-01 13:17:27 +02:00
defaultMessage = 'Control who can interact with this post. You can also apply settings to all future posts by navigating to <link>Preferences > Posting defaults</link>.'
2025-08-14 17:04:32 +02:00
values = { {
link : ( chunks ) = > (
2025-09-01 13:17:27 +02:00
< a href = '/settings/preferences/posting_defaults' > { chunks } < / a >
2025-08-14 17:04:32 +02:00
) ,
} }
tagName = 'p'
/ >
< / div >
< div className = 'dialog-modal__content__form' >
2025-09-09 19:44:43 +02:00
< div
className = { classNames ( 'visibility-dropdown' , {
2025-08-21 16:07:31 +02:00
disabled : disableVisibility ,
2025-08-14 17:04:32 +02:00
} ) }
>
2025-09-09 19:44:43 +02:00
{ /* eslint-disable-next-line jsx-a11y/label-has-associated-control */ }
< label
className = 'visibility-dropdown__label'
id = { visibilityLabelId }
>
< FormattedMessage
id = 'visibility_modal.privacy_label'
defaultMessage = 'Visibility'
/ >
< / label >
2025-08-14 17:04:32 +02:00
< Dropdown
items = { visibilityItems }
2025-08-21 16:07:31 +02:00
current = { visibility }
2025-08-14 17:04:32 +02:00
onChange = { handleVisibilityChange }
2025-09-09 19:44:43 +02:00
labelId = { visibilityLabelId }
descriptionId = { visibilityDescriptionId }
classPrefix = 'visibility-dropdown'
2025-08-21 16:07:31 +02:00
disabled = { disableVisibility }
2025-08-14 17:04:32 +02:00
/ >
{ ! ! statusId && (
2025-09-09 19:44:43 +02:00
< p
className = 'visibility-dropdown__helper'
id = 'visibilityDescriptionId'
>
2025-08-14 17:04:32 +02:00
< FormattedMessage
id = 'visibility_modal.helper.privacy_editing'
defaultMessage = "Visibility can't be changed after a post is published."
/ >
< / p >
) }
2025-09-02 17:48:37 +02:00
{ ! statusId && disablePublicVisibilities && (
2025-09-09 19:44:43 +02:00
< p
className = 'visibility-dropdown__helper'
id = 'visibilityDescriptionId'
>
2025-09-02 17:48:37 +02:00
< FormattedMessage
id = 'visibility_modal.helper.privacy_private_self_quote'
defaultMessage = 'Self-quotes of private posts cannot be made public.'
/ >
< / p >
) }
2025-09-09 19:44:43 +02:00
< / div >
2025-08-14 17:04:32 +02:00
2025-09-09 19:44:43 +02:00
< div
className = { classNames ( 'visibility-dropdown' , {
2025-08-21 16:07:31 +02:00
disabled : disableQuotePolicy ,
2025-08-14 17:04:32 +02:00
} ) }
>
2025-09-09 19:44:43 +02:00
{ /* eslint-disable-next-line jsx-a11y/label-has-associated-control */ }
< label className = 'visibility-dropdown__label' id = { quoteLabelId } >
< FormattedMessage
id = 'visibility_modal.quote_label'
defaultMessage = 'Who can quote'
/ >
< / label >
2025-08-14 17:04:32 +02:00
< Dropdown
items = { quoteItems }
2025-09-09 19:44:43 +02:00
current = { disableQuotePolicy ? 'nobody' : quotePolicy }
2025-08-14 17:04:32 +02:00
onChange = { handleQuotePolicyChange }
2025-09-09 19:44:43 +02:00
labelId = { quoteLabelId }
descriptionId = { quoteDescriptionId }
2025-08-14 17:04:32 +02:00
classPrefix = 'visibility-dropdown'
2025-08-21 16:07:31 +02:00
disabled = { disableQuotePolicy }
2025-08-14 17:04:32 +02:00
/ >
2025-09-09 19:44:43 +02:00
< QuotePolicyHelper
policy = { quotePolicy }
visibility = { visibility }
className = 'visibility-dropdown__helper'
id = { quoteDescriptionId }
/ >
< / div >
2025-11-04 17:32:52 +01:00
{ isQuotePost && visibility === 'direct' && (
< div className = 'visibility-modal__quote-warning' >
< FormattedMessage
id = 'visibility_modal.direct_quote_warning.title'
defaultMessage = "Quotes can't be embedded in private mentions"
tagName = 'h3'
/ >
< FormattedMessage
id = 'visibility_modal.direct_quote_warning.text'
defaultMessage = 'If you save the current settings, the embedded quote will be converted to a link.'
tagName = 'p'
/ >
< / div >
) }
2025-08-14 17:04:32 +02:00
< / div >
2025-08-22 14:34:37 +02:00
< div className = 'dialog-modal__content__actions' >
< Button onClick = { onClose } secondary >
< FormattedMessage
id = 'confirmation_modal.cancel'
defaultMessage = 'Cancel'
/ >
< / Button >
< Button onClick = { handleSave } >
< FormattedMessage
id = 'visibility_modal.save'
defaultMessage = 'Save'
/ >
< / Button >
< / div >
2025-08-14 17:04:32 +02:00
< / div >
< / div >
) ;
} ,
) ;
VisibilityModal . displayName = 'VisibilityModal' ;
2025-09-09 19:44:43 +02:00
const QuotePolicyHelper : FC <
{
policy : ApiQuotePolicy ;
visibility : StatusVisibility ;
} & React . ComponentPropsWithoutRef < 'p' >
> = ( { policy , visibility , . . . otherProps } ) = > {
let hintText : React.ReactElement | undefined ;
2025-08-14 17:04:32 +02:00
if ( visibility === 'unlisted' && policy !== 'nobody' ) {
2025-09-09 19:44:43 +02:00
hintText = (
< FormattedMessage
id = 'visibility_modal.helper.unlisted_quoting'
defaultMessage = 'When people quote you, their post will also be hidden from trending timelines.'
/ >
2025-08-14 17:04:32 +02:00
) ;
}
if ( visibility === 'private' ) {
2025-09-09 19:44:43 +02:00
hintText = (
< FormattedMessage
id = 'visibility_modal.helper.private_quoting'
defaultMessage = "Follower-only posts authored on Mastodon can't be quoted by others."
/ >
2025-08-14 17:04:32 +02:00
) ;
}
if ( visibility === 'direct' ) {
2025-09-09 19:44:43 +02:00
hintText = (
< FormattedMessage
id = 'visibility_modal.helper.direct_quoting'
defaultMessage = "Private mentions authored on Mastodon can't be quoted by others."
/ >
2025-08-14 17:04:32 +02:00
) ;
}
2025-09-09 19:44:43 +02:00
if ( ! hintText ) {
return null ;
}
return < p { ...otherProps } > { hintText } < / p > ;
2025-08-14 17:04:32 +02:00
} ;