]> cat aescling's git repositories - mastodon.git/blob - app/javascript/mastodon/features/compose/components/privacy_dropdown.js
Add dropdown for boost privacy in boost confirmation modal (#15704)
[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 { supportsPassiveEvents } from 'detect-passive-events';
9 import classNames from 'classnames';
10 import Icon from 'mastodon/components/icon';
11
12 const messages = defineMessages({
13 public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
14 public_long: { id: 'privacy.public.long', defaultMessage: 'Visible for all, shown in public timelines' },
15 unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
16 unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Visible for all, but not in public timelines' },
17 private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
18 private_long: { id: 'privacy.private.long', defaultMessage: 'Visible for followers only' },
19 direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
20 direct_long: { id: 'privacy.direct.long', defaultMessage: 'Visible for mentioned users only' },
21 change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' },
22 });
23
24 const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
25
26 class PrivacyDropdownMenu extends React.PureComponent {
27
28 static propTypes = {
29 style: PropTypes.object,
30 items: PropTypes.array.isRequired,
31 value: PropTypes.string.isRequired,
32 placement: PropTypes.string.isRequired,
33 onClose: PropTypes.func.isRequired,
34 onChange: PropTypes.func.isRequired,
35 };
36
37 state = {
38 mounted: false,
39 };
40
41 handleDocumentClick = e => {
42 if (this.node && !this.node.contains(e.target)) {
43 this.props.onClose();
44 }
45 }
46
47 handleKeyDown = e => {
48 const { items } = this.props;
49 const value = e.currentTarget.getAttribute('data-index');
50 const index = items.findIndex(item => {
51 return (item.value === value);
52 });
53 let element = null;
54
55 switch(e.key) {
56 case 'Escape':
57 this.props.onClose();
58 break;
59 case 'Enter':
60 this.handleClick(e);
61 break;
62 case 'ArrowDown':
63 element = this.node.childNodes[index + 1] || this.node.firstChild;
64 break;
65 case 'ArrowUp':
66 element = this.node.childNodes[index - 1] || this.node.lastChild;
67 break;
68 case 'Tab':
69 if (e.shiftKey) {
70 element = this.node.childNodes[index - 1] || this.node.lastChild;
71 } else {
72 element = this.node.childNodes[index + 1] || this.node.firstChild;
73 }
74 break;
75 case 'Home':
76 element = this.node.firstChild;
77 break;
78 case 'End':
79 element = this.node.lastChild;
80 break;
81 }
82
83 if (element) {
84 element.focus();
85 this.props.onChange(element.getAttribute('data-index'));
86 e.preventDefault();
87 e.stopPropagation();
88 }
89 }
90
91 handleClick = e => {
92 const value = e.currentTarget.getAttribute('data-index');
93
94 e.preventDefault();
95
96 this.props.onClose();
97 this.props.onChange(value);
98 }
99
100 componentDidMount () {
101 document.addEventListener('click', this.handleDocumentClick, false);
102 document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
103 if (this.focusedItem) this.focusedItem.focus({ preventScroll: true });
104 this.setState({ mounted: true });
105 }
106
107 componentWillUnmount () {
108 document.removeEventListener('click', this.handleDocumentClick, false);
109 document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
110 }
111
112 setRef = c => {
113 this.node = c;
114 }
115
116 setFocusRef = c => {
117 this.focusedItem = c;
118 }
119
120 render () {
121 const { mounted } = this.state;
122 const { style, items, placement, value } = this.props;
123
124 return (
125 <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 }) }}>
126 {({ opacity, scaleX, scaleY }) => (
127 // It should not be transformed when mounting because the resulting
128 // size will be used to determine the coordinate of the menu by
129 // react-overlays
130 <div className={`privacy-dropdown__dropdown ${placement}`} style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} role='listbox' ref={this.setRef}>
131 {items.map(item => (
132 <div role='option' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleKeyDown} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })} aria-selected={item.value === value} ref={item.value === value ? this.setFocusRef : null}>
133 <div className='privacy-dropdown__option__icon'>
134 <Icon id={item.icon} fixedWidth />
135 </div>
136
137 <div className='privacy-dropdown__option__content'>
138 <strong>{item.text}</strong>
139 {item.meta}
140 </div>
141 </div>
142 ))}
143 </div>
144 )}
145 </Motion>
146 );
147 }
148
149 }
150
151 export default @injectIntl
152 class PrivacyDropdown extends React.PureComponent {
153
154 static propTypes = {
155 isUserTouching: PropTypes.func,
156 onModalOpen: PropTypes.func,
157 onModalClose: PropTypes.func,
158 value: PropTypes.string.isRequired,
159 onChange: PropTypes.func.isRequired,
160 noDirect: PropTpes.bool,
161 container: PropTypes.func,
162 intl: PropTypes.object.isRequired,
163 };
164
165 state = {
166 open: false,
167 placement: 'bottom',
168 };
169
170 handleToggle = ({ target }) => {
171 if (this.props.isUserTouching && this.props.isUserTouching()) {
172 if (this.state.open) {
173 this.props.onModalClose();
174 } else {
175 this.props.onModalOpen({
176 actions: this.options.map(option => ({ ...option, active: option.value === this.props.value })),
177 onClick: this.handleModalActionClick,
178 });
179 }
180 } else {
181 const { top } = target.getBoundingClientRect();
182 if (this.state.open && this.activeElement) {
183 this.activeElement.focus({ preventScroll: true });
184 }
185 this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' });
186 this.setState({ open: !this.state.open });
187 }
188 }
189
190 handleModalActionClick = (e) => {
191 e.preventDefault();
192
193 const { value } = this.options[e.currentTarget.getAttribute('data-index')];
194
195 this.props.onModalClose();
196 this.props.onChange(value);
197 }
198
199 handleKeyDown = e => {
200 switch(e.key) {
201 case 'Escape':
202 this.handleClose();
203 break;
204 }
205 }
206
207 handleMouseDown = () => {
208 if (!this.state.open) {
209 this.activeElement = document.activeElement;
210 }
211 }
212
213 handleButtonKeyDown = (e) => {
214 switch(e.key) {
215 case ' ':
216 case 'Enter':
217 this.handleMouseDown();
218 break;
219 }
220 }
221
222 handleClose = () => {
223 if (this.state.open && this.activeElement) {
224 this.activeElement.focus({ preventScroll: true });
225 }
226 this.setState({ open: false });
227 }
228
229 handleChange = value => {
230 this.props.onChange(value);
231 }
232
233 componentWillMount () {
234 const { intl: { formatMessage } } = this.props;
235
236 this.options = [
237 { icon: 'globe', value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) },
238 { icon: 'unlock', value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long) },
239 { icon: 'lock', value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) },
240 ];
241
242 if (!this.props.noDirect) {
243 this.options.push(
244 { icon: 'envelope', value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) },
245 );
246 }
247 }
248
249 render () {
250 const { value, container, intl } = this.props;
251 const { open, placement } = this.state;
252
253 const valueOption = this.options.find(item => item.value === value);
254
255 return (
256 <div className={classNames('privacy-dropdown', placement, { active: open })} onKeyDown={this.handleKeyDown}>
257 <div className={classNames('privacy-dropdown__value', { active: this.options.indexOf(valueOption) === (placement === 'bottom' ? 0 : (this.options.length - 1)) })}>
258 <IconButton
259 className='privacy-dropdown__value-icon'
260 icon={valueOption.icon}
261 title={intl.formatMessage(messages.change_privacy)}
262 size={18}
263 expanded={open}
264 active={open}
265 inverted
266 onClick={this.handleToggle}
267 onMouseDown={this.handleMouseDown}
268 onKeyDown={this.handleButtonKeyDown}
269 style={{ height: null, lineHeight: '27px' }}
270 />
271 </div>
272
273 <Overlay show={open} placement={placement} target={this} container={container}>
274 <PrivacyDropdownMenu
275 items={this.options}
276 value={value}
277 onClose={this.handleClose}
278 onChange={this.handleChange}
279 placement={placement}
280 />
281 </Overlay>
282 </div>
283 );
284 }
285
286 }
This page took 0.169575 seconds and 4 git commands to generate.