forked from berserker/microblog
parent
fc884d015a
commit
924ffe81d4
64 changed files with 2588 additions and 2002 deletions
@ -1,42 +0,0 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import unicodeMapping from 'flavours/glitch/util/emoji/emoji_unicode_mapping_light'; |
||||
|
||||
const assetHost = process.env.CDN_HOST || ''; |
||||
|
||||
export default class AutosuggestEmoji extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
emoji: PropTypes.object.isRequired, |
||||
}; |
||||
|
||||
render () { |
||||
const { emoji } = this.props; |
||||
let url; |
||||
|
||||
if (emoji.custom) { |
||||
url = emoji.imageUrl; |
||||
} else { |
||||
const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')]; |
||||
|
||||
if (!mapping) { |
||||
return null; |
||||
} |
||||
|
||||
url = `${assetHost}/emoji/${mapping.filename}.svg`; |
||||
} |
||||
|
||||
return ( |
||||
<div className='autosuggest-emoji'> |
||||
<img |
||||
className='emojione' |
||||
src={url} |
||||
alt={emoji.native || emoji.colons} |
||||
/> |
||||
|
||||
{emoji.colons} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -1,223 +0,0 @@ |
||||
import React from 'react'; |
||||
import AutosuggestAccountContainer from 'flavours/glitch/features/compose/containers/autosuggest_account_container'; |
||||
import AutosuggestEmoji from './autosuggest_emoji'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import PropTypes from 'prop-types'; |
||||
import { isRtl } from 'flavours/glitch/util/rtl'; |
||||
import ImmutablePureComponent from 'react-immutable-pure-component'; |
||||
import Textarea from 'react-textarea-autosize'; |
||||
import classNames from 'classnames'; |
||||
|
||||
const textAtCursorMatchesToken = (str, caretPosition) => { |
||||
let word; |
||||
|
||||
let left = str.slice(0, caretPosition).search(/[^\s\u200B]+$/); |
||||
let right = str.slice(caretPosition).search(/[\s\u200B]/); |
||||
|
||||
if (right < 0) { |
||||
word = str.slice(left); |
||||
} else { |
||||
word = str.slice(left, right + caretPosition); |
||||
} |
||||
|
||||
if (!word || word.trim().length < 3 || ['@', ':'].indexOf(word[0]) === -1) { |
||||
return [null, null]; |
||||
} |
||||
|
||||
word = word.trim().toLowerCase(); |
||||
|
||||
if (word.length > 0) { |
||||
return [left + 1, word]; |
||||
} else { |
||||
return [null, null]; |
||||
} |
||||
}; |
||||
|
||||
export default class AutosuggestTextarea extends ImmutablePureComponent { |
||||
|
||||
static propTypes = { |
||||
value: PropTypes.string, |
||||
suggestions: ImmutablePropTypes.list, |
||||
disabled: PropTypes.bool, |
||||
placeholder: PropTypes.string, |
||||
onSuggestionSelected: PropTypes.func.isRequired, |
||||
onSuggestionsClearRequested: PropTypes.func.isRequired, |
||||
onSuggestionsFetchRequested: PropTypes.func.isRequired, |
||||
onChange: PropTypes.func.isRequired, |
||||
onKeyUp: PropTypes.func, |
||||
onKeyDown: PropTypes.func, |
||||
onPaste: PropTypes.func.isRequired, |
||||
autoFocus: PropTypes.bool, |
||||
}; |
||||
|
||||
static defaultProps = { |
||||
autoFocus: true, |
||||
}; |
||||
|
||||
state = { |
||||
suggestionsHidden: false, |
||||
selectedSuggestion: 0, |
||||
lastToken: null, |
||||
tokenStart: 0, |
||||
}; |
||||
|
||||
onChange = (e) => { |
||||
const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart); |
||||
|
||||
if (token !== null && this.state.lastToken !== token) { |
||||
this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart }); |
||||
this.props.onSuggestionsFetchRequested(token); |
||||
} else if (token === null) { |
||||
this.setState({ lastToken: null }); |
||||
this.props.onSuggestionsClearRequested(); |
||||
} |
||||
|
||||
this.props.onChange(e); |
||||
} |
||||
|
||||
onKeyDown = (e) => { |
||||
const { suggestions, disabled } = this.props; |
||||
const { selectedSuggestion, suggestionsHidden } = this.state; |
||||
|
||||
if (disabled) { |
||||
e.preventDefault(); |
||||
return; |
||||
} |
||||
|
||||
switch(e.key) { |
||||
case 'Escape': |
||||
if (!suggestionsHidden) { |
||||
e.preventDefault(); |
||||
this.setState({ suggestionsHidden: true }); |
||||
} |
||||
|
||||
break; |
||||
case 'ArrowDown': |
||||
if (suggestions.size > 0 && !suggestionsHidden) { |
||||
e.preventDefault(); |
||||
this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) }); |
||||
} |
||||
|
||||
break; |
||||
case 'ArrowUp': |
||||
if (suggestions.size > 0 && !suggestionsHidden) { |
||||
e.preventDefault(); |
||||
this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) }); |
||||
} |
||||
|
||||
break; |
||||
case 'Enter': |
||||
case 'Tab': |
||||
// Select suggestion
|
||||
if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) { |
||||
e.preventDefault(); |
||||
e.stopPropagation(); |
||||
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion)); |
||||
} |
||||
|
||||
break; |
||||
} |
||||
|
||||
if (e.defaultPrevented || !this.props.onKeyDown) { |
||||
return; |
||||
} |
||||
|
||||
this.props.onKeyDown(e); |
||||
} |
||||
|
||||
onKeyUp = e => { |
||||
if (e.key === 'Escape' && this.state.suggestionsHidden) { |
||||
document.querySelector('.ui').parentElement.focus(); |
||||
} |
||||
|
||||
if (this.props.onKeyUp) { |
||||
this.props.onKeyUp(e); |
||||
} |
||||
} |
||||
|
||||
onBlur = () => { |
||||
this.setState({ suggestionsHidden: true }); |
||||
} |
||||
|
||||
onSuggestionClick = (e) => { |
||||
const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index')); |
||||
e.preventDefault(); |
||||
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion); |
||||
this.textarea.focus(); |
||||
} |
||||
|
||||
componentWillReceiveProps (nextProps) { |
||||
if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden) { |
||||
this.setState({ suggestionsHidden: false }); |
||||
} |
||||
} |
||||
|
||||
setTextarea = (c) => { |
||||
this.textarea = c; |
||||
} |
||||
|
||||
onPaste = (e) => { |
||||
if (e.clipboardData && e.clipboardData.files.length === 1) { |
||||
this.props.onPaste(e.clipboardData.files); |
||||
e.preventDefault(); |
||||
} |
||||
} |
||||
|
||||
renderSuggestion = (suggestion, i) => { |
||||
const { selectedSuggestion } = this.state; |
||||
let inner, key; |
||||
|
||||
if (typeof suggestion === 'object') { |
||||
inner = <AutosuggestEmoji emoji={suggestion} />; |
||||
key = suggestion.id; |
||||
} else { |
||||
inner = <AutosuggestAccountContainer id={suggestion} />; |
||||
key = suggestion; |
||||
} |
||||
|
||||
return ( |
||||
<div role='button' tabIndex='0' key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}> |
||||
{inner} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
render () { |
||||
const { value, suggestions, disabled, placeholder, autoFocus } = this.props; |
||||
const { suggestionsHidden } = this.state; |
||||
const style = { direction: 'ltr' }; |
||||
|
||||
if (isRtl(value)) { |
||||
style.direction = 'rtl'; |
||||
} |
||||
|
||||
return ( |
||||
<div className='autosuggest-textarea'> |
||||
<label> |
||||
<span style={{ display: 'none' }}>{placeholder}</span> |
||||
|
||||
<Textarea |
||||
inputRef={this.setTextarea} |
||||
className='autosuggest-textarea__textarea' |
||||
disabled={disabled} |
||||
placeholder={placeholder} |
||||
autoFocus={autoFocus} |
||||
value={value} |
||||
onChange={this.onChange} |
||||
onKeyDown={this.onKeyDown} |
||||
onKeyUp={this.onKeyUp} |
||||
onBlur={this.onBlur} |
||||
onPaste={this.onPaste} |
||||
style={style} |
||||
aria-autocomplete='list' |
||||
/> |
||||
</label> |
||||
|
||||
<div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}> |
||||
{suggestions.map(this.renderSuggestion)} |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,26 @@ |
||||
// Package imports.
|
||||
import classNames from 'classnames'; |
||||
import PropTypes from 'prop-types'; |
||||
import React from 'react'; |
||||
|
||||
// This just renders a FontAwesome icon.
|
||||
export default function Icon ({ |
||||
className, |
||||
fullwidth, |
||||
icon, |
||||
}) { |
||||
const computedClass = classNames('icon', 'fa', { 'fa-fw': fullwidth }, `fa-${icon}`, className); |
||||
return icon ? ( |
||||
<span |
||||
aria-hidden='true' |
||||
className={computedClass} |
||||
/> |
||||
) : null; |
||||
} |
||||
|
||||
// Props.
|
||||
Icon.propTypes = { |
||||
className: PropTypes.string, |
||||
fullwidth: PropTypes.bool, |
||||
icon: PropTypes.string, |
||||
}; |
@ -1,62 +0,0 @@ |
||||
// Package imports.
|
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import { injectIntl, defineMessages } from 'react-intl'; |
||||
|
||||
// Our imports.
|
||||
import ComposeAdvancedOptionsToggle from './advanced_options_toggle'; |
||||
import ComposeDropdown from './dropdown'; |
||||
|
||||
const messages = defineMessages({ |
||||
local_only_short : |
||||
{ id: 'advanced-options.local-only.short', defaultMessage: 'Local-only' }, |
||||
local_only_long : |
||||
{ id: 'advanced-options.local-only.long', defaultMessage: 'Do not post to other instances' }, |
||||
advanced_options_icon_title : |
||||
{ id: 'advanced_options.icon_title', defaultMessage: 'Advanced options' }, |
||||
}); |
||||
|
||||
@injectIntl |
||||
export default class ComposeAdvancedOptions extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
values : ImmutablePropTypes.contains({ |
||||
do_not_federate : PropTypes.bool.isRequired, |
||||
}).isRequired, |
||||
onChange : PropTypes.func.isRequired, |
||||
intl : PropTypes.object.isRequired, |
||||
}; |
||||
|
||||
render () { |
||||
const { intl, values } = this.props; |
||||
const options = [ |
||||
{ icon: 'wifi', shortText: messages.local_only_short, longText: messages.local_only_long, name: 'do_not_federate' }, |
||||
]; |
||||
const anyEnabled = values.some((enabled) => enabled); |
||||
|
||||
const optionElems = options.map((option) => { |
||||
return ( |
||||
<ComposeAdvancedOptionsToggle |
||||
onChange={this.props.onChange} |
||||
active={values.get(option.name)} |
||||
key={option.name} |
||||
name={option.name} |
||||
shortText={intl.formatMessage(option.shortText)} |
||||
longText={intl.formatMessage(option.longText)} |
||||
/> |
||||
); |
||||
}); |
||||
|
||||
return ( |
||||
<ComposeDropdown |
||||
title={intl.formatMessage(messages.advanced_options_icon_title)} |
||||
icon='home' |
||||
highlight={anyEnabled} |
||||
> |
||||
{optionElems} |
||||
</ComposeDropdown> |
||||
); |
||||
} |
||||
|
||||
} |
@ -1,35 +0,0 @@ |
||||
// Package imports.
|
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import Toggle from 'react-toggle'; |
||||
|
||||
export default class ComposeAdvancedOptionsToggle extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
onChange: PropTypes.func.isRequired, |
||||
active: PropTypes.bool.isRequired, |
||||
name: PropTypes.string.isRequired, |
||||
shortText: PropTypes.string.isRequired, |
||||
longText: PropTypes.string.isRequired, |
||||
} |
||||
|
||||
onToggle = () => { |
||||
this.props.onChange(this.props.name); |
||||
} |
||||
|
||||
render() { |
||||
const { active, shortText, longText } = this.props; |
||||
return ( |
||||
<div role='button' tabIndex='0' className='advanced-options-dropdown__option' onClick={this.onToggle}> |
||||
<div className='advanced-options-dropdown__option__toggle'> |
||||
<Toggle checked={active} onChange={this.onToggle} /> |
||||
</div> |
||||
<div className='advanced-options-dropdown__option__content'> |
||||
<strong>{shortText}</strong> |
||||
{longText} |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -1,131 +0,0 @@ |
||||
// Package imports //
|
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import { connect } from 'react-redux'; |
||||
import { injectIntl, defineMessages } from 'react-intl'; |
||||
|
||||
// Our imports //
|
||||
import ComposeDropdown from './dropdown'; |
||||
import { uploadCompose } from 'flavours/glitch/actions/compose'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import ImmutablePureComponent from 'react-immutable-pure-component'; |
||||
import { openModal } from 'flavours/glitch/actions/modal'; |
||||
|
||||
const messages = defineMessages({ |
||||
upload : |
||||
{ id: 'compose.attach.upload', defaultMessage: 'Upload a file' }, |
||||
doodle : |
||||
{ id: 'compose.attach.doodle', defaultMessage: 'Draw something' }, |
||||
attach : |
||||
{ id: 'compose.attach', defaultMessage: 'Attach...' }, |
||||
}); |
||||
|
||||
const mapStateToProps = state => ({ |
||||
// This horrible expression is copied from vanilla upload_button_container
|
||||
disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 3 || state.getIn(['compose', 'media_attachments']).some(m => m.get('type') === 'video')), |
||||
resetFileKey: state.getIn(['compose', 'resetFileKey']), |
||||
acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']), |
||||
}); |
||||
|
||||
const mapDispatchToProps = dispatch => ({ |
||||
onSelectFile (files) { |
||||
dispatch(uploadCompose(files)); |
||||
}, |
||||
onOpenDoodle () { |
||||
dispatch(openModal('DOODLE', { noEsc: true })); |
||||
}, |
||||
}); |
||||
|
||||
@injectIntl |
||||
@connect(mapStateToProps, mapDispatchToProps) |
||||
export default class ComposeAttachOptions extends ImmutablePureComponent { |
||||
|
||||
static propTypes = { |
||||
intl : PropTypes.object.isRequired, |
||||
resetFileKey: PropTypes.number, |
||||
acceptContentTypes: ImmutablePropTypes.listOf(PropTypes.string).isRequired, |
||||
disabled: PropTypes.bool, |
||||
onSelectFile: PropTypes.func.isRequired, |
||||
onOpenDoodle: PropTypes.func.isRequired, |
||||
}; |
||||
|
||||
handleItemClick = bt => { |
||||
if (bt === 'upload') { |
||||
this.fileElement.click(); |
||||
} |
||||
|
||||
if (bt === 'doodle') { |
||||
this.props.onOpenDoodle(); |
||||
} |
||||
|
||||
this.dropdown.setState({ open: false }); |
||||
}; |
||||
|
||||
handleFileChange = (e) => { |
||||
if (e.target.files.length > 0) { |
||||
this.props.onSelectFile(e.target.files); |
||||
} |
||||
} |
||||
|
||||
setFileRef = (c) => { |
||||
this.fileElement = c; |
||||
} |
||||
|
||||
setDropdownRef = (c) => { |
||||
this.dropdown = c; |
||||
} |
||||
|
||||
render () { |
||||
const { intl, resetFileKey, disabled, acceptContentTypes } = this.props; |
||||
|
||||
const options = [ |
||||
{ icon: 'cloud-upload', text: messages.upload, name: 'upload' }, |
||||
{ icon: 'paint-brush', text: messages.doodle, name: 'doodle' }, |
||||
]; |
||||
|
||||
const optionElems = options.map((item) => { |
||||
const hdl = () => this.handleItemClick(item.name); |
||||
return ( |
||||
<div |
||||
role='button' |
||||
tabIndex='0' |
||||
key={item.name} |
||||
onClick={hdl} |
||||
className='privacy-dropdown__option' |
||||
> |
||||
<div className='privacy-dropdown__option__icon'> |
||||
<i className={`fa fa-fw fa-${item.icon}`} /> |
||||
</div> |
||||
|
||||
<div className='privacy-dropdown__option__content'> |
||||
<strong>{intl.formatMessage(item.text)}</strong> |
||||
</div> |
||||
</div> |
||||
); |
||||
}); |
||||
|
||||
return ( |
||||
<div> |
||||
<ComposeDropdown |
||||
title={intl.formatMessage(messages.attach)} |
||||
icon='paperclip' |
||||
disabled={disabled} |
||||
ref={this.setDropdownRef} |
||||
> |
||||
{optionElems} |
||||
</ComposeDropdown> |
||||
<input |
||||
key={resetFileKey} |
||||
ref={this.setFileRef} |
||||
type='file' |
||||
multiple={false} |
||||
accept={acceptContentTypes.toArray().join(',')} |
||||
onChange={this.handleFileChange} |
||||
disabled={disabled} |
||||
style={{ display: 'none' }} |
||||
/> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -1,24 +0,0 @@ |
||||
import React from 'react'; |
||||
import Avatar from 'flavours/glitch/components/avatar'; |
||||
import DisplayName from 'flavours/glitch/components/display_name'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import ImmutablePureComponent from 'react-immutable-pure-component'; |
||||
|
||||
export default class AutosuggestAccount extends ImmutablePureComponent { |
||||
|
||||
static propTypes = { |
||||
account: ImmutablePropTypes.map.isRequired, |
||||
}; |
||||
|
||||
render () { |
||||
const { account } = this.props; |
||||
|
||||
return ( |
||||
<div className='autosuggest-account'> |
||||
<div className='autosuggest-account-icon'><Avatar account={account} size={18} /></div> |
||||
<DisplayName account={account} /> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -1,25 +0,0 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import { length } from 'stringz'; |
||||
|
||||
export default class CharacterCounter extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
text: PropTypes.string.isRequired, |
||||
max: PropTypes.number.isRequired, |
||||
}; |
||||
|
||||
checkRemainingText (diff) { |
||||
if (diff < 0) { |
||||
return <span className='character-counter character-counter--over'>{diff}</span>; |
||||
} |
||||
|
||||
return <span className='character-counter'>{diff}</span>; |
||||
} |
||||
|
||||
render () { |
||||
const diff = this.props.max - length(this.props.text); |
||||
return this.checkRemainingText(diff); |
||||
} |
||||
|
||||
} |
@ -1,77 +0,0 @@ |
||||
// Package imports.
|
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
|
||||
// Our imports.
|
||||
import IconButton from 'flavours/glitch/components/icon_button'; |
||||
|
||||
const iconStyle = { |
||||
height : null, |
||||
lineHeight : '27px', |
||||
}; |
||||
|
||||
export default class ComposeDropdown extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
title: PropTypes.string.isRequired, |
||||
icon: PropTypes.string, |
||||
highlight: PropTypes.bool, |
||||
disabled: PropTypes.bool, |
||||
children: PropTypes.arrayOf(PropTypes.node).isRequired, |
||||
}; |
||||
|
||||
state = { |
||||
open: false, |
||||
}; |
||||
|
||||
onGlobalClick = (e) => { |
||||
if (e.target !== this.node && !this.node.contains(e.target) && this.state.open) { |
||||
this.setState({ open: false }); |
||||
} |
||||
}; |
||||
|
||||
componentDidMount () { |
||||
window.addEventListener('click', this.onGlobalClick); |
||||
window.addEventListener('touchstart', this.onGlobalClick); |
||||
} |
||||
componentWillUnmount () { |
||||
window.removeEventListener('click', this.onGlobalClick); |
||||
window.removeEventListener('touchstart', this.onGlobalClick); |
||||
} |
||||
|
||||
onToggleDropdown = () => { |
||||
if (this.props.disabled) return; |
||||
this.setState({ open: !this.state.open }); |
||||
}; |
||||
|
||||
setRef = (c) => { |
||||
this.node = c; |
||||
}; |
||||
|
||||
render () { |
||||
const { open } = this.state; |
||||
let { highlight, title, icon, disabled } = this.props; |
||||
|
||||
if (!icon) icon = 'ellipsis-h'; |
||||
|
||||
return ( |
||||
<div ref={this.setRef} className={`advanced-options-dropdown ${open ? 'open' : ''} ${highlight ? 'active' : ''} `}> |
||||
<div className='advanced-options-dropdown__value'> |
||||
<IconButton |
||||
className={'inverted'} |
||||
title={title} |
||||
icon={icon} active={open || highlight} |
||||
size={18} |
||||
style={iconStyle} |
||||
disabled={disabled} |
||||
onClick={this.onToggleDropdown} |
||||
/> |
||||
</div> |
||||
<div className='advanced-options-dropdown__dropdown'> |
||||
{this.props.children} |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -1,200 +0,0 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import { injectIntl, defineMessages } from 'react-intl'; |
||||
import IconButton from 'flavours/glitch/components/icon_button'; |
||||
import Overlay from 'react-overlays/lib/Overlay'; |
||||
import Motion from 'flavours/glitch/util/optional_motion'; |
||||
import spring from 'react-motion/lib/spring'; |
||||
import detectPassiveEvents from 'detect-passive-events'; |
||||
import classNames from 'classnames'; |
||||
|
||||
const messages = defineMessages({ |
||||
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' }, |
||||
public_long: { id: 'privacy.public.long', defaultMessage: 'Post to public timelines' }, |
||||
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, |
||||
unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Do not show in public timelines' }, |
||||
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' }, |
||||
private_long: { id: 'privacy.private.long', defaultMessage: 'Post to followers only' }, |
||||
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' }, |
||||
direct_long: { id: 'privacy.direct.long', defaultMessage: 'Post to mentioned users only' }, |
||||
change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' }, |
||||
}); |
||||
|
||||
const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false; |
||||
|
||||
class PrivacyDropdownMenu extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
style: PropTypes.object, |
||||
items: PropTypes.array.isRequired, |
||||
value: PropTypes.string.isRequired, |
||||
onClose: PropTypes.func.isRequired, |
||||
onChange: PropTypes.func.isRequired, |
||||
}; |
||||
|
||||
handleDocumentClick = e => { |
||||
if (this.node && !this.node.contains(e.target)) { |
||||
this.props.onClose(); |
||||
} |
||||
} |
||||
|
||||
handleClick = e => { |
||||
if (e.key === 'Escape') { |
||||
this.props.onClose(); |
||||
} else if (!e.key || e.key === 'Enter') { |
||||
const value = 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); |
||||
} |
||||
|
||||
componentWillUnmount () { |
||||
document.removeEventListener('click', this.handleDocumentClick, false); |
||||
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); |
||||
} |
||||
|
||||
setRef = c => { |
||||
this.node = c; |
||||
} |
||||
|
||||
render () { |
||||
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 }) => ( |
||||
<div className='privacy-dropdown__dropdown' style={{ ...style, opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }} ref={this.setRef}> |
||||
{items.map(item => |
||||
<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 })}> |
||||
<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 PrivacyDropdown extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
isUserTouching: PropTypes.func, |
||||
isModalOpen: PropTypes.bool.isRequired, |
||||
onModalOpen: PropTypes.func, |
||||
onModalClose: PropTypes.func, |
||||
value: PropTypes.string.isRequired, |
||||
onChange: PropTypes.func.isRequired, |
||||
intl: PropTypes.object.isRequired, |
||||
}; |
||||
|
||||
state = { |
||||
open: false, |
||||
}; |
||||
|
||||
handleToggle = () => { |
||||
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 { |
||||
this.setState({ open: !this.state.open }); |
||||
} |
||||
} |
||||
|
||||
handleModalActionClick = (e) => { |
||||
e.preventDefault(); |
||||
|
||||
const { value } = this.options[e.currentTarget.getAttribute('data-index')]; |
||||
|
||||
this.props.onModalClose(); |
||||
this.props.onChange(value); |
||||
} |
||||
|
||||
handleKeyDown = e => { |
||||
switch(e.key) { |
||||
case 'Enter': |
||||
this.handleToggle(); |
||||
break; |
||||
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: 'globe', value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) }, |
||||
{ icon: 'unlock-alt', value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long) }, |
||||
{ icon: 'lock', value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) }, |
||||
{ icon: 'envelope', value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) }, |
||||
]; |
||||
} |
||||
|
||||
render () { |
||||
const { value, intl } = this.props; |
||||
const { open } = 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_privacy)} |
||||
size={18} |
||||
expanded={open} |
||||
active={open} |
||||
inverted |
||||
onClick={this.handleToggle} |
||||
style={{ height: null, lineHeight: '27px' }} |
||||
/> |
||||
</div> |
||||
|
||||
<Overlay show={open} placement='bottom' target={this}> |
||||
<PrivacyDropdownMenu |
||||
items={this.options} |
||||
value={value} |
||||
onClose={this.handleClose} |
||||
onChange={this.handleChange} |
||||
/> |
||||
</Overlay> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -1,67 +0,0 @@ |
||||
import React from 'react'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import PropTypes from 'prop-types'; |
||||
import Avatar from 'flavours/glitch/components/avatar'; |
||||
import IconButton from 'flavours/glitch/components/icon_button'; |
||||
import DisplayName from 'flavours/glitch/components/display_name'; |
||||
import { defineMessages, injectIntl } from 'react-intl'; |
||||
import ImmutablePureComponent from 'react-immutable-pure-component'; |
||||
import { isRtl } from 'flavours/glitch/util/rtl'; |
||||
|
||||
const messages = defineMessages({ |
||||
cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' }, |
||||
}); |
||||
|
||||
@injectIntl |
||||
export default class ReplyIndicator extends ImmutablePureComponent { |
||||
|
||||
static contextTypes = { |
||||
router: PropTypes.object, |
||||
}; |
||||
|
||||
static propTypes = { |
||||
status: ImmutablePropTypes.map, |
||||
onCancel: PropTypes.func.isRequired, |
||||
intl: PropTypes.object.isRequired, |
||||
}; |
||||
|
||||
handleClick = () => { |
||||
this.props.onCancel(); |
||||
} |
||||
|
||||
handleAccountClick = (e) => { |
||||
if (e.button === 0) { |
||||
e.preventDefault(); |
||||
this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`); |
||||
} |
||||
} |
||||
|
||||
render () { |
||||
const { status, intl } = this.props; |
||||
|
||||
if (!status) { |
||||
return null; |
||||
} |
||||
|
||||
const content = { __html: status.get('contentHtml') }; |
||||
const style = { |
||||
direction: isRtl(status.get('search_index')) ? 'rtl' : 'ltr', |
||||
}; |
||||
|
||||
return ( |
||||
<div className='reply-indicator'> |
||||
<div className='reply-indicator__header'> |
||||
<div className='reply-indicator__cancel'><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} /></div> |
||||
|
||||
<a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name'> |
||||
<div className='reply-indicator__display-avatar'><Avatar account={status.get('account')} size={24} /></div> |
||||
<DisplayName account={status.get('account')} /> |
||||
</a> |
||||
</div> |
||||
|
||||
<div className='reply-indicator__content' style={style} dangerouslySetInnerHTML={content} /> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -1,96 +0,0 @@ |
||||
import React from 'react'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import PropTypes from 'prop-types'; |
||||
import IconButton from 'flavours/glitch/components/icon_button'; |
||||
import Motion from 'flavours/glitch/util/optional_motion'; |
||||
import spring from 'react-motion/lib/spring'; |
||||
import ImmutablePureComponent from 'react-immutable-pure-component'; |
||||
import { defineMessages, injectIntl } from 'react-intl'; |
||||
import classNames from 'classnames'; |
||||
|
||||
const messages = defineMessages({ |
||||
undo: { id: 'upload_form.undo', defaultMessage: 'Undo' }, |
||||
description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' }, |
||||
}); |
||||
|
||||
@injectIntl |
||||
export default class Upload extends ImmutablePureComponent { |
||||
|
||||
static propTypes = { |
||||
media: ImmutablePropTypes.map.isRequired, |
||||
intl: PropTypes.object.isRequired, |
||||
onUndo: PropTypes.func.isRequired, |
||||
onDescriptionChange: PropTypes.func.isRequired, |
||||
}; |
||||
|
||||
state = { |
||||
hovered: false, |
||||
focused: false, |
||||
dirtyDescription: null, |
||||
}; |
||||
|
||||
handleUndoClick = () => { |
||||
this.props.onUndo(this.props.media.get('id')); |
||||
} |
||||
|
||||
handleInputChange = e => { |
||||
this.setState({ dirtyDescription: e.target.value }); |
||||
} |
||||
|
||||
handleMouseEnter = () => { |
||||
this.setState({ hovered: true }); |
||||
} |
||||
|
||||
handleMouseLeave = () => { |
||||
this.setState({ hovered: false }); |
||||
} |
||||
|
||||
handleInputFocus = () => { |
||||
this.setState({ focused: true }); |
||||
} |
||||
|
||||
handleInputBlur = () => { |
||||
const { dirtyDescription } = this.state; |
||||
|
||||
this.setState({ focused: false, dirtyDescription: null }); |
||||
|
||||
if (dirtyDescription !== null) { |
||||
this.props.onDescriptionChange(this.props.media.get('id'), dirtyDescription); |
||||
} |
||||
} |
||||
|
||||
render () { |
||||
const { intl, media } = this.props; |
||||
const active = this.state.hovered || this.state.focused; |
||||
const description = this.state.dirtyDescription || media.get('description') || ''; |
||||
|
||||
return ( |
||||
<div className='compose-form__upload' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> |
||||
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}> |
||||
{({ scale }) => ( |
||||
<div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})` }}> |
||||
<IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.handleUndoClick} /> |
||||
|
||||
<div className={classNames('compose-form__upload-description', { active })}> |
||||
<label> |
||||
<span style={{ display: 'none' }}>{intl.formatMessage(messages.description)}</span> |
||||
|
||||
<input |
||||
placeholder={intl.formatMessage(messages.description)} |
||||
type='text' |
||||
value={description} |
||||
maxLength={420} |
||||
onFocus={this.handleInputFocus} |
||||
onChange={this.handleInputChange} |
||||
onBlur={this.handleInputBlur} |
||||
/> |
||||
</label> |
||||
</div> |
||||
</div> |
||||
)} |
||||
</Motion> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -1,77 +0,0 @@ |
||||
import React from 'react'; |
||||
import IconButton from 'flavours/glitch/components/icon_button'; |
||||
import PropTypes from 'prop-types'; |
||||
import { defineMessages, injectIntl } from 'react-intl'; |
||||
import { connect } from 'react-redux'; |
||||
import ImmutablePureComponent from 'react-immutable-pure-component'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
|
||||
const messages = defineMessages({ |
||||
upload: { id: 'upload_button.label', defaultMessage: 'Add media' }, |
||||
}); |
||||
|
||||
const makeMapStateToProps = () => { |
||||
const mapStateToProps = state => ({ |
||||
acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']), |
||||
}); |
||||
|
||||
return mapStateToProps; |
||||
}; |
||||
|
||||
const iconStyle = { |
||||
height: null, |
||||
lineHeight: '27px', |
||||
}; |
||||
|
||||
@connect(makeMapStateToProps) |
||||
@injectIntl |
||||
export default class UploadButton extends ImmutablePureComponent { |
||||
|
||||
static propTypes = { |
||||
disabled: PropTypes.bool, |
||||
onSelectFile: PropTypes.func.isRequired, |
||||
style: PropTypes.object, |
||||
resetFileKey: PropTypes.number, |
||||
acceptContentTypes: ImmutablePropTypes.listOf(PropTypes.string).isRequired, |
||||
intl: PropTypes.object.isRequired, |
||||
}; |
||||
|
||||
handleChange = (e) => { |
||||
if (e.target.files.length > 0) { |
||||
this.props.onSelectFile(e.target.files); |
||||
} |
||||
} |
||||
|
||||
handleClick = () => { |
||||
this.fileElement.click(); |
||||
} |
||||
|
||||
setRef = (c) => { |
||||
this.fileElement = c; |
||||
} |
||||
|
||||
render () { |
||||
|
||||
const { intl, resetFileKey, disabled, acceptContentTypes } = this.props; |
||||
|
||||
return ( |
||||
<div className='compose-form__upload-button'> |
||||
<IconButton icon='camera' title={intl.formatMessage(messages.upload)} disabled={disabled} onClick={this.handleClick} className='compose-form__upload-button-icon' size={18} inverted style={iconStyle} /> |
||||
<label> |
||||
<span style={{ display: 'none' }}>{intl.formatMessage(messages.upload)}</span> |
||||
<input |
||||
key={resetFileKey} |
||||
ref={this.setRef} |
||||
type='file' |
||||
multiple={false} |
||||
accept={acceptContentTypes.toArray().join(',')} |
||||
onChange={this.handleChange} |
||||
disabled={disabled} |
||||
style={{ display: 'none' }} |
||||
/> |
||||
</label> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -1,29 +0,0 @@ |
||||
import React from 'react'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import UploadProgressContainer from '../containers/upload_progress_container'; |
||||
import ImmutablePureComponent from 'react-immutable-pure-component'; |
||||
import UploadContainer from '../containers/upload_container'; |
||||
|
||||
export default class UploadForm extends ImmutablePureComponent { |
||||
|
||||
static propTypes = { |
||||
mediaIds: ImmutablePropTypes.list.isRequired, |
||||
}; |
||||
|
||||
render () { |
||||
const { mediaIds } = this.props; |
||||
|
||||
return ( |
||||
<div className='compose-form__upload-wrapper'> |
||||
<UploadProgressContainer /> |
||||
|
||||
<div className='compose-form__uploads-wrapper'> |
||||
{mediaIds.map(id => ( |
||||
<UploadContainer id={id} key={id} /> |
||||
))} |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -1,42 +0,0 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import Motion from 'flavours/glitch/util/optional_motion'; |
||||
import spring from 'react-motion/lib/spring'; |
||||
import { FormattedMessage } from 'react-intl'; |
||||
|
||||
export default class UploadProgress extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
active: PropTypes.bool, |
||||
progress: PropTypes.number, |
||||
}; |
||||
|
||||
render () { |
||||
const { active, progress } = this.props; |
||||
|
||||
if (!active) { |
||||
return null; |
||||
} |
||||
|
||||
return ( |
||||
<div className='upload-progress'> |
||||
<div className='upload-progress__icon'> |
||||
<i className='fa fa-upload' /> |
||||
</div> |
||||
|
||||
<div className='upload-progress__message'> |
||||
<FormattedMessage id='upload_progress.label' defaultMessage='Uploading...' /> |
||||
|
||||
<div className='upload-progress__backdrop'> |
||||
<Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress) }}> |
||||
{({ width }) => |
||||
<div className='upload-progress__tracker' style={{ width: `${width}%` }} /> |
||||
} |
||||
</Motion> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -1,26 +0,0 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import Motion from 'flavours/glitch/util/optional_motion'; |
||||
import spring from 'react-motion/lib/spring'; |
||||
|
||||
export default class Warning extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
message: PropTypes.node.isRequired, |
||||
}; |
||||
|
||||
render () { |
||||
const { message } = 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 }) => ( |
||||
<div className='compose-form__warning' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}> |
||||
{message} |
||||
</div> |
||||
)} |
||||
</Motion> |
||||
); |
||||
} |
||||
|
||||
} |
@ -1,20 +0,0 @@ |
||||
// Package imports.
|
||||
import { connect } from 'react-redux'; |
||||
|
||||
// Our imports.
|
||||
import { toggleComposeAdvancedOption } from 'flavours/glitch/actions/compose'; |
||||
import ComposeAdvancedOptions from '../components/advanced_options'; |
||||
|
||||
const mapStateToProps = state => ({ |
||||
values: state.getIn(['compose', 'advanced_options']), |
||||
}); |
||||
|
||||
const mapDispatchToProps = dispatch => ({ |
||||
|
||||
onChange (option) { |
||||
dispatch(toggleComposeAdvancedOption(option)); |
||||
}, |
||||
|
||||
}); |
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ComposeAdvancedOptions); |
@ -1,15 +0,0 @@ |
||||
import { connect } from 'react-redux'; |
||||
import AutosuggestAccount from '../components/autosuggest_account'; |
||||
import { makeGetAccount } from 'flavours/glitch/selectors'; |
||||
|
||||
const makeMapStateToProps = () => { |
||||
const getAccount = makeGetAccount(); |
||||
|
||||
const mapStateToProps = (state, { id }) => ({ |
||||
account: getAccount(state, id), |
||||
}); |
||||
|
||||
return mapStateToProps; |
||||
}; |
||||
|
||||
export default connect(makeMapStateToProps)(AutosuggestAccount); |
@ -1,71 +0,0 @@ |
||||
import { connect } from 'react-redux'; |
||||
import ComposeForm from '../components/compose_form'; |
||||
import { changeComposeVisibility, uploadCompose } from 'flavours/glitch/actions/compose'; |
||||
import { |
||||
changeCompose, |
||||
submitCompose, |
||||
clearComposeSuggestions, |
||||
fetchComposeSuggestions, |
||||
selectComposeSuggestion, |
||||
changeComposeSpoilerText, |
||||
insertEmojiCompose, |
||||
} from 'flavours/glitch/actions/compose'; |
||||
|
||||
const mapStateToProps = state => ({ |
||||
text: state.getIn(['compose', 'text']), |
||||
suggestion_token: state.getIn(['compose', 'suggestion_token']), |
||||
suggestions: state.getIn(['compose', 'suggestions']), |
||||
advanced_options: state.getIn(['compose', 'advanced_options']), |
||||
spoiler: state.getIn(['compose', 'spoiler']), |
||||
spoiler_text: state.getIn(['compose', 'spoiler_text']), |
||||
privacy: state.getIn(['compose', 'privacy']), |
||||
focusDate: state.getIn(['compose', 'focusDate']), |
||||
preselectDate: state.getIn(['compose', 'preselectDate']), |
||||
is_submitting: state.getIn(['compose', 'is_submitting']), |
||||
is_uploading: state.getIn(['compose', 'is_uploading']), |
||||
showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']), |
||||
settings: state.get('local_settings'), |
||||
filesAttached: state.getIn(['compose', 'media_attachments']).size > 0, |
||||
}); |
||||
|
||||
const mapDispatchToProps = (dispatch) => ({ |
||||
|
||||
onChange (text) { |
||||
dispatch(changeCompose(text)); |
||||
}, |
||||
|
||||
onPrivacyChange (value) { |
||||
dispatch(changeComposeVisibility(value)); |
||||
}, |
||||
|
||||
onSubmit () { |
||||
dispatch(submitCompose()); |
||||
}, |
||||
|
||||
onClearSuggestions () { |
||||
dispatch(clearComposeSuggestions()); |
||||
}, |
||||
|
||||
onFetchSuggestions (token) { |
||||
dispatch(fetchComposeSuggestions(token)); |
||||
}, |
||||
|
||||
onSuggestionSelected (position, token, accountId) { |
||||
dispatch(selectComposeSuggestion(position, token, accountId)); |
||||
}, |
||||
|
||||
onChangeSpoilerText (checked) { |
||||
dispatch(changeComposeSpoilerText(checked)); |
||||
}, |
||||
|
||||
onPaste (files) { |
||||
dispatch(uploadCompose(files)); |
||||
}, |
||||
|
||||
onPickEmoji (position, data) { |
||||
dispatch(insertEmojiCompose(position, data)); |
||||
}, |
||||
|
||||
}); |
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ComposeForm); |
@ -1,82 +0,0 @@ |
||||
import { connect } from 'react-redux'; |
||||
import EmojiPickerDropdown from '../components/emoji_picker_dropdown'; |
||||
import { changeSetting } from 'flavours/glitch/actions/settings'; |
||||
import { createSelector } from 'reselect'; |
||||
import { Map as ImmutableMap } from 'immutable'; |
||||
import { useEmoji } from 'flavours/glitch/actions/emojis'; |
||||
|
||||
const perLine = 8; |
||||
const lines = 2; |
||||
|
||||
const DEFAULTS = [ |
||||
'+1', |
||||
'grinning', |
||||
'kissing_heart', |
||||
'heart_eyes', |
||||
'laughing', |
||||
'stuck_out_tongue_winking_eye', |
||||
'sweat_smile', |
||||
'joy', |
||||
'yum', |
||||
'disappointed', |
||||
'thinking_face', |
||||
'weary', |
||||
'sob', |
||||
'sunglasses', |
||||
'heart', |
||||
'ok_hand', |
||||
]; |
||||
|
||||
const getFrequentlyUsedEmojis = createSelector([ |
||||
state => state.getIn(['settings', 'frequentlyUsedEmojis'], ImmutableMap()), |
||||
], emojiCounters => { |
||||
let emojis = emojiCounters |
||||
.keySeq() |
||||
.sort((a, b) => emojiCounters.get(a) - emojiCounters.get(b)) |
||||
.reverse() |
||||
.slice(0, perLine * lines) |
||||
.toArray(); |
||||
|
||||
if (emojis.length < DEFAULTS.length) { |
||||
emojis = emojis.concat(DEFAULTS.slice(0, DEFAULTS.length - emojis.length)); |
||||
} |
||||
|
||||
return emojis; |
||||
}); |
||||
|
||||
const getCustomEmojis = createSelector([ |
||||
state => state.get('custom_emojis'), |
||||
], emojis => emojis.filter(e => e.get('visible_in_picker')).sort((a, b) => { |
||||
const aShort = a.get('shortcode').toLowerCase(); |
||||
const bShort = b.get('shortcode').toLowerCase(); |
||||
|
||||
if (aShort < bShort) { |
||||
return -1; |
||||
} else if (aShort > bShort ) { |
||||
return 1; |
||||
} else { |
||||
return 0; |
||||
} |
||||
})); |
||||
|
||||
const mapStateToProps = state => ({ |
||||
custom_emojis: getCustomEmojis(state), |
||||
skinTone: state.getIn(['settings', 'skinTone']), |
||||
frequentlyUsedEmojis: getFrequentlyUsedEmojis(state), |
||||
}); |
||||
|
||||
const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({ |
||||
onSkinTone: skinTone => { |
||||
dispatch(changeSetting(['skinTone'], skinTone)); |
||||
}, |
||||
|
||||
onPickEmoji: emoji => { |
||||
dispatch(useEmoji(emoji)); |
||||
|
||||
if (onPickEmoji) { |
||||
onPickEmoji(emoji); |
||||
} |
||||
}, |
||||
}); |
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(EmojiPickerDropdown); |
@ -1,24 +0,0 @@ |
||||
import { connect } from 'react-redux'; |
||||
import PrivacyDropdown from '../components/privacy_dropdown'; |
||||
import { changeComposeVisibility } from 'flavours/glitch/actions/compose'; |
||||
import { openModal, closeModal } from 'flavours/glitch/actions/modal'; |
||||
import { isUserTouching } from 'flavours/glitch/util/is_mobile'; |
||||
|
||||
const mapStateToProps = state => ({ |
||||
isModalOpen: state.get('modal').modalType === 'ACTIONS', |
||||
value: state.getIn(['compose', 'privacy']), |
||||
}); |
||||
|
||||
const mapDispatchToProps = dispatch => ({ |
||||
|
||||
onChange (value) { |
||||
dispatch(changeComposeVisibility(value)); |
||||
}, |
||||
|
||||
isUserTouching, |
||||
onModalOpen: props => dispatch(openModal('ACTIONS', props)), |
||||
onModalClose: () => dispatch(closeModal()), |
||||
|
||||
}); |
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(PrivacyDropdown); |
@ -1,24 +0,0 @@ |
||||
import { connect } from 'react-redux'; |
||||
import { cancelReplyCompose } from 'flavours/glitch/actions/compose'; |
||||
import { makeGetStatus } from 'flavours/glitch/selectors'; |
||||
import ReplyIndicator from '../components/reply_indicator'; |
||||
|
||||
const makeMapStateToProps = () => { |
||||
const getStatus = makeGetStatus(); |
||||
|
||||
const mapStateToProps = state => ({ |
||||
status: getStatus(state, state.getIn(['compose', 'in_reply_to'])), |
||||
}); |
||||
|
||||
return mapStateToProps; |
||||
}; |
||||
|
||||
const mapDispatchToProps = dispatch => ({ |
||||
|
||||
onCancel () { |
||||
dispatch(cancelReplyCompose()); |
||||
}, |
||||
|
||||
}); |
||||
|
||||
export default connect(makeMapStateToProps, mapDispatchToProps)(ReplyIndicator); |
@ -1,71 +0,0 @@ |
||||
import React from 'react'; |
||||
import { connect } from 'react-redux'; |
||||
import PropTypes from 'prop-types'; |
||||
import classNames from 'classnames'; |
||||
import IconButton from 'flavours/glitch/components/icon_button'; |
||||
import { changeComposeSensitivity } from 'flavours/glitch/actions/compose'; |
||||
import Motion from 'flavours/glitch/util/optional_motion'; |
||||
import spring from 'react-motion/lib/spring'; |
||||
import { injectIntl, defineMessages } from 'react-intl'; |
||||
|
||||
const messages = defineMessages({ |
||||
title: { id: 'compose_form.sensitive', defaultMessage: 'Mark media as sensitive' }, |
||||
}); |
||||
|
||||
const mapStateToProps = state => ({ |
||||
visible: state.getIn(['compose', 'media_attachments']).size > 0, |
||||
active: state.getIn(['compose', 'sensitive']), |
||||
disabled: state.getIn(['compose', 'spoiler']), |
||||
}); |
||||
|
||||
const mapDispatchToProps = dispatch => ({ |
||||
|
||||
onClick () { |
||||
dispatch(changeComposeSensitivity()); |
||||
}, |
||||
|
||||
}); |
||||
|
||||
class SensitiveButton extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
visible: PropTypes.bool, |
||||
active: PropTypes.bool, |
||||
disabled: PropTypes.bool, |
||||
onClick: PropTypes.func.isRequired, |
||||
intl: PropTypes.object.isRequired, |
||||
}; |
||||
|
||||
render () { |
||||
const { visible, active, disabled, onClick, intl } = this.props; |
||||
|
||||
return ( |
||||
<Motion defaultStyle={{ scale: 0.87 }} style={{ scale: spring(visible ? 1 : 0.87, { stiffness: 200, damping: 3 }) }}> |
||||
{({ scale }) => { |
||||
const icon = active ? 'eye-slash' : 'eye'; |
||||
const className = classNames('compose-form__sensitive-button', { |
||||
'compose-form__sensitive-button--visible': visible, |
||||
}); |
||||
return ( |
||||
<div className={className} style={{ transform: `scale(${scale})` }}> |
||||
<IconButton |
||||
className='compose-form__sensitive-button__icon' |
||||
title={intl.formatMessage(messages.title)} |
||||
icon={icon} |
||||
onClick={onClick} |
||||
size={18} |
||||
active={active} |
||||
disabled={disabled} |
||||
style={{ lineHeight: null, height: null }} |
||||
inverted |
||||
/> |
||||
</div> |
||||
); |
||||
}} |
||||
</Motion> |
||||
); |
||||
} |
||||
|
||||
} |
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(SensitiveButton)); |
@ -1,25 +0,0 @@ |
||||
import { connect } from 'react-redux'; |
||||
import TextIconButton from '../components/text_icon_button'; |
||||
import { changeComposeSpoilerness } from 'flavours/glitch/actions/compose'; |
||||
import { injectIntl, defineMessages } from 'react-intl'; |
||||
|
||||
const messages = defineMessages({ |
||||
title: { id: 'compose_form.spoiler', defaultMessage: 'Hide text behind warning' }, |
||||
}); |
||||
|
||||
const mapStateToProps = (state, { intl }) => ({ |
||||
label: 'CW', |
||||
title: intl.formatMessage(messages.title), |
||||
active: state.getIn(['compose', 'spoiler']), |
||||
ariaControls: 'cw-spoiler-input', |
||||
}); |
||||
|
||||
const mapDispatchToProps = dispatch => ({ |
||||
|
||||
onClick () { |
||||
dispatch(changeComposeSpoilerness()); |
||||
}, |
||||
|
||||
}); |
||||
|
||||
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(TextIconButton)); |
@ -1,18 +0,0 @@ |
||||
import { connect } from 'react-redux'; |
||||
import UploadButton from '../components/upload_button'; |
||||
import { uploadCompose } from 'flavours/glitch/actions/compose'; |
||||
|
||||
const mapStateToProps = state => ({ |
||||
disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 3 || state.getIn(['compose', 'media_attachments']).some(m => m.get('type') === 'video')), |
||||
resetFileKey: state.getIn(['compose', 'resetFileKey']), |
||||
}); |
||||
|
||||
const mapDispatchToProps = dispatch => ({ |
||||
|
||||
onSelectFile (files) { |
||||
dispatch(uploadCompose(files)); |
||||
}, |
||||
|
||||
}); |
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(UploadButton); |
@ -1,21 +0,0 @@ |
||||
import { connect } from 'react-redux'; |
||||
import Upload from '../components/upload'; |
||||
import { undoUploadCompose, changeUploadCompose } from 'flavours/glitch/actions/compose'; |
||||
|
||||
const mapStateToProps = (state, { id }) => ({ |
||||
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id), |
||||
}); |
||||
|
||||
const mapDispatchToProps = dispatch => ({ |
||||
|
||||
onUndo: id => { |
||||
dispatch(undoUploadCompose(id)); |
||||
}, |
||||
|
||||
onDescriptionChange: (id, description) => { |
||||
dispatch(changeUploadCompose(id, description)); |
||||
}, |
||||
|
||||
}); |
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Upload); |
@ -1,8 +0,0 @@ |
||||
import { connect } from 'react-redux'; |
||||
import UploadForm from '../components/upload_form'; |
||||
|
||||
const mapStateToProps = state => ({ |
||||
mediaIds: state.getIn(['compose', 'media_attachments']).map(item => item.get('id')), |
||||
}); |
||||
|
||||
export default connect(mapStateToProps)(UploadForm); |
@ -1,9 +0,0 @@ |
||||
import { connect } from 'react-redux'; |
||||
import UploadProgress from '../components/upload_progress'; |
||||
|
||||
const mapStateToProps = state => ({ |
||||
active: state.getIn(['compose', 'is_uploading']), |
||||
progress: state.getIn(['compose', 'progress']), |
||||
}); |
||||
|
||||
export default connect(mapStateToProps)(UploadProgress); |
@ -1,24 +0,0 @@ |
||||
import React from 'react'; |
||||
import { connect } from 'react-redux'; |
||||
import Warning from '../components/warning'; |
||||
import PropTypes from 'prop-types'; |
||||
import { FormattedMessage } from 'react-intl'; |
||||
import { me } from 'flavours/glitch/util/initial_state'; |
||||
|
||||
const mapStateToProps = state => ({ |
||||
needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']), |
||||
}); |
||||
|
||||
const WarningWrapper = ({ needsLockWarning }) => { |
||||
if (needsLockWarning) { |
||||
return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <a href='/settings/profile'><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />; |
||||
} |
||||
|
||||
return null; |
||||
}; |
||||
|
||||
WarningWrapper.propTypes = { |
||||
needsLockWarning: PropTypes.bool, |
||||
}; |
||||
|
||||
export default connect(mapStateToProps)(WarningWrapper); |
@ -1,126 +0,0 @@ |
||||
import React from 'react'; |
||||
import ComposeFormContainer from './containers/compose_form_container'; |
||||
import NavigationContainer from './containers/navigation_container'; |
||||
import PropTypes from 'prop-types'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import { connect } from 'react-redux'; |
||||
import { mountCompose, unmountCompose } from 'flavours/glitch/actions/compose'; |
||||
import { openModal } from 'flavours/glitch/actions/modal'; |
||||
import { changeLocalSetting } from 'flavours/glitch/actions/local_settings'; |
||||
import { Link } from 'react-router-dom'; |
||||
import { injectIntl, defineMessages } from 'react-intl'; |
||||
import SearchContainer from './containers/search_container'; |
||||
import Motion from 'flavours/glitch/util/optional_motion'; |
||||
import spring from 'react-motion/lib/spring'; |
||||
import SearchResultsContainer from './containers/search_results_container'; |
||||
import { changeComposing } from 'flavours/glitch/actions/compose'; |
||||
|
||||
const messages = defineMessages({ |
||||
start: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, |
||||
home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' }, |
||||
notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' }, |
||||
public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' }, |
||||
community: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' }, |
||||
settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' }, |
||||
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, |
||||
}); |
||||
|
||||
const mapStateToProps = state => ({ |
||||
columns: state.getIn(['settings', 'columns']), |
||||
showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']), |
||||
}); |
||||
|
||||
@connect(mapStateToProps) |
||||
@injectIntl |
||||
export default class Compose extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
dispatch: PropTypes.func.isRequired, |
||||
columns: ImmutablePropTypes.list.isRequired, |
||||
multiColumn: PropTypes.bool, |
||||
showSearch: PropTypes.bool, |
||||
intl: PropTypes.object.isRequired, |
||||
}; |
||||
|
||||
componentDidMount () { |
||||
this.props.dispatch(mountCompose()); |
||||
} |
||||
|
||||
componentWillUnmount () { |
||||
this.props.dispatch(unmountCompose()); |
||||
} |
||||
|
||||
onLayoutClick = (e) => { |
||||
const layout = e.currentTarget.getAttribute('data-mastodon-layout'); |
||||
this.props.dispatch(changeLocalSetting(['layout'], layout)); |
||||
e.preventDefault(); |
||||
} |
||||
|
||||
openSettings = () => { |
||||
this.props.dispatch(openModal('SETTINGS', {})); |
||||
} |
||||
|
||||
onFocus = () => { |
||||
this.props.dispatch(changeComposing(true)); |
||||
} |
||||
|
||||
onBlur = () => { |
||||
this.props.dispatch(changeComposing(false)); |
||||
} |
||||
|
||||
render () { |
||||
const { multiColumn, showSearch, intl } = this.props; |
||||
|
||||
let header = ''; |
||||
|
||||
if (multiColumn) { |
||||
const { columns } = this.props; |
||||
header = ( |
||||
<nav className='drawer__header'> |
||||
<Link to='/getting-started' className='drawer__tab' title={intl.formatMessage(messages.start)} aria-label={intl.formatMessage(messages.start)}><i role='img' className='fa fa-fw fa-asterisk' /></Link> |
||||
{!columns.some(column => column.get('id') === 'HOME') && ( |
||||
<Link to='/timelines/home' className='drawer__tab' title={intl.formatMessage(messages.home_timeline)} aria-label={intl.formatMessage(messages.home_timeline)}><i role='img' className='fa fa-fw fa-home' /></Link> |
||||
)} |
||||
{!columns.some(column => column.get('id') === 'NOTIFICATIONS') && ( |
||||
<Link to='/notifications' className='drawer__tab' title={intl.formatMessage(messages.notifications)} aria-label={intl.formatMessage(messages.notifications)}><i role='img' className='fa fa-fw fa-bell' /></Link> |
||||
)} |
||||
{!columns.some(column => column.get('id') === 'COMMUNITY') && ( |
||||
<Link to='/timelines/public/local' className='drawer__tab' title={intl.formatMessage(messages.community)} aria-label={intl.formatMessage(messages.community)}><i role='img' className='fa fa-fw fa-users' /></Link> |
||||
)} |
||||
{!columns.some(column => column.get('id') === 'PUBLIC') && ( |
||||
<Link to='/timelines/public' className='drawer__tab' title={intl.formatMessage(messages.public)} aria-label={intl.formatMessage(messages.public)}><i role='img' className='fa fa-fw fa-globe' /></Link> |
||||
)} |
||||
<a onClick={this.openSettings} role='button' tabIndex='0' className='drawer__tab' title={intl.formatMessage(messages.settings)} aria-label={intl.formatMessage(messages.settings)}><i role='img' className='fa fa-fw fa-cogs' /></a> |
||||
<a href='/auth/sign_out' className='drawer__tab' data-method='delete' title={intl.formatMessage(messages.logout)} aria-label={intl.formatMessage(messages.logout)}><i role='img' className='fa fa-fw fa-sign-out' /></a> |
||||
</nav> |
||||
); |
||||
} |
||||
|
||||
|
||||
|
||||
return ( |
||||
<div className='drawer'> |
||||
{header} |
||||
|
||||
<SearchContainer /> |
||||
|
||||
<div className='drawer__pager'> |
||||
<div className='drawer__inner scrollable optionally-scrollable' onFocus={this.onFocus}> |
||||
<NavigationContainer onClose={this.onBlur} /> |
||||
<ComposeFormContainer /> |
||||
</div> |
||||
|
||||
<Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}> |
||||
{({ x }) => |
||||
<div className='drawer__inner darker scrollable optionally-scrollable' style={{ transform: `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}> |
||||
<SearchResultsContainer /> |
||||
</div> |
||||
} |
||||
</Motion> |
||||
</div> |
||||
|
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,243 @@ |
||||
// Package imports.
|
||||
import classNames from 'classnames'; |
||||
import PropTypes from 'prop-types'; |
||||
import React from 'react'; |
||||
import spring from 'react-motion/lib/spring'; |
||||
import Overlay from 'react-overlays/lib/Overlay'; |
||||
|
||||
// Components.
|
||||
import IconButton from 'flavours/glitch/components/icon_button'; |
||||
import ComposerOptionsDropdownItem from './item'; |
||||
|
||||
// Utils.
|
||||
import { withPassive } from 'flavours/glitch/util/dom_helpers'; |
||||
import { isUserTouching } from 'flavours/glitch/util/is_mobile'; |
||||
import Motion from 'flavours/glitch/util/optional_motion'; |
||||
import { assignHandlers } from 'flavours/glitch/util/react_helpers'; |
||||
|
||||
// We'll use this to define our various transitions.
|
||||
const springMotion = spring(1, { |
||||
damping: 35, |
||||
stiffness: 400, |
||||
}); |
||||
|
||||
// Handlers.
|
||||
const handlers = { |
||||
|
||||
// Closes the dropdown.
|
||||
close () { |
||||
this.setState({ open: false }); |
||||
}, |
||||
|
||||
// When the document is clicked elsewhere, we close the dropdown.
|
||||
documentClick ({ target }) { |
||||
const { node } = this; |
||||
const { onClose } = this.props; |
||||
if (onClose && node && !node.contains(target)) { |
||||
onClose(); |
||||
} |
||||
}, |
||||
|
||||
// The enter key toggles the dropdown's open state, and the escape
|
||||
// key closes it.
|
||||
keyDown ({ key }) { |
||||
const { |
||||
close, |
||||
toggle, |
||||
} = this.handlers; |
||||
switch (key) { |
||||
case 'Enter': |
||||
toggle(); |
||||
break; |
||||
case 'Escape': |
||||
close(); |
||||
break; |
||||
} |
||||
}, |
||||
|
||||
// Toggles opening and closing the dropdown.
|
||||
toggle () { |
||||
const { |
||||
items, |
||||
onChange, |
||||
onModalClose, |
||||
onModalOpen, |
||||
value, |
||||
} = this.props; |
||||
const { open } = this.state; |
||||
|
||||
// If this is a touch device, we open a modal instead of the
|
||||
// dropdown.
|
||||
if (onModalClose && isUserTouching()) { |
||||
if (open) { |
||||
onModalClose() |
||||
} else if (onChange && onModalOpen) { |
||||
onModalOpen({ |
||||
actions: items.map( |
||||
({ |
||||
name, |
||||
...rest |
||||
}) => ({ |
||||
...rest, |
||||
active: value && name === value, |
||||
onClick (e) { |
||||
e.preventDefault(); // Prevents focus from changing
|
||||
onModalClose(); |
||||
onChange(name); |
||||
}, |
||||
}) |
||||
), |
||||
}); |
||||
} |
||||
|
||||
// Otherwise, we just set our state to open.
|
||||
} else { |
||||
this.setState({ open: !open }); |
||||
} |
||||
}, |
||||
|
||||
// Stores our node in `this.node`.
|
||||
ref (node) { |
||||
this.node = node; |
||||
}, |
||||
}; |
||||
|
||||
// The component.
|
||||
export default class ComposerOptionsDropdown extends React.PureComponent { |
||||
|
||||
// Constructor.
|
||||
constructor (props) { |
||||
super(props); |
||||
assignHandlers(this, handlers); |
||||
this.state = { open: false }; |
||||
|
||||
// Instance variables.
|
||||
this.node = null; |
||||
} |
||||
|
||||
// On mounting, we add our listeners.
|
||||
componentDidMount () { |
||||
const { documentClick } = this.handlers; |
||||
document.addEventListener('click', documentClick, false); |
||||
document.addEventListener('touchend', documentClick, withPassive); |
||||
} |
||||
|
||||
// On unmounting, we remove our listeners.
|
||||
componentWillUnmount () { |
||||
const { documentClick } = this.handlers; |
||||
document.removeEventListener('click', documentClick, false); |
||||
document.removeEventListener('touchend', documentClick, withPassive); |
||||
} |
||||
|
||||
// Rendering.
|
||||
render () { |
||||
const { |
||||
close, |
||||
keyDown, |
||||
ref, |
||||
toggle, |
||||
} = this.handlers; |
||||
const { |
||||
active, |
||||
disabled, |
||||
title, |
||||
icon, |
||||
items, |
||||
onChange, |
||||
value, |
||||
} = this.props; |
||||
const { open } = this.state; |
||||
const computedClass = classNames('composer--options--dropdown', { |
||||
active, |
||||
open: open || active, |
||||
}); |
||||
|
||||
// The result.
|
||||
return ( |
||||
<div |
||||
className={computedClass} |
||||
onKeyDown={keyDown} |
||||
ref={ref} |
||||
> |
||||
<IconButton |
||||
active={open || active} |
||||
className='value' |
||||
disabled={disabled} |
||||
icon={icon} |
||||
onClick={toggle} |
||||
size={18} |
||||
style={{ |
||||
height: null, |
||||
lineHeight: '27px', |
||||
}} |
||||
title={title} |
||||
/> |
||||
<Overlay |
||||
placement='bottom' |
||||
show={open} |
||||
target={this} |
||||
> |
||||
<Motion |
||||
defaultStyle={{ |
||||
opacity: 0, |
||||
scaleX: 0.85, |
||||
scaleY: 0.75, |
||||
}} |
||||
style={{ |
||||
opacity: springMotion, |
||||
scaleX: springMotion, |
||||
scaleY: springMotion, |
||||
}} |
||||
> |
||||
{({ opacity, scaleX, scaleY }) => ( |
||||
<div |
||||
className='dropdown' |
||||
ref={this.setRef} |
||||
style={{ |
||||
opacity: opacity, |
||||
transform: `scale(${scaleX}, ${scaleY})`, |
||||
}} |
||||
> |
||||
{items.map( |
||||
({ |
||||
name, |
||||
...rest |
||||
}) => ( |
||||
<ComposerOptionsDropdownItem |
||||
active={name === value} |
||||
key={name} |
||||
name={name} |
||||
onChange={onChange} |
||||
onClose={close} |
||||
options={rest} |
||||
/> |
||||
) |
||||
)} |
||||
</div> |
||||
)} |
||||
</Motion> |
||||
</Overlay> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
||||
|
||||
// Props.
|
||||
ComposerOptionsDropdown.propTypes = { |
||||
active: PropTypes.bool, |
||||
disabled: PropTypes.bool, |
||||
icon: PropTypes.string, |
||||
items: PropTypes.arrayOf(PropTypes.shape({ |
||||
icon: PropTypes.string, |
||||
meta: PropTypes.node, |
||||
name: PropTypes.string.isRequired, |
||||
on: PropTypes.bool, |
||||
text: PropTypes.node, |
||||
})).isRequired, |
||||
onChange: PropTypes.func, |
||||
onModalClose: PropTypes.func, |
||||
onModalOpen: PropTypes.func, |
||||
title: PropTypes.string, |
||||
value: PropTypes.string, |
||||
}; |
@ -0,0 +1,126 @@ |
||||
// Package imports.
|
||||
import classNames from 'classnames'; |
||||
import PropTypes from 'prop-types'; |
||||
import React from 'react'; |
||||
import Toggle from 'react-toggle'; |
||||
|
||||
// Components.
|
||||
import Icon from 'flavours/glitch/components/icon'; |
||||
|
||||
// Utils.
|
||||
import { assignHandlers } from 'flavours/glitch/util/react_helpers'; |
||||
|
||||
// Handlers.
|
||||
const handlers = { |
||||
|
||||
// This function activates the dropdown item.
|
||||
activate (e) { |
||||
const { |
||||
name, |
||||
onChange, |
||||
onClose, |
||||
options: { on }, |
||||
} = this.props; |
||||
|
||||
// If the escape key was pressed, we close the dropdown.
|
||||
if (e.key === 'Escape' && onClose) { |
||||
onClose(); |
||||
|
||||
// Otherwise, we both close the dropdown and change the value.
|
||||
} else if (onChange && (!e.key || e.key === 'Enter')) { |
||||
e.preventDefault(); // Prevents change in focus on click
|
||||
if ((on === null || typeof on === 'undefined') && onClose) { |
||||
onClose(); |
||||
} |
||||
onChange(name); |
||||
} |
||||
}, |
||||
|
||||
}; |
||||
|
||||
// The component.
|
||||
export default class ComposerOptionsDropdownItem extends React.PureComponent { |
||||
|
||||
// Constructor.
|
||||
constructor (props) { |
||||
super(props); |
||||
assignHandlers(this, handlers); |
||||
} |
||||
|
||||
// Rendering.
|
||||
render () { |
||||
const { activate } = this.handlers; |
||||
const { |
||||
active, |
||||
options: { |
||||
icon, |
||||
meta, |
||||
on, |
||||
text, |
||||
}, |
||||
} = this.props; |
||||
const computedClass = classNames('composer--options--dropdown_item', { |
||||
active, |
||||
lengthy: meta, |
||||
'toggled-off': !on && on !== null && typeof on !== 'undefined', |
||||
'toggled-on': on, |
||||
'with-icon': icon, |
||||
}); |
||||
|
||||
// The result.
|
||||
return ( |
||||
<div |
||||
className={computedClass} |
||||
onClick={activate} |
||||
onKeyDown={activate} |
||||
role='button' |
||||
tabIndex='0' |
||||
> |
||||
{function () { |
||||
|
||||
// We render a `<Toggle>` if we were provided an `on`
|
||||
// property, and otherwise show an `<Icon>` if available.
|
||||
switch (true) { |
||||
case on !== null && typeof on !== 'undefined': |
||||
return ( |
||||
<Toggle |
||||
checked={on} |
||||
onChange={activate} |
||||
/> |
||||
); |
||||
case !!icon: |
||||
return ( |
||||
<Icon |
||||
fullwidth |
||||
icon={icon} |
||||
/> |
||||
); |
||||
default: |
||||
return null; |
||||
} |
||||
}()} |
||||
{meta ? ( |
||||
<div> |
||||
<strong>{text}</strong> |
||||
{meta} |
||||
</div> |
||||
) : <div>{text}</div>} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
}; |
||||
|
||||
// Props.
|
||||
ComposerOptionsDropdownItem.propTypes = { |
||||
active: PropTypes.bool, |
||||
name: PropTypes.string, |
||||
onChange: PropTypes.func, |
||||
onClose: PropTypes.func, |
||||
options: PropTypes.shape({ |
||||
icon: PropTypes.string, |
||||
meta: PropTypes.node, |
||||
on: PropTypes.bool, |
||||
text: PropTypes.node, |
||||
}), |
||||
}; |
@ -0,0 +1,321 @@ |
||||
// Package imports.
|
||||
import PropTypes from 'prop-types'; |
||||
import React from 'react'; |
||||
import { |
||||
FormattedMessage, |
||||
defineMessages, |
||||
} from 'react-intl'; |
||||
import spring from 'react-motion/lib/spring'; |
||||
|
||||
// Components.
|
||||
import IconButton from 'flavours/glitch/components/icon_button'; |
||||
import TextIconButton from 'flavours/glitch/components/text_icon_button'; |
||||
import Dropdown from './dropdown'; |
||||
|
||||
// Utils.
|
||||
import Motion from 'flavours/glitch/util/optional_motion'; |
||||
import { |
||||
assignHandlers, |
||||
hiddenComponent, |
||||
} from 'flavours/glitch/util/react_helpers'; |
||||
|
||||
// Messages.
|
||||
const messages = defineMessages({ |
||||
advanced_options_icon_title: { |
||||
defaultMessage: 'Advanced options', |
||||
id: 'advanced_options.icon_title', |
||||
}, |
||||
attach: { |
||||
defaultMessage: 'Attach...', |
||||
id: 'compose.attach', |
||||
}, |
||||
change_privacy: { |
||||
defaultMessage: 'Adjust status privacy', |
||||
id: 'privacy.change', |
||||
}, |
||||
direct_long: { |
||||
defaultMessage: 'Post to mentioned users only', |
||||
id: 'privacy.direct.long', |
||||
}, |
||||
direct_short: { |
||||
defaultMessage: 'Direct', |
||||
id: 'privacy.direct.short', |
||||
}, |
||||
doodle: { |
||||
defaultMessage: 'Draw something', |
||||
id: 'compose.attach.doodle', |
||||
}, |
||||
local_only_long: { |
||||
defaultMessage: 'Do not post to other instances', |
||||
id: 'advanced-options.local-only.long', |
||||
}, |
||||
local_only_short: { |
||||
defaultMessage: 'Local-only', |
||||
id: 'advanced-options.local-only.short', |
||||
}, |
||||
private_long: { |
||||
defaultMessage: 'Post to followers only', |
||||
id: 'privacy.private.long', |
||||
}, |
||||
private_short: { |
||||
defaultMessage: 'Followers-only', |
||||
id: 'privacy.private.short', |
||||
}, |
||||
public_long: { |
||||
defaultMessage: 'Post to public timelines', |
||||
id: 'privacy.public.long', |
||||
}, |
||||
public_short: { |
||||
defaultMessage: 'Public', |
||||
id: 'privacy.public.short', |
||||
}, |
||||
sensitive: { |
||||
defaultMessage: 'Mark media as sensitive', |
||||
id: 'compose_form.sensitive', |
||||
}, |
||||
spoiler: { |
||||
defaultMessage: 'Hide text behind warning', |
||||
id: 'compose_form.spoiler', |
||||
}, |
||||
unlisted_long: { |
||||
defaultMessage: 'Do not show in public timelines', |
||||
id: 'privacy.unlisted.long', |
||||
}, |
||||
unlisted_short: { |
||||
defaultMessage: 'Unlisted', |
||||
id: 'privacy.unlisted.short', |
||||
}, |
||||
upload: { |
||||
defaultMessage: 'Upload a file', |
||||
id: 'compose.attach.upload', |
||||
}, |
||||
}); |
||||
|
||||
// Handlers.
|
||||
const handlers = { |
||||
|
||||
// Handles file selection.
|
||||
changeFiles ({ target: { files } }) { |
||||
const { onUpload } = this.props; |
||||
if (files.length && onUpload) { |
||||
onUpload(files); |
||||
} |
||||
}, |
||||
|
||||
// Handles attachment clicks.
|
||||
clickAttach (name) { |
||||
const { fileElement } = this; |
||||
const { onDoodleOpen } = this.props; |
||||
|
||||
// We switch over the name of the option.
|
||||
switch (name) { |
||||
case 'upload': |
||||
if (fileElement) { |
||||
fileElement.click(); |
||||
} |
||||
return; |
||||
case 'doodle': |
||||
if (onDoodleOpen) { |
||||
onDoodleOpen(); |
||||
} |
||||
return; |
||||
} |
||||
}, |
||||
|
||||
// Handles a ref to the file input.
|
||||
refFileElement (fileElement) { |
||||
this.fileElement = fileElement; |
||||
}, |
||||
}; |
||||
|
||||
// The component.
|
||||
export default class ComposerOptions extends React.PureComponent { |
||||
|
||||
// Constructor.
|
||||
constructor (props) { |
||||
super(props); |
||||
assignHandlers(this, handlers); |
||||
|
||||
// Instance variables.
|
||||
this.fileElement = null; |
||||
} |
||||
|
||||
// Rendering.
|
||||
render () { |
||||
const { |
||||
changeFiles, |
||||
clickAttach, |
||||
refFileElement, |
||||
} = this.handlers; |
||||
const { |
||||
acceptContentTypes, |
||||
disabled, |
||||
doNotFederate, |
||||
full, |
||||
hasMedia, |
||||
intl, |
||||
onChangeSensitivity, |
||||
onChangeVisibility, |
||||
onModalClose, |
||||
onModalOpen, |
||||
onToggleAdvancedOption, |
||||
privacy, |
||||
resetFileKey, |
||||
sensitive, |
||||
spoiler, |
||||
} = this.props; |
||||
|
||||
// We predefine our privacy items so that we can easily pick the
|
||||
// dropdown icon later.
|
||||
const privacyItems = { |
||||
direct: { |
||||
icon: 'envelope', |
||||
meta: <FormattedMessage {...messages.direct_long} />, |
||||
name: 'direct', |
||||
text: <FormattedMessage {...messages.direct_short} />, |
||||
}, |
||||
private: { |
||||
icon: 'lock', |
||||
meta: <FormattedMessage {...messages.private_long} />, |
||||
name: 'private', |
||||
text: <FormattedMessage {...messages.private_short} />, |
||||
}, |
||||
public: { |
||||
icon: 'globe', |
||||
meta: <FormattedMessage {...messages.public_long} />, |
||||
name: 'public', |
||||
text: <FormattedMessage {...messages.public_short} />, |
||||
}, |
||||
unlisted: { |
||||
icon: 'unlock-alt', |
||||
meta: <FormattedMessage {...messages.unlisted_long} />, |
||||
name: 'unlisted', |
||||
text: <FormattedMessage {...messages.unlisted_short} />, |
||||
}, |
||||
}; |
||||
|
||||
// The result.
|
||||
return ( |
||||
<div className='composer--options'> |
||||
<input |
||||
accept={acceptContentTypes} |
||||
disabled={disabled || full} |
||||
key={resetFileKey} |
||||
onChange={changeFiles} |
||||
ref={refFileElement} |
||||
type='file' |
||||
{...hiddenComponent} |
||||
/> |
||||
<Dropdown |
||||
disabled={disabled || full} |
||||
icon='paperclip' |
||||
items={[ |
||||
{ |
||||
icon: 'cloud-upload', |
||||
name: 'upload', |
||||
text: <FormattedMessage {...messages.upload} />, |
||||
}, |
||||
{ |
||||
icon: 'paint-brush', |
||||
name: 'doodle', |
||||
text: <FormattedMessage {...messages.doodle} />, |
||||
}, |
||||
]} |
||||
onChange={clickAttach} |
||||
onModalClose={onModalClose} |
||||
onModalOpen={onModalOpen} |
||||
title={messages.attach} |
||||
/> |
||||
<Motion |
||||
defaultStyle={{ scale: 0.87 }} |
||||
style={{ |
||||
scale: spring(hasMedia ? 1 : 0.87, { |
||||
stiffness: 200, |
||||
damping: 3, |
||||
}), |
||||
}} |
||||
> |
||||
{({ scale }) => ( |
||||
<div style={{ transform: `scale(${scale})` }}> |
||||
<IconButton |
||||
active={sensitive} |
||||
className='sensitive' |
||||
disabled={spoiler} |
||||
icon={sensitive ? 'eye-slash' : 'eye'} |
||||
inverted |
||||
onClick={onChangeSensitivity} |
||||
size={18} |
||||
style={{ |
||||
height: null, |
||||
lineHeight: null, |
||||
}} |
||||
title={intl.formatMessage(messages.sensitive)} |
||||
/> |
||||
</div> |
||||
)} |
||||
</Motion> |
||||
<hr /> |
||||
<Dropdown |
||||
disabled={disabled} |
||||
icon={(privacyItems[privacy] || {}).icon} |
||||
items={[ |
||||
privacyItems.public, |
||||
privacyItems.unlisted, |
||||
privacyItems.private, |
||||
privacyItems.direct, |
||||
]} |
||||
onChange={onChangeVisibility} |
||||
onModalClose={onModalClose} |
||||
onModalOpen={onModalOpen} |
||||
title={intl.formatMessage(messages.change_privacy)} |
||||
value={privacy} |
||||
/> |
||||
<TextIconButton |
||||
active={spoiler} |
||||
ariaControls='glitch.composer.spoiler.input' |
||||
label='CW' |
||||
title={intl.formatMessage(messages.spoiler)} |
||||
/> |
||||
<Dropdown |
||||
active={doNotFederate} |
||||
disabled={disabled} |
||||
icon='home' |
||||
items={[ |
||||
{ |
||||
meta: <FormattedMessage {...messages.local_only_long} />, |
||||
name: 'do_not_federate', |
||||
on: doNotFederate, |
||||
text: <FormattedMessage {...messages.local_only_short} />, |
||||
}, |
||||
]} |
||||
onChange={onToggleAdvancedOption} |
||||
onModalClose={onModalClose} |
||||
onModalOpen={onModalOpen} |
||||
title={intl.formatMessage(messages.advanced_options_icon_title)} |
||||
/> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
||||
|
||||
// Props.
|
||||
ComposerOptions.propTypes = { |
||||
acceptContentTypes: PropTypes.string, |
||||
disabled: PropTypes.bool, |
||||
doNotFederate: PropTypes.bool, |
||||
full: PropTypes.bool, |
||||
hasMedia: PropTypes.bool, |
||||
intl: PropTypes.object.isRequired, |
||||
onChangeSensitivity: PropTypes.func, |
||||
onChangeVisibility: PropTypes.func, |
||||
onDoodleOpen: PropTypes.func, |
||||
onModalClose: PropTypes.func, |
||||
onModalOpen: PropTypes.func, |
||||
onToggleAdvancedOption: PropTypes.func, |
||||
onUpload: PropTypes.func, |
||||
privacy: PropTypes.string, |
||||
resetFileKey: PropTypes.string, |
||||
sensitive: PropTypes.bool, |
||||
spoiler: PropTypes.bool, |
||||
}; |
@ -0,0 +1,119 @@ |
||||
// Package imports.
|
||||
import classNames from 'classnames'; |
||||
import PropTypes from 'prop-types'; |
||||
import React from 'react'; |
||||
import { |
||||
defineMessages, |
||||
FormattedMessage, |
||||
} from 'react-intl'; |
||||
import { length } from 'stringz'; |
||||
|
||||
// Components.
|
||||
import Button from 'flavours/glitch/components/button'; |
||||
import Icon from 'flavours/glitch/components/icon'; |
||||
|
||||
// Utils.
|
||||
import { maxChars } from 'flavours/glitch/util/initial_state'; |
||||
|
||||
// Messages.
|
||||
const messages = defineMessages({ |
||||
publish: { |
||||
defaultMessage: 'Toot', |
||||
id: 'compose_form.publish', |
||||
}, |
||||
publishLoud: { |
||||
defaultMessage: '{publish}!', |
||||
id: 'compose_form.publish_loud', |
||||
}, |
||||
}); |
||||
|
||||
// The component.
|
||||
export default function ComposerPublisher ({ |
||||
countText, |
||||
disabled, |
||||
intl, |
||||
onSecondarySubmit, |
||||
onSubmit, |
||||
privacy, |
||||
sideArm, |
||||
}) { |
||||
const diff = maxChars - length(countText || ''); |
||||
const computedClass = classNames('composer--publisher', { |
||||
disabled: disabled || diff < 0, |
||||
over: diff < 0, |
||||
}); |
||||
|
||||
// The result.
|
||||
return ( |
||||
<div className={computedClass}> |
||||
<span class='count'>{diff}</span> |
||||
{sideArm && sideArm !== 'none' ? ( |
||||
<Button |
||||
text={ |
||||
<span> |
||||
<Icon |
||||
icon={{ |
||||
public: 'globe', |
||||
unlisted: 'unlock-alt', |
||||
private: 'lock', |
||||
direct: 'envelope', |
||||
}[sideArm]} |
||||
/> |
||||
</span> |
||||
} |
||||
title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${sideArm}.short` })}`} |
||||
onClick={onSecondarySubmit} |
||||
disabled={disabled || diff < 0} |
||||
/> |
||||
) : null} |
||||
<Button |
||||
className='compose-form__publish__primary' |
||||
text={function () { |
||||
switch (true) { |
||||
case !!sideArm && sideArm !== 'none': |
||||
case privacy === 'direct': |
||||
case privacy === 'private': |
||||
return ( |
||||
<span> |
||||
<Icon |
||||
icon={{ |
||||
direct: 'envelope', |
||||
private: 'lock', |
||||
public: 'globe', |
||||
unlisted: 'unlock-alt', |
||||
}[privacy]} |
||||
/> |
||||
<FormattedMessage {...messages.publish} /> |
||||
</span> |
||||
); |
||||
case privacy === 'public': |
||||
return ( |
||||
<span> |
||||
<FormattedMessage |
||||
{...messages.publishLoud} |
||||
values={{ publish: <FormattedMessage {...messages.publish} /> }} |
||||
/> |
||||
</span> |
||||
); |
||||
default: |
||||
return <span><FormattedMessage {...messages.publish} /></span>; |
||||
} |
||||
}()} |
||||
title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${privacy}.short` })}`} |
||||
onClick={onSubmit} |
||||
disabled={disabled || diff < 0} |
||||
/> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
// Props.
|
||||
ComposerPublisher.propTypes = { |
||||
countText: PropTypes.string, |
||||
disabled: PropTypes.bool, |
||||
intl: PropTypes.object.isRequired, |
||||
onSecondarySubmit: PropTypes.func, |
||||
onSubmit: PropTypes.func, |
||||
privacy: PropTypes.oneOf(['direct', 'private', 'unlisted', 'public']), |
||||
sideArm: PropTypes.oneOf(['none', 'direct', 'private', 'unlisted', 'public']), |
||||
}; |
@ -0,0 +1,106 @@ |
||||
// Package imports.
|
||||
import PropTypes from 'prop-types'; |
||||
import React from 'react'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import { defineMessages } from 'react-intl'; |
||||
|
||||
// Components.
|
||||
import Avatar from 'flavours/glitch/components/avatar'; |
||||
import DisplayName from 'flavours/glitch/components/display_name'; |
||||
import IconButton from 'flavours/glitch/components/icon_button'; |
||||
|
||||
// Utils.
|
||||
import { assignHandlers } from 'flavours/glitch/util/react_helpers'; |
||||
import { isRtl } from 'flavours/glitch/util/rtl'; |
||||
|
||||
// Messages.
|
||||
const messages = defineMessages({ |
||||
cancel: { |
||||
defaultMessage: 'Cancel', |
||||
id: 'reply_indicator.cancel', |
||||
}, |
||||
}); |
||||
|
||||
// Handlers.
|
||||
const handlers = { |
||||
|
||||
// Handles a click on the "close" button.
|
||||
click () { |
||||
const { onCancel } = this.props; |
||||
if (onCancel) { |
||||
onCancel(); |
||||
} |
||||
}, |
||||
|
||||
// Handles a click on the status's account.
|
||||
clickAccount () { |
||||
const { |
||||
account, |
||||
history, |
||||
} = this.props; |
||||
if (history) { |
||||
history.push(`/accounts/${account.get('id')}`); |
||||
} |
||||
}, |
||||
}; |
||||
|
||||
// The component.
|
||||
export default class ComposerReply extends React.PureComponent { |
||||
|
||||
// Constructor.
|
||||
constructor (props) { |
||||
super(props); |
||||
assignHandlers(this, handlers); |
||||
} |
||||
|
||||
// Rendering.
|
||||
render () { |
||||
const { |
||||
click, |
||||
clickAccount, |
||||
} = this.handlers; |
||||
const { |
||||
account, |
||||
content, |
||||
intl, |
||||
} = this.props; |
||||
|
||||
// The result.
|
||||
return ( |
||||
<article className='composer--reply'> |
||||
<header> |
||||
<IconButton |
||||
icon='times' |
||||
onClick={click} |
||||
title={intl.formatMessage(messages.cancel)} |
||||
/> |
||||
{account ? ( |
||||
<a |
||||
href={account.get('url')} |
||||
onClick={clickAccount} |
||||
> |
||||
<Avatar |
||||
account={account} |
||||
size={24} |
||||
/> |
||||
<DisplayName account={account} /> |
||||
</a> |
||||
) : null} |
||||
</header> |
||||
<div |
||||
dangerouslySetInnerHTML={{ __html: content || '' }} |
||||
style={{ direction: isRtl(content) ? 'rtl' : 'ltr' }} |
||||
/> |
||||
</article> |
||||
); |
||||
} |
||||
|
||||
} |
||||
|
||||
ComposerReply.propTypes = { |
||||
account: ImmutablePropTypes.map, |
||||
content: PropTypes.string, |
||||
history: PropTypes.object, |
||||
intl: PropTypes.object.isRequired, |
||||
onCancel: PropTypes.func, |
||||
}; |
@ -0,0 +1,92 @@ |
||||
// Package imports.
|
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import { defineMessages, FormattedMessage } from 'react-intl'; |
||||
|
||||
// Components.
|
||||
import Collapsable from 'flavours/glitch/components/collapsable'; |
||||
|
||||
// Utils.
|
||||
import { |
||||
assignHandlers, |
||||
hiddenComponent, |
||||
} from 'flavours/glitch/util/react_helpers'; |
||||
|
||||
// Messages.
|
||||
const messages = defineMessages({ |
||||
placeholder: { |
||||
defaultMessage: 'Write your warning here', |
||||
id: 'compose_form.spoiler_placeholder', |
||||
}, |
||||
}); |
||||
|
||||
// Handlers.
|
||||
const handlers = { |
||||
|
||||
// Handles a keypress.
|
||||
keyDown ({ |
||||
ctrlKey, |
||||
keyCode, |
||||
metaKey, |
||||
}) { |
||||
const { onSubmit } = this.props; |
||||
|
||||
// We submit the status on control/meta + enter.
|
||||
if (onSubmit && keyCode === 13 && (ctrlKey || metaKey)) { |
||||
onSubmit(); |
||||
} |
||||
}, |
||||
}; |
||||
|
||||
// The component.
|
||||
export default class ComposerSpoiler extends React.PureComponent { |
||||
|
||||
// Constructor.
|
||||
constructor (props) { |
||||
super(props); |
||||
assignHandlers(this, handlers); |
||||
} |
||||
|
||||
// Rendering.
|
||||
render () { |
||||
const { keyDown } = this.handlers; |
||||
const { |
||||
hidden, |
||||
intl, |
||||
onChange, |
||||
text, |
||||
} = this.props; |
||||
|
||||
// The result.
|
||||
return ( |
||||
<Collapsable |
||||
isVisible={!hidden} |
||||
fullHeight={50} |
||||
> |
||||
<label className='composer--spoiler'> |
||||
<span {...hiddenComponent}> |
||||
<FormattedMessage {...messages.placeholder} /> |
||||
</span> |
||||
<input |
||||
id='glitch.composer.spoiler.input' |
||||
onChange={onChange} |
||||
onKeyDown={keyDown} |
||||
placeholder={intl.formatMessage(messages.placeholder)} |
||||
type='text' |
||||
value={text} |
||||
/> |
||||
</label> |
||||
</Collapsable> |
||||
); |
||||
} |
||||
|
||||
} |
||||
|
||||
// Props.
|
||||
ComposerSpoiler.propTypes = { |
||||
hidden: PropTypes.bool, |
||||
intl: PropTypes.object.isRequired, |
||||
onChange: PropTypes.func, |
||||
onSubmit: PropTypes.func, |
||||
text: PropTypes.string, |
||||
}; |
@ -0,0 +1,297 @@ |
||||
// Package imports.
|
||||
import PropTypes from 'prop-types'; |
||||
import React from 'react'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import { |
||||
defineMessages, |
||||
FormattedMessage, |
||||
} from 'react-intl'; |
||||
import Textarea from 'react-textarea-autosize'; |
||||
|
||||
// Components.
|
||||
import EmojiPicker from 'flavours/glitch/features/emoji_picker'; |
||||
import ComposerTextareaSuggestions from './suggestions'; |
||||
|
||||
// Utils.
|
||||
import { isRtl } from 'flavours/glitch/util/rtl'; |
||||
import { |
||||
assignHandlers, |
||||
hiddenComponent, |
||||
} from 'flavours/glitch/util/react_helpers'; |
||||
|
||||
// Messages.
|
||||
const messages = defineMessages({ |
||||
placeholder: { |
||||
defaultMessage: 'What is on your mind?', |
||||
id: 'compose_form.placeholder', |
||||
}, |
||||
}); |
||||
|
||||
// Handlers.
|
||||
const handlers = { |
||||
|
||||
// When blurring the textarea, suggestions are hidden.
|
||||
blur () { |
||||
this.setState({ suggestionsHidden: true }); |
||||
}, |
||||
|
||||
// When the contents of the textarea change, we have to pull up new
|
||||
// autosuggest suggestions if applicable, and also change the value
|
||||
// of the textarea in our store.
|
||||
change ({ |
||||
target: { |
||||
selectionStart, |
||||
value, |
||||
}, |
||||
}) { |
||||
const { |
||||
onChange, |
||||
onSuggestionsFetchRequested, |
||||
onSuggestionsClearRequested, |
||||
} = this.props; |
||||
const { lastToken } = this.state; |
||||
|
||||
// This gets the token at the caret location, if it begins with an
|
||||
// `@` (mentions) or `:` (shortcodes).
|
||||
const left = value.slice(0, selectionStart).search(/[^\s\u200B]+$/); |
||||
const right = value.slice(selectionStart).search(/[\s\u200B]/); |
||||
const token = function () { |
||||
switch (true) { |
||||
case left < 0 || /[@:]/.test(!value[left]): |
||||
return null; |
||||
case right < 0: |
||||
return value.slice(left); |
||||
default: |
||||
return value.slice(left, right + selectionStart).trim().toLowerCase(); |
||||
} |
||||
}(); |
||||
|
||||
// We only request suggestions for tokens which are at least 3
|
||||
// characters long.
|
||||
if (onSuggestionsFetchRequested && token && token.length >= 3) { |
||||
if (lastToken !== token) { |
||||
this.setState({ |
||||
lastToken: token, |
||||
selectedSuggestion: 0, |
||||
tokenStart: left, |
||||
}); |
||||
onSuggestionsFetchRequested(token); |
||||
} |
||||
} else { |
||||
this.setState({ lastToken: null }); |
||||
if (onSuggestionsClearRequested) { |
||||
onSuggestionsClearRequested(); |
||||
} |
||||
} |
||||
|
||||
// Updates the value of the textarea.
|
||||
if (onChange) { |
||||
onChange(value); |
||||
} |
||||
}, |
||||
|
||||
// Handles a click on an autosuggestion.
|
||||
clickSuggestion (index) { |
||||
const { textarea } = this; |
||||
const { |
||||
onSuggestionSelected, |
||||
suggestions, |
||||
} = this.props; |
||||
const { |
||||
lastToken, |
||||
tokenStart, |
||||
} = this.state; |
||||
onSuggestionSelected(tokenStart, lastToken, suggestions.get(index)); |
||||
textarea.focus(); |
||||
}, |
||||
|
||||
// Handles a keypress. If the autosuggestions are visible, we need
|
||||
// to allow keypresses to navigate and sleect them.
|
||||
keyDown (e) { |
||||
const { |
||||
disabled, |
||||
onSubmit, |
||||
onSuggestionSelected, |
||||
suggestions, |
||||
} = this.props; |
||||
const { |
||||
lastToken, |
||||
suggestionsHidden, |
||||
selectedSuggestion, |
||||
tokenStart, |
||||
} = this.state; |
||||
|
||||
// Keypresses do nothing if the composer is disabled.
|
||||
if (disabled) { |
||||
e.preventDefault(); |
||||
return; |
||||
} |
||||
|
||||
// Switches over the pressed key.
|
||||
switch(e.key) { |
||||
|
||||
// On arrow down, we pick the next suggestion.
|
||||
case 'ArrowDown': |
||||
if (suggestions && suggestions.size > 0 && !suggestionsHidden) { |
||||
e.preventDefault(); |
||||
this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) }); |
||||
} |
||||
return; |
||||
|
||||
// On arrow up, we pick the previous suggestion.
|
||||
case 'ArrowUp': |
||||
if (suggestions && suggestions.size > 0 && !suggestionsHidden) { |
||||
e.preventDefault(); |
||||
this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) }); |
||||
} |
||||
return; |
||||
|
||||
// On enter or tab, we select the suggestion.
|
||||
case 'Enter': |
||||
case 'Tab': |
||||
if (onSuggestionSelected && lastToken !== null && suggestions && suggestions.size > 0 && !suggestionsHidden) { |
||||
e.preventDefault(); |
||||
e.stopPropagation(); |
||||
onSuggestionSelected(tokenStart, lastToken, suggestions.get(selectedSuggestion)); |
||||
} |
||||
return; |
||||
} |
||||
|
||||
// We submit the status on control/meta + enter.
|
||||
if (onSubmit && e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { |
||||
onSubmit(); |
||||
} |
||||
}, |
||||
|
||||
// When the escape key is released, we either close the suggestions
|
||||
// window or focus the UI.
|
||||
keyUp ({ key }) { |
||||
const { suggestionsHidden } = this.state; |
||||
if (key === 'Escape') { |
||||
if (!suggestionsHidden) { |
||||
this.setState({ suggestionsHidden: true }); |
||||
} else { |
||||
document.querySelector('.ui').parentElement.focus(); |
||||
} |
||||
} |
||||
}, |
||||
|
||||
// Handles the pasting of images into the composer.
|
||||
paste (e) { |
||||
const { onPaste } = this.props; |
||||
let d; |
||||
if (onPaste && (d = e.clipboardData) && (d = d.files).length === 1) { |
||||
onPaste(d); |
||||
e.preventDefault(); |
||||
} |
||||
}, |
||||
|
||||
// Saves a reference to the textarea.
|
||||
refTextarea (textarea) { |
||||
this.textarea = textarea; |
||||
}, |
||||
}; |
||||
|
||||
// The component.
|
||||
export default class ComposerTextarea extends React.Component { |
||||
|
||||
// Constructor.
|
||||
constructor (props) { |
||||
super(props); |
||||
assignHandlers(this, handlers); |
||||
this.state = { |
||||
suggestionsHidden: false, |
||||
selectedSuggestion: 0, |
||||
lastToken: null, |
||||
tokenStart: 0, |
||||
}; |
||||
|
||||
// Instance variables.
|
||||
this.textarea = null; |
||||
} |
||||
|
||||
// When we receive new suggestions, we unhide the suggestions window
|
||||
// if we didn't have any suggestions before.
|
||||
componentWillReceiveProps (nextProps) { |
||||
const { suggestions } = this.props; |
||||
const { suggestionsHidden } = this.state; |
||||
if (nextProps.suggestions && nextProps.suggestions !== suggestions && nextProps.suggestions.size > 0 && suggestionsHidden) { |
||||
this.setState({ suggestionsHidden: false }); |
||||
} |
||||
} |
||||
|
||||
// Rendering.
|
||||
render () { |
||||
const { |
||||
blur, |
||||
change, |
||||
clickSuggestion, |
||||
keyDown, |
||||
keyUp, |
||||
paste, |
||||
refTextarea, |
||||
} = this.handlers; |
||||
const { |
||||
autoFocus, |
||||
disabled, |
||||
intl, |
||||
onPickEmoji, |
||||
suggestions, |
||||
value, |
||||
} = this.props; |
||||
const { |
||||
selectedSuggestion, |
||||
suggestionsHidden, |
||||
} = this.state; |
||||
|
||||
// The result.
|
||||
return ( |
||||
<div className='autosuggest-textarea'> |
||||
<label> |
||||
<span {...hiddenComponent}><FormattedMessage {...messages.placeholder} /></span> |
||||
<Textarea |
||||
aria-autocomplete='list' |
||||
autoFocus={autoFocus} |
||||
disabled={disabled} |
||||
inputRef={refTextarea} |
||||
onBlur={blur} |
||||
onChange={change} |
||||
onKeyDown={keyDown} |
||||
onKeyUp={keyUp} |
||||
onPaste={paste} |
||||
placeholder={intl.formatMessage(messages.placeholder)} |
||||
value={value} |
||||
style={{ direction: isRtl(value) ? 'rtl' : 'ltr' }} |
||||
/> |
||||
</label> |
||||
<EmojiPicker onPickEmoji={onPickEmoji} /> |
||||
<ComposerTextareaSuggestions |
||||
hidden={suggestionsHidden} |
||||
onSuggestionClick={clickSuggestion} |
||||
suggestions={suggestions} |
||||
value={selectedSuggestion} |
||||
/> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
||||
|
||||
// Props.
|
||||
ComposerTextarea.propTypes = { |
||||
autoFocus: PropTypes.bool, |
||||
disabled: PropTypes.bool, |
||||
intl: PropTypes.object.isRequired, |
||||
onChange: PropTypes.func, |
||||
onPaste: PropTypes.func, |
||||
onPickEmoji: PropTypes.func, |
||||
onSubmit: PropTypes.func, |
||||
onSuggestionsClearRequested: PropTypes.func, |
||||
onSuggestionsFetchRequested: PropTypes.func, |
||||
onSuggestionSelected: PropTypes.func, |
||||
suggestions: ImmutablePropTypes.list, |
||||
value: PropTypes.string, |
||||
}; |
||||
|
||||
// Default props.
|
||||
ComposerTextarea.defaultProps = { autoFocus: true }; |
@ -0,0 +1,41 @@ |
||||
// Package imports.
|
||||
import classNames from 'classnames'; |
||||
import PropTypes from 'prop-types'; |
||||
import React from 'react'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
|
||||
// Components.
|
||||
import ComposerTextareaSuggestionsItem from './item'; |
||||
|
||||
// The component.
|
||||
export default function ComposerTextareaSuggestions ({ |
||||
hidden, |
||||
onSuggestionClick, |
||||
suggestions, |
||||
value, |
||||
}) { |
||||
const computedClass = classNames('comoser--textarea--suggestions', { hidden: hidden || suggestions.isEmpty() }); |
||||
|
||||
return ( |
||||
<div className={computedClass}> |
||||
{!hidden ? suggestions.map( |
||||
(suggestion, index) => ( |
||||
<ComposerTextareaSuggestionsItem |
||||
index={index} |
||||
key={typeof suggestion === 'object' ? suggestion.id : suggestion} |
||||
onClick={onSuggestionClick} |
||||
selected={index === value} |
||||
suggestion={suggestion} |
||||
/> |
||||
) |
||||
) : null} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
ComposerTextareaSuggestions.propTypes = { |
||||
hidden: PropTypes.bool, |
||||
onSuggestionClick: PropTypes.func, |
||||
suggestions: ImmutablePropTypes.list, |
||||
value: PropTypes.string, |
||||
}; |
@ -0,0 +1,101 @@ |
||||
// Package imports.
|
||||
import classNames from 'classnames'; |
||||
import PropTypes from 'prop-types'; |
||||
import React from 'react'; |
||||
|
||||
// Components.
|
||||
import AccountContainer from 'flavours/glitch/containers/account_container'; |
||||
|
||||
// Utils.
|
||||
import { unicodeMapping } from 'flavours/glitch/util/emoji/emoji_unicode_mapping_light'; |
||||
import { assignHandlers } from 'flavours/glitch/util/react_helpers'; |
||||
|
||||
// Gets our asset host from the environment, if available.
|
||||
const assetHost = ((process || {}).env || {}).CDN_HOST || ''; |
||||
|
||||
// Handlers.
|
||||
const handlers = { |
||||
|
||||
// Handles a click on a suggestion.
|
||||
click (e) { |
||||
const { |
||||
index, |
||||
onClick, |
||||
} = this.props; |
||||
if (onClick) { |
||||
e.preventDefault(); |
||||
onClick(index); |
||||
} |
||||
}, |
||||
}; |
||||
|
||||
// The component.
|
||||
export default class ComposerTextareaSuggestionsItem extends React.Component { |
||||
|
||||
// Constructor.
|
||||
constructor (props) { |
||||
super(props); |
||||
assignHandlers(this, handlers); |
||||
} |
||||
|
||||
// Rendering.
|
||||
render () { |
||||
const { click } = this.handlers; |
||||
const { |
||||
selected, |
||||
suggestion, |
||||
} = this.props; |
||||
const computedClass = classNames('composer--textarea--suggestions--item', { selected }); |
||||
|
||||
// The result.
|
||||
return ( |
||||
<div |
||||
role='button' |
||||
tabIndex='0' |
||||
className={computedClass} |
||||
onMouseDown={click} |
||||
> |
||||
{ // If the suggestion is an object, then we render an emoji.
|
||||
// Otherwise, we render an account.
|
||||
typeof suggestion === 'object' ? function () { |
||||
const url = function () { |
||||
if (suggestion.custom) { |
||||
return suggestion.imageUrl; |
||||
} else { |
||||
const mapping = unicodeMapping[suggestion.native] || unicodeMapping[suggestion.native.replace(/\uFE0F$/, '')]; |
||||
if (!mapping) { |
||||
return null; |
||||
} |
||||
return `${assetHost}/emoji/${mapping.filename}.svg`; |
||||
} |
||||
}(); |
||||
return url ? ( |
||||
<div className='emoji'> |
||||
<img |
||||
alt={suggestion.native || suggestion.colons} |
||||
className='emojione' |
||||
src={url} |
||||
/> |
||||
{suggestion.colons} |
||||
</div> |
||||
) : null; |
||||
}() : ( |
||||
<AccountContainer |
||||
id={suggestion} |
||||
small |
||||
/> |
||||
) |
||||
} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
||||
|
||||
// Props.
|
||||
ComposerTextareaSuggestionsItem.propTypes = { |
||||
index: PropTypes.number, |
||||
onClick: PropTypes.func, |
||||
selected: PropTypes.bool, |
||||
suggestion: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), |
||||
}; |
@ -0,0 +1,54 @@ |
||||
// Package imports.
|
||||
import classNames from 'classnames'; |
||||
import PropTypes from 'prop-types'; |
||||
import React from 'react'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
|
||||
// Components.
|
||||
import ComposerUploadFormItem from './item'; |
||||
import ComposerUploadFormProgress from './progress'; |
||||
|
||||
// The component.
|
||||
export default function ComposerUploadForm ({ |
||||
active, |
||||
intl, |
||||
media, |
||||
onChangeDescription, |
||||
onRemove, |
||||
progress, |
||||
}) { |
||||
const computedClass = classNames('composer--upload_form', { uploading: active }); |
||||
|
||||
// We need `media` in order to be able to render.
|
||||
if (!media) { |
||||
return null; |
||||
} |
||||
|
||||
// The result.
|
||||
return ( |
||||
<div className={computedClass}> |
||||
{active ? <ComposerUploadFormProgress progress={progress} /> : null} |
||||
{media.map(item => ( |
||||
<ComposerUploadFormItem |
||||
description={item.get('description')} |
||||
key={item.get('id')} |
||||
id={item.get('id')} |
||||
intl={intl} |
||||
preview={item.get('preview_url')} |
||||
onChangeDescription={onChangeDescription} |
||||
onRemove={onRemove} |
||||
/> |
||||
))} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
// Props.
|
||||
ComposerUploadForm.propTypes = { |
||||
active: PropTypes.bool, |
||||
intl: PropTypes.object.isRequired, |
||||
media: ImmutablePropTypes.list, |
||||
onChangeDescription: PropTypes.func, |
||||
onRemove: PropTypes.func, |
||||
progress: PropTypes.number, |
||||
}; |
@ -0,0 +1,176 @@ |
||||
// Package imports.
|
||||
import classNames from 'classnames'; |
||||
import PropTypes from 'prop-types'; |
||||
import React from 'react'; |
||||
import { |
||||
FormattedMessage, |
||||
defineMessages, |
||||
} from 'react-intl'; |
||||
import spring from 'react-motion/lib/spring'; |
||||
|
||||
// Components.
|
||||
import IconButton from 'flavours/glitch/components/icon_button'; |
||||
|
||||
// Utils.
|
||||
import Motion from 'flavours/glitch/util/optional_motion'; |
||||
import { assignHandlers } from 'flavours/glitch/util/react_helpers'; |
||||
|
||||
// Messages.
|
||||
const messages = defineMessages({ |
||||
undo: { |
||||
defaultMessage: 'Undo', |
||||
id: 'upload_form.undo', |
||||
}, |
||||
description: { |
||||
defaultMessage: 'Describe for the visually impaired', |
||||
id: 'upload_form.description', |
||||
}, |
||||
}); |
||||
|
||||
// Handlers.
|
||||
const handlers = { |
||||
|
||||
// On blur, we save the description for the media item.
|
||||
blur () { |
||||
const { |
||||
id, |
||||
onChangeDescription, |
||||
} = this.props; |
||||
const { dirtyDescription } = this.state; |
||||
if (id && onChangeDescription && dirtyDescription !== null) { |
||||
this.setState({ |
||||
dirtyDescription: null, |
||||
focused: false, |
||||
}); |
||||
onChangeDescription(id, dirtyDescription); |
||||
} |
||||
}, |
||||
|
||||
// When the value of our description changes, we store it in the
|
||||
// temp value `dirtyDescription` in our state.
|
||||
change ({ target: { value } }) { |
||||
this.setState({ dirtyDescription: value }); |
||||
}, |
||||
|
||||
// Records focus on the media item.
|
||||
focus () { |
||||
this.setState({ focused: true }); |
||||
}, |
||||
|
||||
// Records the start of a hover over the media item.
|
||||
mouseEnter () { |
||||
this.setState({ hovered: true }); |
||||
}, |
||||
|
||||
// Records the end of a hover over the media item.
|
||||
mouseLeave () { |
||||
this.setState({ hovered: false }); |
||||
}, |
||||
|
||||
// Removes the media item.
|
||||
remove () { |
||||
const { |
||||
id, |
||||
onRemove, |
||||
} = this.props; |
||||
if (id && onRemove) { |
||||
onRemove(id); |
||||
} |
||||
}, |
||||
}; |
||||
|
||||
// The component.
|
||||
export default class ComposerUploadFormItem extends React.PureComponent { |
||||
|
||||
// Constructor.
|
||||
constructor (props) { |
||||
super(props); |
||||
assignHandlers(handlers); |
||||
this.state = { |
||||
hovered: false, |
||||
focused: false, |
||||
dirtyDescription: null, |
||||
}; |
||||
} |
||||
|
||||
// Rendering.
|
||||
render () { |
||||
const { |
||||
blur, |
||||
change, |
||||
focus, |
||||
mouseEnter, |
||||
mouseLeave, |
||||
remove, |
||||
} = this.handlers; |
||||
const { |
||||
description, |
||||
intl, |
||||
preview, |
||||
} = this.props; |
||||
const { |
||||
focused, |
||||
hovered, |
||||
dirtyDescription, |
||||
} = this.state; |
||||
const computedClass = classNames('composer--upload_form--item', { active: hovered || focused }); |
||||
|
||||
// The result.
|
||||
return ( |
||||
<div |
||||
className={computedClass} |
||||
onMouseEnter={mouseEnter} |
||||
onMouseLeave={mouseLeave} |
||||
> |
||||
<Motion |
||||
defaultStyle={{ scale: 0.8 }} |
||||
style={{ |
||||
scale: spring(1, { |
||||
stiffness: 180, |
||||
damping: 12, |
||||
}), |
||||
}} |
||||
> |
||||
{({ scale }) => ( |
||||
<div |
||||
style={{ |
||||
transform: `scale(${scale})`, |
||||
backgroundImage: preview ? `url(${preview})` : null, |
||||
}} |
||||
> |
||||
<IconButton |
||||
icon='times' |
||||
onClick={remove} |
||||
size={36} |
||||
title={intl.formatMessage(messages.undo)} |
||||
/> |
||||
<label> |
||||
<span style={{ display: 'none' }}><FormattedMessage {...messages.description} /></span> |
||||
<input |
||||
maxLength={420} |
||||
onBlur={blur} |
||||
onChange={change} |
||||
onFocus={focus} |
||||
placeholder={intl.formatMessage(messages.description)} |
||||
type='text' |
||||
value={dirtyDescription || description || ''} |
||||
/> |
||||
</label> |
||||
</div> |
||||
)} |
||||
</Motion> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
||||
|
||||
// Props.
|
||||
ComposerUploadFormItem.propTypes = { |
||||
description: PropTypes.string, |
||||
id: PropTypes.number, |
||||
intl: PropTypes.object.isRequired, |
||||
onChangeDescription: PropTypes.func, |
||||
onRemove: PropTypes.func, |
||||
preview: PropTypes.string, |
||||
}; |
@ -0,0 +1,52 @@ |
||||
// Package imports.
|
||||
import PropTypes from 'prop-types'; |
||||
import React from 'react'; |
||||
import { |
||||
defineMessages, |
||||
FormattedMessage, |
||||
} from 'react-intl'; |
||||
import spring from 'react-motion/lib/spring'; |
||||
|
||||
// Components.
|
||||
import Icon from 'flavours/glitch/components/icon'; |
||||
|
||||
// Utils.
|
||||
import Motion from 'flavours/glitch/util/optional_motion'; |
||||
|
||||
// Messages.
|
||||
const messages = defineMessages({ |
||||
upload: { |
||||
defaultMessage: 'Uploading...', |
||||
id: 'upload_progress.label', |
||||
}, |
||||
}); |
||||
|
||||
// The component.
|
||||
export default function ComposerUploadFormProgress ({ progress }) { |
||||
|
||||
// The result.
|
||||
return ( |
||||
<div className='composer--upload_form--progress'> |
||||
<Icon icon='upload' /> |
||||
<div className='message'> |
||||
<FormattedMessage {...messages.upload} /> |
||||
<div className='backdrop'> |
||||
<Motion |
||||
defaultStyle={{ width: 0 }} |
||||
style={{ width: spring(progress) }} |
||||
> |
||||
{({ width }) => |
||||
<div |
||||
className='tracker' |
||||
style={{ width: `${width}%` }} |
||||
/> |
||||
} |
||||
</Motion> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
// Props.
|
||||
ComposerUploadFormProgress.propTypes = { progress: PropTypes.number }; |
@ -0,0 +1,54 @@ |
||||
import React from 'react'; |
||||
import Motion from 'flavours/glitch/util/optional_motion'; |
||||
import spring from 'react-motion/lib/spring'; |
||||
import { defineMessages, FormattedMessage } from 'react-intl'; |
||||
|
||||
// This is the spring used with our motion.
|
||||
const motionSpring = spring(1, { damping: 35, stiffness: 400 }); |
||||
|
||||
// Messages.
|
||||
const messages = defineMessages({ |
||||
disclaimer: { |
||||
defaultMessage: 'Your account is not {locked}. Anyone can follow you to view your follower-only posts.', |
||||
id: 'compose_form.lock_disclaimer', |
||||
}, |
||||
locked: { |
||||
defaultMessage: 'locked', |
||||
id: 'compose_form.lock_disclaimer.lock', |
||||
}, |
||||
}); |
||||
|
||||
// The component.
|
||||
export default function ComposerWarning () { |
||||
return ( |
||||
<Motion |
||||
defaultStyle={{ |
||||
opacity: 0, |
||||
scaleX: 0.85, |
||||
scaleY: 0.75, |
||||
}} |
||||
style={{ |
||||
opacity: motionSpring, |
||||
scaleX: motionSpring, |
||||
scaleY: motionSpring, |
||||
}} |
||||
> |
||||
{({ opacity, scaleX, scaleY }) => ( |
||||
<div |
||||
className='composer--warning' |
||||
style={{ |
||||
opacity: opacity, |
||||
transform: `scale(${scaleX}, ${scaleY})`, |
||||
}} |
||||
> |
||||
<FormattedMessage |
||||
{...messages.disclaimer} |
||||
values={{ locked: <a href='/settings/profile'><FormattedMessage {...messages.locked} /></a> }} |
||||
/> |
||||
</div> |
||||
)} |
||||
</Motion> |
||||
); |
||||
} |
||||
|
||||
ComposerWarning.propTypes = {}; |
@ -0,0 +1,198 @@ |
||||
// Package imports.
|
||||
import PropTypes from 'prop-types'; |
||||
import React from 'react'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import { injectIntl, defineMessages } from 'react-intl'; |
||||
import spring from 'react-motion/lib/spring'; |
||||
import { connect } from 'react-redux'; |
||||
import { Link } from 'react-router-dom'; |
||||
|
||||
// Actions.
|
||||
import { changeComposing } from 'flavours/glitch/actions/compose'; |
||||
import { changeLocalSetting } from 'flavours/glitch/actions/local_settings'; |
||||
import { openModal } from 'flavours/glitch/actions/modal'; |
||||
|
||||
// Components.
|
||||
import Icon from 'flavours/glitch/components/icon'; |
||||
import Compose from 'flavours/glitch/features/compose'; |
||||
import NavigationContainer from './containers/navigation_container'; |
||||
import SearchContainer from './containers/search_container'; |
||||
import SearchResultsContainer from './containers/search_results_container'; |
||||
|
||||
// Utils.
|
||||
import Motion from 'flavours/glitch/util/optional_motion'; |
||||
import { |
||||
assignHandlers, |
||||
conditionalRender, |
||||
} from 'flavours/glitch/util/react_helpers'; |
||||
|
||||
// Messages.
|
||||
const messages = defineMessages({ |
||||
community: { |
||||
defaultMessage: 'Local timeline', |
||||
id: 'navigation_bar.community_timeline', |
||||
}, |
||||
home_timeline: { |
||||
defaultMessage: 'Home', |
||||
id: 'tabs_bar.home', |
||||
}, |
||||
logout: { |
||||
defaultMessage: 'Logout', |
||||
id: 'navigation_bar.logout', |
||||
}, |
||||
notifications: { |
||||
defaultMessage: 'Notifications', |
||||
id: 'tabs_bar.notifications', |
||||
}, |
||||
public: { |
||||
defaultMessage: 'Federated timeline', |
||||
id: 'navigation_bar.public_timeline', |
||||
}, |
||||
settings: { |
||||
defaultMessage: 'App settings', |
||||
id: 'navigation_bar.app_settings', |
||||
}, |
||||
start: { |
||||
defaultMessage: 'Getting started', |
||||
id: 'getting_started.heading', |
||||
}, |
||||
}); |
||||
|
||||
// State mapping.
|
||||
const mapStateToProps = state => ({ |
||||
columns: state.getIn(['settings', 'columns']), |
||||
showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']), |
||||
}); |
||||
|
||||
// Dispatch mapping.
|
||||
const mapDispatchToProps = dispatch => ({ |
||||
onBlur () { |
||||
dispatch(changeComposing(false)); |
||||
}, |
||||
onFocus () { |
||||
dispatch(changeComposing(true)); |
||||
}, |
||||
onSettingsOpen () { |
||||
dispatch(openModal('SETTINGS', {})); |
||||
}, |
||||
}); |
||||
|
||||
// The component.
|
||||
@connect(mapStateToProps, mapDispatchToProps) |
||||
@injectIntl |
||||
export default function Drawer ({ |
||||
columns, |
||||
intl, |
||||
multiColumn, |
||||
onBlur, |
||||
onFocus, |
||||
onSettingsOpen, |
||||
showSearch, |
||||
}) { |
||||
|
||||
// Only renders the component if the column isn't being shown.
|
||||
const renderForColumn = conditionalRender.bind( |
||||
columnId => !columns.some(column => column.get('id') === columnId) |
||||
); |
||||
|
||||
// The result.
|
||||
return ( |
||||
<div className='drawer'> |
||||
{multiColumn ? ( |
||||
<nav className='drawer__header'> |
||||
<Link |
||||
aria-label={intl.formatMessage(messages.start)} |
||||
className='drawer__tab' |
||||
title={intl.formatMessage(messages.start)} |
||||
to='/getting-started' |
||||
><Icon icon='asterisk' /></Link> |
||||
{renderForColumn('HOME', ( |
||||
<Link |
||||
aria-label={intl.formatMessage(messages.home_timeline)} |
||||
className='drawer__tab' |
||||
title={intl.formatMessage(messages.home_timeline)} |
||||
to='/timelines/home' |
||||
><Icon icon='home' /></Link> |
||||
))} |
||||
{renderForColumn('NOTIFICATIONS', ( |
||||
<Link |
||||
aria-label={intl.formatMessage(messages.notifications)} |
||||
className='drawer__tab' |
||||
title={intl.formatMessage(messages.notifications)} |
||||
to='/notifications' |
||||
><Icon icon='bell' /></Link> |
||||
))} |
||||
{renderForColumn('COMMUNITY', ( |
||||
<Link |
||||
aria-label={intl.formatMessage(messages.community)} |
||||
className='drawer__tab' |
||||
title={intl.formatMessage(messages.community)} |
||||
to='/timelines/public/local' |
||||
><Icon icon='users' /></Link> |
||||
))} |
||||
{renderForColumn('PUBLIC', ( |
||||
<Link |
||||
aria-label={intl.formatMessage(messages.public)} |
||||
className='drawer__tab' |
||||
title={intl.formatMessage(messages.public)} |
||||
to='/timelines/public' |
||||
><Icon icon='globe' /></Link> |
||||
))} |
||||
<a |
||||
aria-label={intl.formatMessage(messages.settings)} |
||||
className='drawer__tab' |
||||
onClick={settings} |
||||
role='button' |
||||
title={intl.formatMessage(messages.settings)} |
||||
tabIndex='0' |
||||
><Icon icon='cogs' /></a> |
||||
<a |
||||
aria-label={intl.formatMessage(messages.logout)} |
||||
className='drawer__tab' |
||||
data-method='delete' |
||||
href='/auth/sign_out' |
||||
title={intl.formatMessage(messages.logout)} |
||||
><Icon icon='sign-out' /></a> |
||||
</nav> |
||||
) : null} |
||||
<SearchContainer /> |
||||
<div className='drawer__pager'> |
||||
<div |
||||
className='drawer__inner scrollable optionally-scrollable' |
||||
onFocus={focus} |
||||
> |
||||
<NavigationContainer onClose={blur} /> |
||||
<Compose /> |
||||
</div> |
||||
<Motion |
||||
defaultStyle={{ x: -100 }} |
||||
style={{ |
||||
x: spring(showSearch ? 0 : -100, { |
||||
stiffness: 210, |
||||
damping: 20, |
||||
}) |
||||
}} |
||||
> |
||||
{({ x }) => ( |
||||
<div |
||||
className='drawer__inner darker scrollable optionally-scrollable' |
||||
style={{ |
||||
transform: `translateX(${x}%)`, |
||||
visibility: x === -100 ? 'hidden' : 'visible' |
||||
}} |
||||
><SearchResultsContainer /></div> |
||||
)} |
||||
</Motion> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
// Props.
|
||||
Drawer.propTypes = { |
||||
dispatch: PropTypes.func.isRequired, |
||||
columns: ImmutablePropTypes.list.isRequired, |
||||
multiColumn: PropTypes.bool, |
||||
showSearch: PropTypes.bool, |
||||
intl: PropTypes.object.isRequired, |
||||
}; |
@ -0,0 +1,6 @@ |
||||
// Package imports.
|
||||
import detectPassiveEvents from 'detect-passive-events'; |
||||
|
||||
// This will either be a passive lister options object (if passive
|
||||
// events are supported), or `false`.
|
||||
export const withPassive = detectPassiveEvents.hasSupport ? { passive: true } : false; |
@ -0,0 +1,21 @@ |
||||
// This function binds the given `handlers` to the `target`.
|
||||
export function assignHandlers (target, handlers) { |
||||
if (!target || !handlers) { |
||||
return; |
||||
} |
||||
|
||||
// We just bind each handler to the `target`.
|
||||
const handle = target.handlers = {}; |
||||
handlers.keys().forEach( |
||||
key => handle.key = key.bind(target) |
||||
); |
||||
} |
||||
|
||||
// This function only returns the component if the result of calling
|
||||
// `test` with `data` is `true`. Useful with funciton binding.
|
||||
export function conditionalRender (test, data, component) { |
||||
return test ? component : null; |
||||
} |
||||
|
||||
// This object provides props to make the component not visible.
|
||||
export const hiddenComponent = { style: { display: 'none' } }; |
@ -0,0 +1,7 @@ |
||||
// Merges react-redux props.
|
||||
export function mergeProps (stateProps, dispatchProps, ownProps) { |
||||
Object.assign({}, ownProps, { |
||||
dispatch: Object.assign({}, dispatchProps, ownProps.dispatch || {}), |
||||
state: Object.assign({}, stateProps, ownProps.state || {}), |
||||
}); |
||||
} |
Loading…
Reference in new issue