Base frontend on privacy dropdown

This commit is contained in:
Renato "Lond" Cerqueira 2018-08-23 20:35:29 +02:00
parent ffaa814bbe
commit 4969ea4f2c
6 changed files with 298 additions and 0 deletions

View File

@ -40,6 +40,7 @@ export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE';
export const COMPOSE_FEDERATION_CHANGE = 'COMPOSE_FEDERATION_CHANGE';
export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE';
export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE';
@ -125,6 +126,7 @@ export function submitCompose() {
sensitive: getState().getIn(['compose', 'sensitive']),
spoiler_text: getState().getIn(['compose', 'spoiler_text'], ''),
visibility: getState().getIn(['compose', 'privacy']),
local_only: !getState().getIn(['compose', 'federation']),
}, {
headers: {
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
@ -445,6 +447,13 @@ export function changeComposeVisibility(value) {
};
};
export function changeComposeFederation(value) {
return {
type: COMPOSE_FEDERATION_CHANGE,
value,
};
};
export function insertEmojiCompose(position, emoji, needsSpace) {
return {
type: COMPOSE_EMOJI_INSERT,

View File

@ -9,6 +9,7 @@ import UploadButtonContainer from '../containers/upload_button_container';
import { defineMessages, injectIntl } from 'react-intl';
import SpoilerButtonContainer from '../containers/spoiler_button_container';
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
import FederationDropdownContainer from '../containers/federation_dropdown_container';
import SensitiveButtonContainer from '../containers/sensitive_button_container';
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
import UploadFormContainer from '../containers/upload_form_container';
@ -37,6 +38,7 @@ export default class ComposeForm extends ImmutablePureComponent {
suggestions: ImmutablePropTypes.list,
spoiler: PropTypes.bool,
privacy: PropTypes.string,
federation: PropTypes.bool,
spoiler_text: PropTypes.string,
focusDate: PropTypes.instanceOf(Date),
caretPosition: PropTypes.number,
@ -207,6 +209,7 @@ export default class ComposeForm extends ImmutablePureComponent {
<PrivacyDropdownContainer />
<SensitiveButtonContainer />
<SpoilerButtonContainer />
<FederationDropdownContainer />
</div>
<div className='character-counter__wrapper'><CharacterCounter max={500} text={text} /></div>
</div>

View File

@ -0,0 +1,250 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, defineMessages } from 'react-intl';
import IconButton from '../../../components/icon_button';
import Overlay from 'react-overlays/lib/Overlay';
import Motion from '../../ui/util/optional_motion';
import spring from 'react-motion/lib/spring';
import detectPassiveEvents from 'detect-passive-events';
import classNames from 'classnames';
const messages = defineMessages({
federate_short: { id: 'federation.federated.short', defaultMessage: 'Federated' },
federate_long: { id: 'federation.federated.long', defaultMessage: 'Allow toot to reach other instances' },
local_only_short: { id: 'federation.local_only.short', defaultMessage: 'Local-only' },
local_only_long: { id: 'federation.local_only.long', defaultMessage: 'Restrict this toot only to my instance' },
change_federation: { id: 'federation.change', defaultMessage: 'Adjust status federation' },
});
const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
class FederationDropdownMenu extends React.PureComponent {
static propTypes = {
style: PropTypes.object,
items: PropTypes.array.isRequired,
value: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
};
state = {
mounted: false,
};
handleDocumentClick = e => {
if (this.node && !this.node.contains(e.target)) {
this.props.onClose();
}
}
handleKeyDown = e => {
const { items } = this.props;
const value = Boolean(e.currentTarget.getAttribute('data-index'));
const index = items.findIndex(item => {
return (item.value === value);
});
let element;
switch(e.key) {
case 'Escape':
this.props.onClose();
break;
case 'Enter':
this.handleClick(e);
break;
case 'ArrowDown':
element = this.node.childNodes[index + 1];
if (element) {
element.focus();
this.props.onChange(Boolean(element.getAttribute('data-index')));
}
break;
case 'ArrowUp':
element = this.node.childNodes[index - 1];
if (element) {
element.focus();
this.props.onChange(Boolean(element.getAttribute('data-index')));
}
break;
case 'Home':
element = this.node.firstChild;
if (element) {
element.focus();
this.props.onChange(Boolean(element.getAttribute('data-index')));
}
break;
case 'End':
element = this.node.lastChild;
if (element) {
element.focus();
this.props.onChange(Boolean(element.getAttribute('data-index')));
}
break;
}
}
handleClick = e => {
const value = Boolean(e.currentTarget.getAttribute('data-index'));
e.preventDefault();
this.props.onClose();
this.props.onChange(value);
}
componentDidMount () {
document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
if (this.focusedItem) this.focusedItem.focus();
this.setState({ mounted: true });
}
componentWillUnmount () {
document.removeEventListener('click', this.handleDocumentClick, false);
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
setRef = c => {
this.node = c;
}
setFocusRef = c => {
this.focusedItem = c;
}
render () {
const { mounted } = this.state;
const { style, items, value } = this.props;
return (
<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 }) }}>
{({ opacity, scaleX, scaleY }) => (
// It should not be transformed when mounting because the resulting
// size will be used to determine the coordinate of the menu by
// react-overlays
<div className='privacy-dropdown__dropdown' style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} role='listbox' ref={this.setRef}>
{items.map(item => (
<div role='option' tabIndex='0' key={item.value} data-index={item.value ? item.value : undefined} 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}>
<div className='privacy-dropdown__option__icon'>
<i className={`fa fa-fw fa-${item.icon}`} />
</div>
<div className='privacy-dropdown__option__content'>
<strong>{item.text}</strong>
{item.meta}
</div>
</div>
))}
</div>
)}
</Motion>
);
}
}
@injectIntl
export default class FederationDropdown extends React.PureComponent {
static propTypes = {
isUserTouching: PropTypes.func,
isModalOpen: PropTypes.bool.isRequired,
onModalOpen: PropTypes.func,
onModalClose: PropTypes.func,
value: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
state = {
open: false,
placement: null,
};
handleToggle = ({ target }) => {
if (this.props.isUserTouching()) {
if (this.state.open) {
this.props.onModalClose();
} else {
this.props.onModalOpen({
actions: this.options.map(option => ({ ...option, active: option.value === this.props.value })),
onClick: this.handleModalActionClick,
});
}
} else {
const { top } = target.getBoundingClientRect();
this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' });
this.setState({ open: !this.state.open });
}
}
handleModalActionClick = (e) => {
e.preventDefault();
const { value } = this.options[Boolean(e.currentTarget.getAttribute('data-index'))];
this.props.onModalClose();
this.props.onChange(value);
}
handleKeyDown = e => {
switch(e.key) {
case 'Escape':
this.handleClose();
break;
}
}
handleClose = () => {
this.setState({ open: false });
}
handleChange = value => {
this.props.onChange(value);
}
componentWillMount () {
const { intl: { formatMessage } } = this.props;
this.options = [
{ icon: 'link', value: true, text: formatMessage(messages.federate_short), meta: formatMessage(messages.federate_long) },
{ icon: 'chain-broken', value: false, text: formatMessage(messages.local_only_short), meta: formatMessage(messages.local_only_long) },
];
}
render () {
const { value, intl } = this.props;
const { open, placement } = this.state;
const valueOption = this.options.find(item => item.value === value);
return (
<div className={classNames('privacy-dropdown', { active: open })} onKeyDown={this.handleKeyDown}>
<div className={classNames('privacy-dropdown__value', { active: this.options.indexOf(valueOption) === 0 })}>
<IconButton
className='privacy-dropdown__value-icon'
icon={valueOption.icon}
title={intl.formatMessage(messages.change_federation)}
size={18}
expanded={open}
active={open}
inverted
onClick={this.handleToggle}
style={{ height: null, lineHeight: '27px' }}
/>
</div>
<Overlay show={open} placement={placement} target={this}>
<FederationDropdownMenu
items={this.options}
value={value}
onClose={this.handleClose}
onChange={this.handleChange}
/>
</Overlay>
</div>
);
}
}

View File

@ -18,6 +18,7 @@ const mapStateToProps = state => ({
spoiler: state.getIn(['compose', 'spoiler']),
spoiler_text: state.getIn(['compose', 'spoiler_text']),
privacy: state.getIn(['compose', 'privacy']),
federation: state.getIn(['compose', 'federation']),
focusDate: state.getIn(['compose', 'focusDate']),
caretPosition: state.getIn(['compose', 'caretPosition']),
preselectDate: state.getIn(['compose', 'preselectDate']),

View File

@ -0,0 +1,24 @@
import { connect } from 'react-redux';
import FederationDropdown from '../components/federation_dropdown';
import { changeComposeFederation } from '../../../actions/compose';
import { openModal, closeModal } from '../../../actions/modal';
import { isUserTouching } from '../../../is_mobile';
const mapStateToProps = state => ({
isModalOpen: state.get('modal').modalType === 'ACTIONS',
value: state.getIn(['compose', 'federation']),
});
const mapDispatchToProps = dispatch => ({
onChange (value) {
dispatch(changeComposeFederation(value));
},
isUserTouching,
onModalOpen: props => dispatch(openModal('ACTIONS', props)),
onModalClose: () => dispatch(closeModal()),
});
export default connect(mapStateToProps, mapDispatchToProps)(FederationDropdown);

View File

@ -23,6 +23,7 @@ import {
COMPOSE_SPOILERNESS_CHANGE,
COMPOSE_SPOILER_TEXT_CHANGE,
COMPOSE_VISIBILITY_CHANGE,
COMPOSE_FEDERATION_CHANGE,
COMPOSE_COMPOSING_CHANGE,
COMPOSE_EMOJI_INSERT,
COMPOSE_UPLOAD_CHANGE_REQUEST,
@ -44,6 +45,7 @@ const initialState = ImmutableMap({
spoiler: false,
spoiler_text: '',
privacy: null,
federation: null,
text: '',
focusDate: null,
caretPosition: null,
@ -57,6 +59,7 @@ const initialState = ImmutableMap({
suggestion_token: null,
suggestions: ImmutableList(),
default_privacy: 'public',
default_federation: true,
default_sensitive: false,
resetFileKey: Math.floor((Math.random() * 0x10000)),
idempotencyKey: null,
@ -81,6 +84,7 @@ function clearAll(state) {
map.set('is_submitting', false);
map.set('in_reply_to', null);
map.set('privacy', state.get('default_privacy'));
map.set('federation', state.get('default_federation'));
map.set('sensitive', false);
map.update('media_attachments', list => list.clear());
map.set('idempotencyKey', uuid());
@ -209,6 +213,10 @@ export default function compose(state = initialState, action) {
return state
.set('spoiler_text', action.text)
.set('idempotencyKey', uuid());
case COMPOSE_FEDERATION_CHANGE:
return state
.set('federation', action.value)
.set('idempotencyKey', uuid());
case COMPOSE_VISIBILITY_CHANGE:
return state
.set('privacy', action.value)
@ -224,6 +232,7 @@ export default function compose(state = initialState, action) {
map.set('in_reply_to', action.status.get('id'));
map.set('text', statusToTextMentions(state, action.status));
map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy')));
map.set('federation', !action.status.get('local_only'));
map.set('focusDate', new Date());
map.set('caretPosition', null);
map.set('preselectDate', new Date());
@ -245,6 +254,7 @@ export default function compose(state = initialState, action) {
map.set('spoiler', false);
map.set('spoiler_text', '');
map.set('privacy', state.get('default_privacy'));
map.set('federation', state.get('default_federation'));
map.set('idempotencyKey', uuid());
});
case COMPOSE_SUBMIT_REQUEST:
@ -313,6 +323,7 @@ export default function compose(state = initialState, action) {
map.set('text', unescapeHTML(expandMentions(action.status)));
map.set('in_reply_to', action.status.get('in_reply_to_id'));
map.set('privacy', action.status.get('visibility'));
map.set('federation', !action.status.get('local_only'));
map.set('media_attachments', action.status.get('media_attachments'));
map.set('focusDate', new Date());
map.set('caretPosition', null);