]> cat aescling's git repositories - mastodon.git/blob - app/javascript/mastodon/features/compose/components/privacy_dropdown.js
Place privacy dropdown menu top if it is closer to the bottom of the viewport (#7106)
[mastodon.git] / app / javascript / mastodon / features / compose / components / privacy_dropdown.js
1 import React from 'react';
2 import PropTypes from 'prop-types';
3 import { injectIntl, defineMessages } from 'react-intl';
4 import IconButton from '../../../components/icon_button';
5 import Overlay from 'react-overlays/lib/Overlay';
6 import Motion from '../../ui/util/optional_motion';
7 import spring from 'react-motion/lib/spring';
8 import detectPassiveEvents from 'detect-passive-events';
9 import classNames from 'classnames';
10
11 const messages = defineMessages({
12 public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
13 public_long: { id: 'privacy.public.long', defaultMessage: 'Post to public timelines' },
14 unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
15 unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Do not show in public timelines' },
16 private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
17 private_long: { id: 'privacy.private.long', defaultMessage: 'Post to followers only' },
18 direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
19 direct_long: { id: 'privacy.direct.long', defaultMessage: 'Post to mentioned users only' },
20 change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' },
21 });
22
23 const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
24
25 class PrivacyDropdownMenu extends React.PureComponent {
26
27 static propTypes = {
28 style: PropTypes.object,
29 items: PropTypes.array.isRequired,
30 value: PropTypes.string.isRequired,
31 onClose: PropTypes.func.isRequired,
32 onChange: PropTypes.func.isRequired,
33 };
34
35 state = {
36 mounted: false,
37 };
38
39 handleDocumentClick = e => {
40 if (this.node && !this.node.contains(e.target)) {
41 this.props.onClose();
42 }
43 }
44
45 handleClick = e => {
46 if (e.key === 'Escape') {
47 this.props.onClose();
48 } else if (!e.key || e.key === 'Enter') {
49 const value = e.currentTarget.getAttribute('data-index');
50
51 e.preventDefault();
52
53 this.props.onClose();
54 this.props.onChange(value);
55 }
56 }
57
58 componentDidMount () {
59 document.addEventListener('click', this.handleDocumentClick, false);
60 document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
61 this.setState({ mounted: true });
62 }
63
64 componentWillUnmount () {
65 document.removeEventListener('click', this.handleDocumentClick, false);
66 document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
67 }
68
69 setRef = c => {
70 this.node = c;
71 }
72
73 render () {
74 const { mounted } = this.state;
75 const { style, items, value } = this.props;
76
77 return (
78 <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
79 {({ opacity, scaleX, scaleY }) => (
80 // It should not be transformed when mounting because the resulting
81 // size will be used to determine the coordinate of the menu by
82 // react-overlays
83 <div className='privacy-dropdown__dropdown' style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} ref={this.setRef}>
84 {items.map(item => (
85 <div role='button' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleClick} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })}>
86 <div className='privacy-dropdown__option__icon'>
87 <i className={`fa fa-fw fa-${item.icon}`} />
88 </div>
89
90 <div className='privacy-dropdown__option__content'>
91 <strong>{item.text}</strong>
92 {item.meta}
93 </div>
94 </div>
95 ))}
96 </div>
97 )}
98 </Motion>
99 );
100 }
101
102 }
103
104 @injectIntl
105 export default class PrivacyDropdown extends React.PureComponent {
106
107 static propTypes = {
108 isUserTouching: PropTypes.func,
109 isModalOpen: PropTypes.bool.isRequired,
110 onModalOpen: PropTypes.func,
111 onModalClose: PropTypes.func,
112 value: PropTypes.string.isRequired,
113 onChange: PropTypes.func.isRequired,
114 intl: PropTypes.object.isRequired,
115 };
116
117 state = {
118 open: false,
119 placement: null,
120 };
121
122 handleToggle = ({ target }) => {
123 if (this.props.isUserTouching()) {
124 if (this.state.open) {
125 this.props.onModalClose();
126 } else {
127 this.props.onModalOpen({
128 actions: this.options.map(option => ({ ...option, active: option.value === this.props.value })),
129 onClick: this.handleModalActionClick,
130 });
131 }
132 } else {
133 const { top } = target.getBoundingClientRect();
134 this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' });
135 this.setState({ open: !this.state.open });
136 }
137 }
138
139 handleModalActionClick = (e) => {
140 e.preventDefault();
141
142 const { value } = this.options[e.currentTarget.getAttribute('data-index')];
143
144 this.props.onModalClose();
145 this.props.onChange(value);
146 }
147
148 handleKeyDown = e => {
149 switch(e.key) {
150 case 'Enter':
151 this.handleToggle(e);
152 break;
153 case 'Escape':
154 this.handleClose();
155 break;
156 }
157 }
158
159 handleClose = () => {
160 this.setState({ open: false });
161 }
162
163 handleChange = value => {
164 this.props.onChange(value);
165 }
166
167 componentWillMount () {
168 const { intl: { formatMessage } } = this.props;
169
170 this.options = [
171 { icon: 'globe', value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) },
172 { icon: 'unlock-alt', value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long) },
173 { icon: 'lock', value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) },
174 { icon: 'envelope', value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) },
175 ];
176 }
177
178 render () {
179 const { value, intl } = this.props;
180 const { open, placement } = this.state;
181
182 const valueOption = this.options.find(item => item.value === value);
183
184 return (
185 <div className={classNames('privacy-dropdown', { active: open })} onKeyDown={this.handleKeyDown}>
186 <div className={classNames('privacy-dropdown__value', { active: this.options.indexOf(valueOption) === 0 })}>
187 <IconButton
188 className='privacy-dropdown__value-icon'
189 icon={valueOption.icon}
190 title={intl.formatMessage(messages.change_privacy)}
191 size={18}
192 expanded={open}
193 active={open}
194 inverted
195 onClick={this.handleToggle}
196 style={{ height: null, lineHeight: '27px' }}
197 />
198 </div>
199
200 <Overlay show={open} placement={placement} target={this}>
201 <PrivacyDropdownMenu
202 items={this.options}
203 value={value}
204 onClose={this.handleClose}
205 onChange={this.handleChange}
206 />
207 </Overlay>
208 </div>
209 );
210 }
211
212 }
This page took 0.118264 seconds and 5 git commands to generate.