Merge pull request #293 from glitch-soc/compose-refactor

Compose refactor
signup-info-prompt
beatrix 2018-01-05 18:29:08 -05:00 committed by GitHub
commit faf20eeaa4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
82 changed files with 4122 additions and 3263 deletions

View File

@ -38,7 +38,6 @@ export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE'; export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE'; export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE';
export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE'; export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE';
export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE';
export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT'; export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT';
@ -316,21 +315,14 @@ export function readyComposeSuggestionsAccounts(token, accounts) {
export function selectComposeSuggestion(position, token, suggestion) { export function selectComposeSuggestion(position, token, suggestion) {
return (dispatch, getState) => { return (dispatch, getState) => {
let completion, startPosition; const completion = typeof suggestion === 'object' && suggestion.id ? (
dispatch(useEmoji(suggestion)),
if (typeof suggestion === 'object' && suggestion.id) { suggestion.native || suggestion.colons
completion = suggestion.native || suggestion.colons; ) : '@' + getState().getIn(['accounts', suggestion, 'acct']);
startPosition = position - 1;
dispatch(useEmoji(suggestion));
} else {
completion = getState().getIn(['accounts', suggestion, 'acct']);
startPosition = position;
}
dispatch({ dispatch({
type: COMPOSE_SUGGESTION_SELECT, type: COMPOSE_SUGGESTION_SELECT,
position: startPosition, position,
token, token,
completion, completion,
}); });
@ -389,10 +381,3 @@ export function insertEmojiCompose(position, emoji) {
emoji, emoji,
}; };
}; };
export function changeComposing(value) {
return {
type: COMPOSE_COMPOSING_CHANGE,
value,
};
}

View File

@ -30,6 +30,7 @@ export default class Account extends ImmutablePureComponent {
onMuteNotifications: PropTypes.func.isRequired, onMuteNotifications: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
hidden: PropTypes.bool, hidden: PropTypes.bool,
small: PropTypes.bool,
}; };
handleFollow = () => { handleFollow = () => {
@ -53,7 +54,12 @@ export default class Account extends ImmutablePureComponent {
} }
render () { render () {
const { account, intl, hidden } = this.props; const {
account,
hidden,
intl,
small,
} = this.props;
if (!account) { if (!account) {
return <div />; return <div />;
@ -70,7 +76,7 @@ export default class Account extends ImmutablePureComponent {
let buttons; let buttons;
if (account.get('id') !== me && account.get('relationship', null) !== null) { if (account.get('id') !== me && !small && account.get('relationship', null) !== null) {
const following = account.getIn(['relationship', 'following']); const following = account.getIn(['relationship', 'following']);
const requested = account.getIn(['relationship', 'requested']); const requested = account.getIn(['relationship', 'requested']);
const blocking = account.getIn(['relationship', 'blocking']); const blocking = account.getIn(['relationship', 'blocking']);
@ -98,17 +104,23 @@ export default class Account extends ImmutablePureComponent {
} }
} }
return ( return small ? (
<div className='account small'>
<div className='account__avatar-wrapper'><Avatar account={account} size={18} /></div>
<DisplayName account={account} />
</div>
) : (
<div className='account'> <div className='account'>
<div className='account__wrapper'> <div className='account__wrapper'>
<Permalink key={account.get('id')} className='account__display-name' href={account.get('url')} to={`/accounts/${account.get('id')}`}> <Permalink key={account.get('id')} className='account__display-name' href={account.get('url')} to={`/accounts/${account.get('id')}`}>
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div> <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
<DisplayName account={account} /> <DisplayName account={account} />
</Permalink> </Permalink>
{buttons ?
<div className='account__relationship'> <div className='account__relationship'>
{buttons} {buttons}
</div> </div>
: null}
</div> </div>
</div> </div>
); );

View File

@ -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>
);
}
}

View File

@ -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>
);
}
}

View File

@ -1,3 +1,4 @@
import classNames from 'classnames';
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
@ -7,6 +8,7 @@ export default class Avatar extends React.PureComponent {
static propTypes = { static propTypes = {
account: ImmutablePropTypes.map.isRequired, account: ImmutablePropTypes.map.isRequired,
className: PropTypes.string,
size: PropTypes.number.isRequired, size: PropTypes.number.isRequired,
style: PropTypes.object, style: PropTypes.object,
inline: PropTypes.bool, inline: PropTypes.bool,
@ -34,17 +36,19 @@ export default class Avatar extends React.PureComponent {
} }
render () { render () {
const { account, size, animate, inline } = this.props; const {
account,
animate,
className,
inline,
size,
} = this.props;
const { hovering } = this.state; const { hovering } = this.state;
const src = account.get('avatar'); const src = account.get('avatar');
const staticSrc = account.get('avatar_static'); const staticSrc = account.get('avatar_static');
let className = 'account__avatar'; const computedClass = classNames('account__avatar', { 'account__avatar-inline': inline }, className);
if (inline) {
className = className + ' account__avatar-inline';
}
const style = { const style = {
...this.props.style, ...this.props.style,
@ -61,7 +65,7 @@ export default class Avatar extends React.PureComponent {
return ( return (
<div <div
className={className} className={computedClass}
onMouseEnter={this.handleMouseEnter} onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave} onMouseLeave={this.handleMouseLeave}
style={style} style={style}

View File

@ -1,3 +1,5 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
@ -5,13 +7,19 @@ export default class DisplayName extends React.PureComponent {
static propTypes = { static propTypes = {
account: ImmutablePropTypes.map.isRequired, account: ImmutablePropTypes.map.isRequired,
className: PropTypes.string,
}; };
render () { render () {
const displayNameHtml = { __html: this.props.account.get('display_name_html') }; const {
account,
className,
} = this.props;
const computedClass = classNames('display-name', className);
const displayNameHtml = { __html: account.get('display_name_html') };
return ( return (
<span className='display-name'> <span className={computedClass}>
<strong className='display-name__html' dangerouslySetInnerHTML={displayNameHtml} /> <span className='display-name__account'>@{this.props.account.get('acct')}</span> <strong className='display-name__html' dangerouslySetInnerHTML={displayNameHtml} /> <span className='display-name__account'>@{this.props.account.get('acct')}</span>
</span> </span>
); );

View File

@ -133,8 +133,13 @@ export default class Dropdown extends React.PureComponent {
this.props.onModalOpen({ this.props.onModalOpen({
status, status,
actions: items, actions: items.map(
onClick: this.handleItemClick, (item, i) => item ? {
...item,
name: `${item.text}-${i}`,
onClick: this.handleItemClick.bind(i),
} : null
),
}); });
return; return;
@ -162,8 +167,7 @@ export default class Dropdown extends React.PureComponent {
} }
} }
handleItemClick = e => { handleItemClick = (i, e) => {
const i = Number(e.currentTarget.getAttribute('data-index'));
const { action, to } = this.props.items[i]; const { action, to } = this.props.items[i];
this.handleClose(); this.handleClose();

View File

@ -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,
};

View File

@ -0,0 +1,97 @@
// Inspired by <CommonLink> from Mastodon GO!
// ~ 😘 kibi!
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
// Utils.
import { assignHandlers } from 'flavours/glitch/util/react_helpers';
// Handlers.
const handlers = {
// We don't handle clicks that are made with modifiers, since these
// often have special browser meanings (eg, "open in new tab").
click (e) {
const { onClick } = this.props;
if (!onClick || e.button || e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) {
return;
}
onClick(e);
e.preventDefault(); // Prevents following of the link
},
};
// The component.
export default class Link extends React.PureComponent {
// Constructor.
constructor (props) {
super(props);
assignHandlers(this, handlers);
}
// Rendering.
render () {
const { click } = this.handlers;
const {
children,
className,
href,
onClick,
role,
title,
...rest
} = this.props;
const computedClass = classNames('link', className, `role-${role}`);
// We assume that our `onClick` is a routing function and give it
// the qualities of a link even if no `href` is provided. However,
// if we have neither an `onClick` or an `href`, our link is
// purely presentational.
const conditionalProps = {};
if (href) {
conditionalProps.href = href;
conditionalProps.onClick = click;
} else if (onClick) {
conditionalProps.onClick = click;
conditionalProps.role = 'link';
conditionalProps.tabIndex = 0;
} else {
conditionalProps.role = 'presentation';
}
// If we were provided a `role` it overwrites any that we may have
// set above. This can be used for "links" which are actually
// buttons.
if (role) {
conditionalProps.role = role;
}
// Rendering. We set `rel='noopener'` for user privacy, and our
// `target` as `'_blank'`.
return (
<a
className={computedClass}
{...conditionalProps}
rel='noopener'
target='_blank'
title={title}
{...rest}
>{children}</a>
);
}
}
// Props.
Link.propTypes = {
children: PropTypes.node,
className: PropTypes.string,
href: PropTypes.string, // The link destination
onClick: PropTypes.func, // A function to call instead of opening the link
role: PropTypes.string, // An ARIA role for the link
title: PropTypes.string, // A title for the link
};

View File

@ -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>
);
}
}

View File

@ -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>
);
}
}

View File

@ -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>
);
}
}

View File

@ -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>
);
}
}

View File

@ -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);
}
}

View File

@ -1,286 +0,0 @@
import React from 'react';
import CharacterCounter from './character_counter';
import Button from 'flavours/glitch/components/button';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import ReplyIndicatorContainer from '../containers/reply_indicator_container';
import AutosuggestTextarea from 'flavours/glitch/components/autosuggest_textarea';
import { defineMessages, injectIntl } from 'react-intl';
import Collapsable from 'flavours/glitch/components/collapsable';
import SpoilerButtonContainer from '../containers/spoiler_button_container';
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
import ComposeAdvancedOptionsContainer from '../containers/advanced_options_container';
import SensitiveButtonContainer from '../containers/sensitive_button_container';
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
import UploadFormContainer from '../containers/upload_form_container';
import WarningContainer from '../containers/warning_container';
import { isMobile } from 'flavours/glitch/util/is_mobile';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { length } from 'stringz';
import { countableText } from 'flavours/glitch/util/counter';
import ComposeAttachOptions from './attach_options';
import initialState from 'flavours/glitch/util/initial_state';
const maxChars = initialState.max_toot_chars;
const messages = defineMessages({
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' },
publish: { id: 'compose_form.publish', defaultMessage: 'Toot' },
publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
});
@injectIntl
export default class ComposeForm extends ImmutablePureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
text: PropTypes.string.isRequired,
suggestion_token: PropTypes.string,
suggestions: ImmutablePropTypes.list,
spoiler: PropTypes.bool,
privacy: PropTypes.string,
advanced_options: ImmutablePropTypes.contains({
do_not_federate: PropTypes.bool,
}),
spoiler_text: PropTypes.string,
focusDate: PropTypes.instanceOf(Date),
preselectDate: PropTypes.instanceOf(Date),
is_submitting: PropTypes.bool,
is_uploading: PropTypes.bool,
onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
onClearSuggestions: PropTypes.func.isRequired,
onFetchSuggestions: PropTypes.func.isRequired,
onPrivacyChange: PropTypes.func.isRequired,
onSuggestionSelected: PropTypes.func.isRequired,
onChangeSpoilerText: PropTypes.func.isRequired,
onPaste: PropTypes.func.isRequired,
onPickEmoji: PropTypes.func.isRequired,
showSearch: PropTypes.bool,
settings : ImmutablePropTypes.map.isRequired,
};
static defaultProps = {
showSearch: false,
};
handleChange = (e) => {
this.props.onChange(e.target.value);
}
handleKeyDown = (e) => {
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
this.handleSubmit();
}
}
handleSubmit2 = () => {
this.props.onPrivacyChange(this.props.settings.get('side_arm'));
this.handleSubmit();
}
handleSubmit = () => {
if (this.props.text !== this.autosuggestTextarea.textarea.value) {
// Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
// Update the state to match the current text
this.props.onChange(this.autosuggestTextarea.textarea.value);
}
this.props.onSubmit();
}
onSuggestionsClearRequested = () => {
this.props.onClearSuggestions();
}
onSuggestionsFetchRequested = (token) => {
this.props.onFetchSuggestions(token);
}
onSuggestionSelected = (tokenStart, token, value) => {
this._restoreCaret = null;
this.props.onSuggestionSelected(tokenStart, token, value);
}
handleChangeSpoilerText = (e) => {
this.props.onChangeSpoilerText(e.target.value);
}
componentWillReceiveProps (nextProps) {
// If this is the update where we've finished uploading,
// save the last caret position so we can restore it below!
if (!nextProps.is_uploading && this.props.is_uploading) {
this._restoreCaret = this.autosuggestTextarea.textarea.selectionStart;
}
}
componentDidUpdate (prevProps) {
// This statement does several things:
// - If we're beginning a reply, and,
// - Replying to zero or one users, places the cursor at the end of the textbox.
// - Replying to more than one user, selects any usernames past the first;
// this provides a convenient shortcut to drop everyone else from the conversation.
// - If we've just finished uploading an image, and have a saved caret position,
// restores the cursor to that position after the text changes!
if (this.props.focusDate !== prevProps.focusDate || (prevProps.is_uploading && !this.props.is_uploading && typeof this._restoreCaret === 'number')) {
let selectionEnd, selectionStart;
if (this.props.preselectDate !== prevProps.preselectDate) {
selectionEnd = this.props.text.length;
selectionStart = this.props.text.search(/\s/) + 1;
} else if (typeof this._restoreCaret === 'number') {
selectionStart = this._restoreCaret;
selectionEnd = this._restoreCaret;
} else {
selectionEnd = this.props.text.length;
selectionStart = selectionEnd;
}
this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd);
this.autosuggestTextarea.textarea.focus();
} else if(prevProps.is_submitting && !this.props.is_submitting) {
this.autosuggestTextarea.textarea.focus();
}
}
setAutosuggestTextarea = (c) => {
this.autosuggestTextarea = c;
}
handleEmojiPick = (data) => {
const position = this.autosuggestTextarea.textarea.selectionStart;
const emojiChar = data.native;
this._restoreCaret = position + emojiChar.length + 1;
this.props.onPickEmoji(position, data);
}
render () {
const { intl, onPaste, showSearch } = this.props;
const disabled = this.props.is_submitting;
const maybeEye = (this.props.advanced_options && this.props.advanced_options.do_not_federate) ? ' 👁️' : '';
const text = [this.props.spoiler_text, countableText(this.props.text), maybeEye].join('');
const secondaryVisibility = this.props.settings.get('side_arm');
let showSideArm = secondaryVisibility !== 'none';
let publishText = '';
let publishText2 = '';
let title = '';
let title2 = '';
const privacyIcons = {
none: '',
public: 'globe',
unlisted: 'unlock-alt',
private: 'lock',
direct: 'envelope',
};
title = `${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${this.props.privacy}.short` })}`;
if (showSideArm) {
// Enhanced behavior with dual toot buttons
publishText = (
<span>
{
<i
className={`fa fa-${privacyIcons[this.props.privacy]}`}
style={{ paddingRight: '5px' }}
/>
}{intl.formatMessage(messages.publish)}
</span>
);
title2 = `${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${secondaryVisibility}.short` })}`;
publishText2 = (
<i
className={`fa fa-${privacyIcons[secondaryVisibility]}`}
aria-label={title2}
/>
);
} else {
// Original vanilla behavior - no icon if public or unlisted
if (this.props.privacy === 'private' || this.props.privacy === 'direct') {
publishText = <span className='compose-form__publish-private'><i className='fa fa-lock' /> {intl.formatMessage(messages.publish)}</span>;
} else {
publishText = this.props.privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish);
}
}
const submitDisabled = disabled || this.props.is_uploading || length(text) > maxChars || (text.length !== 0 && text.trim().length === 0);
return (
<div className='compose-form'>
<Collapsable isVisible={this.props.spoiler} fullHeight={50}>
<div className='spoiler-input'>
<label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.spoiler_placeholder)}</span>
<input placeholder={intl.formatMessage(messages.spoiler_placeholder)} value={this.props.spoiler_text} onChange={this.handleChangeSpoilerText} onKeyDown={this.handleKeyDown} type='text' className='spoiler-input__input' id='cw-spoiler-input' />
</label>
</div>
</Collapsable>
<WarningContainer />
<ReplyIndicatorContainer />
<div className='compose-form__autosuggest-wrapper'>
<AutosuggestTextarea
ref={this.setAutosuggestTextarea}
placeholder={intl.formatMessage(messages.placeholder)}
disabled={disabled}
value={this.props.text}
onChange={this.handleChange}
suggestions={this.props.suggestions}
onKeyDown={this.handleKeyDown}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSuggestionSelected}
onPaste={onPaste}
autoFocus={!showSearch && !isMobile(window.innerWidth)}
/>
<EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
</div>
<div className='compose-form__modifiers'>
<UploadFormContainer />
</div>
<div className='compose-form__buttons'>
<ComposeAttachOptions />
<SensitiveButtonContainer />
<div className='compose-form__buttons-separator' />
<PrivacyDropdownContainer />
<SpoilerButtonContainer />
<ComposeAdvancedOptionsContainer />
</div>
<div className='compose-form__publish'>
<div className='character-counter__wrapper'><CharacterCounter max={maxChars} text={text} /></div>
<div className='compose-form__publish-button-wrapper'>
{
showSideArm ?
<Button
className='compose-form__publish__side-arm'
text={publishText2}
title={title2}
onClick={this.handleSubmit2}
disabled={submitDisabled}
/> : ''
}
<Button
className='compose-form__publish__primary'
text={publishText}
title={title}
onClick={this.handleSubmit}
disabled={submitDisabled}
/>
</div>
</div>
</div>
);
}
}

View File

@ -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>
);
}
}

View File

@ -1,38 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Avatar from 'flavours/glitch/components/avatar';
import IconButton from 'flavours/glitch/components/icon_button';
import Permalink from 'flavours/glitch/components/permalink';
import { FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
export default class NavigationBar extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
onClose: PropTypes.func.isRequired,
};
render () {
return (
<div className='navigation-bar'>
<Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
<span style={{ display: 'none' }}>{this.props.account.get('acct')}</span>
<Avatar account={this.props.account} size={40} />
</Permalink>
<div className='navigation-bar__profile'>
<Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
<strong className='navigation-bar__profile-account'>@{this.props.account.get('acct')}</strong>
</Permalink>
<a href='/settings/profile' className='navigation-bar__profile-edit'><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a>
</div>
<IconButton title='' icon='close' onClick={this.props.onClose} />
</div>
);
}
}

View File

@ -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>
);
}
}

View File

@ -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>
);
}
}

View File

@ -1,129 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import Overlay from 'react-overlays/lib/Overlay';
import Motion from 'flavours/glitch/util/optional_motion';
import spring from 'react-motion/lib/spring';
const messages = defineMessages({
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
});
class SearchPopout extends React.PureComponent {
static propTypes = {
style: PropTypes.object,
};
render () {
const { style } = this.props;
return (
<div style={{ ...style, position: 'absolute', width: 285 }}>
<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='search-popout' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
<h4><FormattedMessage id='search_popout.search_format' defaultMessage='Advanced search format' /></h4>
<ul>
<li><em>#example</em> <FormattedMessage id='search_popout.tips.hashtag' defaultMessage='hashtag' /></li>
<li><em>@username@domain</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li>
<li><em>URL</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li>
<li><em>URL</em> <FormattedMessage id='search_popout.tips.status' defaultMessage='status' /></li>
</ul>
<FormattedMessage id='search_popout.tips.text' defaultMessage='Simple text returns matching display names, usernames and hashtags' />
</div>
)}
</Motion>
</div>
);
}
}
@injectIntl
export default class Search extends React.PureComponent {
static propTypes = {
value: PropTypes.string.isRequired,
submitted: PropTypes.bool,
onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
onClear: PropTypes.func.isRequired,
onShow: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
state = {
expanded: false,
};
handleChange = (e) => {
this.props.onChange(e.target.value);
}
handleClear = (e) => {
e.preventDefault();
if (this.props.value.length > 0 || this.props.submitted) {
this.props.onClear();
}
}
handleKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.props.onSubmit();
} else if (e.key === 'Escape') {
document.querySelector('.ui').parentElement.focus();
}
}
noop () {
}
handleFocus = () => {
this.setState({ expanded: true });
this.props.onShow();
}
handleBlur = () => {
this.setState({ expanded: false });
}
render () {
const { intl, value, submitted } = this.props;
const { expanded } = this.state;
const hasValue = value.length > 0 || submitted;
return (
<div className='search'>
<label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.placeholder)}</span>
<input
className='search__input'
type='text'
placeholder={intl.formatMessage(messages.placeholder)}
value={value}
onChange={this.handleChange}
onKeyUp={this.handleKeyDown}
onFocus={this.handleFocus}
onBlur={this.handleBlur}
/>
</label>
<div role='button' tabIndex='0' className='search__icon' onClick={this.handleClear}>
<i className={`fa fa-search ${hasValue ? '' : 'active'}`} />
<i aria-label={intl.formatMessage(messages.placeholder)} className={`fa fa-times-circle ${hasValue ? 'active' : ''}`} />
</div>
<Overlay show={expanded && !hasValue} placement='bottom' target={this}>
<SearchPopout />
</Overlay>
</div>
);
}
}

View File

@ -1,65 +0,0 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { FormattedMessage } from 'react-intl';
import AccountContainer from 'flavours/glitch/containers/account_container';
import StatusContainer from 'flavours/glitch/containers/status_container';
import { Link } from 'react-router-dom';
import ImmutablePureComponent from 'react-immutable-pure-component';
export default class SearchResults extends ImmutablePureComponent {
static propTypes = {
results: ImmutablePropTypes.map.isRequired,
};
render () {
const { results } = this.props;
let accounts, statuses, hashtags;
let count = 0;
if (results.get('accounts') && results.get('accounts').size > 0) {
count += results.get('accounts').size;
accounts = (
<div className='search-results__section'>
{results.get('accounts').map(accountId => <AccountContainer key={accountId} id={accountId} />)}
</div>
);
}
if (results.get('statuses') && results.get('statuses').size > 0) {
count += results.get('statuses').size;
statuses = (
<div className='search-results__section'>
{results.get('statuses').map(statusId => <StatusContainer key={statusId} id={statusId} />)}
</div>
);
}
if (results.get('hashtags') && results.get('hashtags').size > 0) {
count += results.get('hashtags').size;
hashtags = (
<div className='search-results__section'>
{results.get('hashtags').map(hashtag =>
<Link key={hashtag} className='search-results__hashtag' to={`/timelines/tag/${hashtag}`}>
#{hashtag}
</Link>
)}
</div>
);
}
return (
<div className='search-results'>
<div className='search-results__header'>
<FormattedMessage id='search_results.total' defaultMessage='{count, number} {count, plural, one {result} other {results}}' values={{ count }} />
</div>
{accounts}
{statuses}
{hashtags}
</div>
);
}
}

View File

@ -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>
);
}
}

View File

@ -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>
);
}
}

View File

@ -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>
);
}
}

View File

@ -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>
);
}
}

View File

@ -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>
);
}
}

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -1,11 +0,0 @@
import { connect } from 'react-redux';
import NavigationBar from '../components/navigation_bar';
import { me } from 'flavours/glitch/util/initial_state';
const mapStateToProps = state => {
return {
account: state.getIn(['accounts', me]),
};
};
export default connect(mapStateToProps)(NavigationBar);

View File

@ -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);

View File

@ -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);

View File

@ -1,35 +0,0 @@
import { connect } from 'react-redux';
import {
changeSearch,
clearSearch,
submitSearch,
showSearch,
} from 'flavours/glitch/actions/search';
import Search from '../components/search';
const mapStateToProps = state => ({
value: state.getIn(['search', 'value']),
submitted: state.getIn(['search', 'submitted']),
});
const mapDispatchToProps = dispatch => ({
onChange (value) {
dispatch(changeSearch(value));
},
onClear () {
dispatch(clearSearch());
},
onSubmit () {
dispatch(submitSearch());
},
onShow () {
dispatch(showSearch());
},
});
export default connect(mapStateToProps, mapDispatchToProps)(Search);

View File

@ -1,8 +0,0 @@
import { connect } from 'react-redux';
import SearchResults from '../components/search_results';
const mapStateToProps = state => ({
results: state.getIn(['search', 'results']),
});
export default connect(mapStateToProps)(SearchResults);

View File

@ -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));

View File

@ -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));

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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>
);
}
}

View File

@ -0,0 +1,423 @@
// Package imports.
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
// Actions.
import {
cancelReplyCompose,
changeCompose,
changeComposeSensitivity,
changeComposeSpoilerText,
changeComposeSpoilerness,
changeComposeVisibility,
changeUploadCompose,
clearComposeSuggestions,
fetchComposeSuggestions,
insertEmojiCompose,
selectComposeSuggestion,
submitCompose,
toggleComposeAdvancedOption,
undoUploadCompose,
uploadCompose,
} from 'flavours/glitch/actions/compose';
import {
closeModal,
openModal,
} from 'flavours/glitch/actions/modal';
// Components.
import ComposerOptions from './options';
import ComposerPublisher from './publisher';
import ComposerReply from './reply';
import ComposerSpoiler from './spoiler';
import ComposerTextarea from './textarea';
import ComposerUploadForm from './upload_form';
import ComposerWarning from './warning';
// Utils.
import { countableText } from 'flavours/glitch/util/counter';
import { me } from 'flavours/glitch/util/initial_state';
import { isMobile } from 'flavours/glitch/util/is_mobile';
import { assignHandlers } from 'flavours/glitch/util/react_helpers';
import { wrap } from 'flavours/glitch/util/redux_helpers';
// State mapping.
function mapStateToProps (state) {
const inReplyTo = state.getIn(['compose', 'in_reply_to']);
return {
acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']).toArray().join(','),
amUnlocked: !state.getIn(['accounts', me, 'locked']),
doNotFederate: state.getIn(['compose', 'advanced_options', 'do_not_federate']),
focusDate: state.getIn(['compose', 'focusDate']),
isSubmitting: state.getIn(['compose', 'is_submitting']),
isUploading: state.getIn(['compose', 'is_uploading']),
layout: state.getIn(['local_settings', 'layout']),
media: state.getIn(['compose', 'media_attachments']),
preselectDate: state.getIn(['compose', 'preselectDate']),
privacy: state.getIn(['compose', 'privacy']),
progress: state.getIn(['compose', 'progress']),
replyAccount: inReplyTo ? state.getIn(['accounts', state.getIn(['statuses', inReplyTo, 'account'])]) : null,
replyContent: inReplyTo ? state.getIn(['statuses', inReplyTo, 'contentHtml']) : null,
resetFileKey: state.getIn(['compose', 'resetFileKey']),
sideArm: state.getIn(['local_settings', 'side_arm']),
sensitive: state.getIn(['compose', 'sensitive']),
showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
spoiler: state.getIn(['compose', 'spoiler']),
spoilerText: state.getIn(['compose', 'spoiler_text']),
suggestionToken: state.getIn(['compose', 'suggestion_token']),
suggestions: state.getIn(['compose', 'suggestions']),
text: state.getIn(['compose', 'text']),
};
};
// Dispatch mapping.
const mapDispatchToProps = {
onCancelReply: cancelReplyCompose,
onChangeDescription: changeUploadCompose,
onChangeSensitivity: changeComposeSensitivity,
onChangeSpoilerText: changeComposeSpoilerText,
onChangeSpoilerness: changeComposeSpoilerness,
onChangeText: changeCompose,
onChangeVisibility: changeComposeVisibility,
onClearSuggestions: clearComposeSuggestions,
onCloseModal: closeModal,
onFetchSuggestions: fetchComposeSuggestions,
onInsertEmoji: insertEmojiCompose,
onOpenActionsModal: openModal.bind(null, 'ACTIONS'),
onOpenDoodleModal: openModal.bind(null, 'DOODLE', { noEsc: true }),
onSelectSuggestion: selectComposeSuggestion,
onSubmit: submitCompose,
onToggleAdvancedOption: toggleComposeAdvancedOption,
onUndoUpload: undoUploadCompose,
onUpload: uploadCompose,
};
// Handlers.
const handlers = {
// Changes the text value of the spoiler.
handleChangeSpoiler ({ target: { value } }) {
const { onChangeSpoilerText } = this.props;
if (onChangeSpoilerText) {
onChangeSpoilerText(value);
}
},
// Inserts an emoji at the caret.
handleEmoji (data) {
const { textarea: { selectionStart } } = this;
const { onInsertEmoji } = this.props;
this.caretPos = selectionStart + data.native.length + 1;
if (onInsertEmoji) {
onInsertEmoji(selectionStart, data);
}
},
// Handles the secondary submit button.
handleSecondarySubmit () {
const { handleSubmit } = this.handlers;
const {
onChangeVisibility,
sideArm,
} = this.props;
if (sideArm !== 'none' && onChangeVisibility) {
onChangeVisibility(sideArm);
}
handleSubmit();
},
// Selects a suggestion from the autofill.
handleSelect (tokenStart, token, value) {
const { onSelectSuggestion } = this.props;
this.caretPos = null;
if (onSelectSuggestion) {
onSelectSuggestion(tokenStart, token, value);
}
},
// Submits the status.
handleSubmit () {
const { textarea: { value } } = this;
const {
onChangeText,
onSubmit,
text,
} = this.props;
// If something changes inside the textarea, then we update the
// state before submitting.
if (onChangeText && text !== value) {
onChangeText(value);
}
// Submits the status.
if (onSubmit) {
onSubmit();
}
},
// Sets a reference to the textarea.
handleRefTextarea (textareaComponent) {
if (textareaComponent) {
this.textarea = textareaComponent.textarea;
}
},
};
// The component.
class Composer extends React.Component {
// Constructor.
constructor (props) {
super(props);
assignHandlers(this, handlers);
// Instance variables.
this.caretPos = null;
this.textarea = null;
}
// If this is the update where we've finished uploading,
// save the last caret position so we can restore it below!
componentWillReceiveProps (nextProps) {
const { textarea } = this;
const { isUploading } = this.props;
if (textarea && isUploading && !nextProps.isUploading) {
this.caretPos = textarea.selectionStart;
}
}
// This statement does several things:
// - If we're beginning a reply, and,
// - Replying to zero or one users, places the cursor at the end
// of the textbox.
// - Replying to more than one user, selects any usernames past
// the first; this provides a convenient shortcut to drop
// everyone else from the conversation.
// - If we've just finished uploading an image, and have a saved
// caret position, restores the cursor to that position after the
// text changes.
componentDidUpdate (prevProps) {
const {
caretPos,
textarea,
} = this;
const {
focusDate,
isUploading,
isSubmitting,
preselectDate,
text,
} = this.props;
let selectionEnd, selectionStart;
// Caret/selection handling.
if (focusDate !== prevProps.focusDate || (prevProps.isUploading && !isUploading && !isNaN(caretPos) && caretPos !== null)) {
switch (true) {
case preselectDate !== prevProps.preselectDate:
selectionStart = text.search(/\s/) + 1;
selectionEnd = text.length;
break;
case !isNaN(caretPos) && caretPos !== null:
selectionStart = selectionEnd = caretPos;
break;
default:
selectionStart = selectionEnd = text.length;
}
if (textarea) {
textarea.setSelectionRange(selectionStart, selectionEnd);
textarea.focus();
}
// Refocuses the textarea after submitting.
} else if (textarea && prevProps.isSubmitting && !isSubmitting) {
textarea.focus();
}
}
render () {
const {
handleChangeSpoiler,
handleEmoji,
handleSecondarySubmit,
handleSelect,
handleSubmit,
handleRefTextarea,
} = this.handlers;
const { history } = this.context;
const {
acceptContentTypes,
amUnlocked,
doNotFederate,
intl,
isSubmitting,
isUploading,
layout,
media,
onCancelReply,
onChangeDescription,
onChangeSensitivity,
onChangeSpoilerness,
onChangeText,
onChangeVisibility,
onClearSuggestions,
onCloseModal,
onFetchSuggestions,
onOpenActionsModal,
onOpenDoodleModal,
onToggleAdvancedOption,
onUndoUpload,
onUpload,
privacy,
progress,
replyAccount,
replyContent,
resetFileKey,
sensitive,
showSearch,
sideArm,
spoiler,
spoilerText,
suggestions,
text,
} = this.props;
return (
<div className='composer'>
<ComposerSpoiler
hidden={!spoiler}
intl={intl}
onChange={handleChangeSpoiler}
onSubmit={handleSubmit}
text={spoilerText}
/>
{privacy === 'private' && amUnlocked ? <ComposerWarning /> : null}
{replyContent ? (
<ComposerReply
account={replyAccount}
content={replyContent}
history={history}
intl={intl}
onCancel={onCancelReply}
/>
) : null}
<ComposerTextarea
autoFocus={!showSearch && !isMobile(window.innerWidth, layout)}
disabled={isSubmitting}
intl={intl}
onChange={onChangeText}
onPaste={onUpload}
onPickEmoji={handleEmoji}
onSubmit={handleSubmit}
onSuggestionsClearRequested={onClearSuggestions}
onSuggestionsFetchRequested={onFetchSuggestions}
onSuggestionSelected={handleSelect}
ref={handleRefTextarea}
suggestions={suggestions}
value={text}
/>
{isUploading || media && media.size ? (
<ComposerUploadForm
intl={intl}
media={media}
onChangeDescription={onChangeDescription}
onRemove={onUndoUpload}
progress={progress}
uploading={isUploading}
/>
) : null}
<ComposerOptions
acceptContentTypes={acceptContentTypes}
disabled={isSubmitting}
doNotFederate={doNotFederate}
full={media.size >= 4 || media.some(
item => item.get('type') === 'video'
)}
hasMedia={!!media.size}
intl={intl}
onChangeSensitivity={onChangeSensitivity}
onChangeVisibility={onChangeVisibility}
onDoodleOpen={onOpenDoodleModal}
onModalClose={onCloseModal}
onModalOpen={onOpenActionsModal}
onToggleAdvancedOption={onToggleAdvancedOption}
onToggleSpoiler={onChangeSpoilerness}
onUpload={onUpload}
privacy={privacy}
resetFileKey={resetFileKey}
sensitive={sensitive}
spoiler={spoiler}
/>
<ComposerPublisher
countText={`${spoilerText}${countableText(text)}${doNotFederate ? ' 👁️' : ''}`}
disabled={isSubmitting || isUploading || !!text.length && !text.trim().length}
intl={intl}
onSecondarySubmit={handleSecondarySubmit}
onSubmit={handleSubmit}
privacy={privacy}
sideArm={sideArm}
/>
</div>
);
}
}
// Context
Composer.contextTypes = {
history: PropTypes.object,
};
// Props.
Composer.propTypes = {
intl: PropTypes.object.isRequired,
// State props.
acceptContentTypes: PropTypes.string,
amUnlocked: PropTypes.bool,
doNotFederate: PropTypes.bool,
focusDate: PropTypes.instanceOf(Date),
isSubmitting: PropTypes.bool,
isUploading: PropTypes.bool,
layout: PropTypes.string,
media: ImmutablePropTypes.list,
preselectDate: PropTypes.instanceOf(Date),
privacy: PropTypes.string,
progress: PropTypes.number,
replyAccount: ImmutablePropTypes.map,
replyContent: PropTypes.string,
resetFileKey: PropTypes.number,
sideArm: PropTypes.string,
sensitive: PropTypes.bool,
showSearch: PropTypes.bool,
spoiler: PropTypes.bool,
spoilerText: PropTypes.string,
suggestionToken: PropTypes.string,
suggestions: ImmutablePropTypes.list,
text: PropTypes.string,
// Dispatch props.
onCancelReply: PropTypes.func,
onChangeDescription: PropTypes.func,
onChangeSensitivity: PropTypes.func,
onChangeSpoilerText: PropTypes.func,
onChangeSpoilerness: PropTypes.func,
onChangeText: PropTypes.func,
onChangeVisibility: PropTypes.func,
onClearSuggestions: PropTypes.func,
onCloseModal: PropTypes.func,
onFetchSuggestions: PropTypes.func,
onInsertEmoji: PropTypes.func,
onOpenActionsModal: PropTypes.func,
onOpenDoodleModal: PropTypes.func,
onSelectSuggestion: PropTypes.func,
onSubmit: PropTypes.func,
onToggleAdvancedOption: PropTypes.func,
onUndoUpload: PropTypes.func,
onUpload: PropTypes.func,
};
// Connecting and export.
export { Composer as WrappedComponent };
export default wrap(Composer, mapStateToProps, mapDispatchToProps, true);

View File

@ -0,0 +1,138 @@
// Package imports.
import PropTypes from 'prop-types';
import React from 'react';
import spring from 'react-motion/lib/spring';
// Components.
import ComposerOptionsDropdownContentItem from './item';
// Utils.
import { withPassive } from 'flavours/glitch/util/dom_helpers';
import Motion from 'flavours/glitch/util/optional_motion';
import { assignHandlers } from 'flavours/glitch/util/react_helpers';
// Handlers.
const handlers = {
// When the document is clicked elsewhere, we close the dropdown.
handleDocumentClick ({ target }) {
const { node } = this;
const { onClose } = this.props;
if (onClose && node && !node.contains(target)) {
onClose();
}
},
// Stores our node in `this.node`.
handleRef (node) {
this.node = node;
},
};
// The spring to use with our motion.
const springMotion = spring(1, {
damping: 35,
stiffness: 400,
});
// The component.
export default class ComposerOptionsDropdownContent extends React.PureComponent {
// Constructor.
constructor (props) {
super(props);
assignHandlers(this, handlers);
// Instance variables.
this.node = null;
}
// On mounting, we add our listeners.
componentDidMount () {
const { handleDocumentClick } = this.handlers;
document.addEventListener('click', handleDocumentClick, false);
document.addEventListener('touchend', handleDocumentClick, withPassive);
}
// On unmounting, we remove our listeners.
componentWillUnmount () {
const { handleDocumentClick } = this.handlers;
document.removeEventListener('click', handleDocumentClick, false);
document.removeEventListener('touchend', handleDocumentClick, withPassive);
}
// Rendering.
render () {
const { handleRef } = this.handlers;
const {
items,
onChange,
onClose,
style,
value,
} = this.props;
// The result.
return (
<Motion
defaultStyle={{
opacity: 0,
scaleX: 0.85,
scaleY: 0.75,
}}
style={{
opacity: springMotion,
scaleX: springMotion,
scaleY: springMotion,
}}
>
{({ opacity, scaleX, scaleY }) => (
<div
className='composer--options--dropdown--content'
ref={handleRef}
style={{
...style,
opacity: opacity,
transform: `scale(${scaleX}, ${scaleY})`,
}}
>
{items.map(
({
name,
...rest
}) => (
<ComposerOptionsDropdownContentItem
active={name === value}
key={name}
name={name}
onChange={onChange}
onClose={onClose}
options={rest}
/>
)
)}
</div>
)}
</Motion>
);
}
}
// Props.
ComposerOptionsDropdownContent.propTypes = {
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,
onClose: PropTypes.func,
style: PropTypes.object,
value: PropTypes.string,
};
// Default props.
ComposerOptionsDropdownContent.defaultProps = { style: {} };

View File

@ -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.
handleActivate (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 ComposerOptionsDropdownContentItem extends React.PureComponent {
// Constructor.
constructor (props) {
super(props);
assignHandlers(this, handlers);
}
// Rendering.
render () {
const { handleActivate } = this.handlers;
const {
active,
options: {
icon,
meta,
on,
text,
},
} = this.props;
const computedClass = classNames('composer--options--dropdown--content--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={handleActivate}
onKeyDown={handleActivate}
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={handleActivate}
/>
);
case !!icon:
return (
<Icon
className='icon'
fullwidth
icon={icon}
/>
);
default:
return null;
}
}()}
{meta ? (
<div className='content'>
<strong>{text}</strong>
{meta}
</div>
) : <div className='content'>{text}</div>}
</div>
);
}
};
// Props.
ComposerOptionsDropdownContentItem.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,
}),
};

View File

@ -0,0 +1,225 @@
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import Overlay from 'react-overlays/lib/Overlay';
// Components.
import IconButton from 'flavours/glitch/components/icon_button';
import ComposerOptionsDropdownContent from './content';
// Utils.
import { isUserTouching } from 'flavours/glitch/util/is_mobile';
import { assignHandlers } from 'flavours/glitch/util/react_helpers';
// Handlers.
const handlers = {
// Closes the dropdown.
handleClose () {
this.setState({ open: false });
},
// The enter key toggles the dropdown's open state, and the escape
// key closes it.
handleKeyDown ({ key }) {
const {
handleClose,
handleToggle,
} = this.handlers;
switch (key) {
case 'Enter':
handleToggle();
break;
case 'Escape':
handleClose();
break;
}
},
// Creates an action modal object.
handleMakeModal () {
const component = this;
const {
items,
onChange,
onModalOpen,
onModalClose,
value,
} = this.props;
// Required props.
if (!(onChange && onModalOpen && onModalClose && items)) {
return null;
}
// The object.
return {
actions: items.map(
({
name,
...rest
}) => ({
...rest,
active: value && name === value,
name,
onClick (e) {
e.preventDefault(); // Prevents focus from changing
onModalClose();
onChange(name);
},
onPassiveClick (e) {
e.preventDefault(); // Prevents focus from changing
onChange(name);
component.setState({ needsModalUpdate: true });
},
})
),
};
},
// Toggles opening and closing the dropdown.
handleToggle () {
const { handleMakeModal } = this.handlers;
const { onModalOpen } = this.props;
const { open } = this.state;
// If this is a touch device, we open a modal instead of the
// dropdown.
if (isUserTouching()) {
// This gets the modal to open.
const modal = handleMakeModal();
// If we can, we then open the modal.
if (modal && onModalOpen) {
onModalOpen(modal);
return;
}
}
// Otherwise, we just set our state to open.
this.setState({ open: !open });
},
// If our modal is open and our props update, we need to also update
// the modal.
handleUpdate () {
const { handleMakeModal } = this.handlers;
const { onModalOpen } = this.props;
const { needsModalUpdate } = this.state;
// Gets our modal object.
const modal = handleMakeModal();
// Reopens the modal with the new object.
if (needsModalUpdate && modal && onModalOpen) {
onModalOpen(modal);
}
},
};
// The component.
export default class ComposerOptionsDropdown extends React.PureComponent {
// Constructor.
constructor (props) {
super(props);
assignHandlers(this, handlers);
this.state = {
needsModalUpdate: false,
open: false,
};
}
// Updates our modal as necessary.
componentDidUpdate (prevProps) {
const { handleUpdate } = this.handlers;
const { items } = this.props;
const { needsModalUpdate } = this.state;
if (needsModalUpdate && items.find(
(item, i) => item.on !== prevProps.items[i].on
)) {
handleUpdate();
this.setState({ needsModalUpdate: false });
}
}
// Rendering.
render () {
const {
handleClose,
handleKeyDown,
handleToggle,
} = this.handlers;
const {
active,
disabled,
title,
icon,
items,
onChange,
value,
} = this.props;
const { open } = this.state;
const computedClass = classNames('composer--options--dropdown', {
active,
open,
});
// The result.
return (
<div
className={computedClass}
onKeyDown={handleKeyDown}
>
<IconButton
active={open || active}
className='value'
disabled={disabled}
icon={icon}
onClick={handleToggle}
size={18}
style={{
height: null,
lineHeight: '27px',
}}
title={title}
/>
<Overlay
containerPadding={20}
placement='bottom'
show={open}
target={this}
>
<ComposerOptionsDropdownContent
items={items}
onChange={onChange}
onClose={handleClose}
value={value}
/>
</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,
};

View File

@ -0,0 +1,329 @@
// 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.
handleChangeFiles ({ target: { files } }) {
const { onUpload } = this.props;
if (files.length && onUpload) {
onUpload(files);
}
},
// Handles attachment clicks.
handleClickAttach (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.
handleRefFileElement (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 {
handleChangeFiles,
handleClickAttach,
handleRefFileElement,
} = this.handlers;
const {
acceptContentTypes,
disabled,
doNotFederate,
full,
hasMedia,
intl,
onChangeSensitivity,
onChangeVisibility,
onModalClose,
onModalOpen,
onToggleAdvancedOption,
onToggleSpoiler,
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={handleChangeFiles}
ref={handleRefFileElement}
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={handleClickAttach}
onModalClose={onModalClose}
onModalOpen={onModalOpen}
title={intl.formatMessage(messages.attach)}
/>
<Motion
defaultStyle={{ scale: 0.87 }}
style={{
scale: spring(hasMedia ? 1 : 0.87, {
stiffness: 200,
damping: 3,
}),
}}
>
{({ scale }) => (
<div
style={{
display: hasMedia ? null : 'none',
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'
onClick={onToggleSpoiler}
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,
onToggleSpoiler: PropTypes.func,
onUpload: PropTypes.func,
privacy: PropTypes.string,
resetFileKey: PropTypes.number,
sensitive: PropTypes.bool,
spoiler: PropTypes.bool,
};

View File

@ -0,0 +1,121 @@
// 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 className='count'>{diff}</span>
{sideArm && sideArm !== 'none' ? (
<Button
className='side_arm'
disabled={disabled || diff < 0}
onClick={onSecondarySubmit}
style={{ padding: null }}
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` })}`}
/>
) : null}
<Button
className='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']),
};

View File

@ -0,0 +1,113 @@
// 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.
handleClick () {
const { onCancel } = this.props;
if (onCancel) {
onCancel();
}
},
// Handles a click on the status's account.
handleClickAccount () {
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 {
handleClick,
handleClickAccount,
} = this.handlers;
const {
account,
content,
intl,
} = this.props;
// The result.
return (
<article className='composer--reply'>
<header>
<IconButton
className='cancel'
icon='times'
onClick={handleClick}
title={intl.formatMessage(messages.cancel)}
/>
{account ? (
<a
className='account'
href={account.get('url')}
onClick={handleClickAccount}
>
<Avatar
account={account}
className='avatar'
size={24}
/>
<DisplayName
account={account}
className='display_name'
/>
</a>
) : null}
</header>
<div
className='content'
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,
};

View File

@ -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.
handleKeyDown ({
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 { handleKeyDown } = 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={handleKeyDown}
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,
};

View File

@ -0,0 +1,298 @@
// 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.
handleBlur () {
//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.
handleChange ({
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.
handleClickSuggestion (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.
handleKeyDown (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.
handleKeyUp ({ 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.
handlePaste (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.
handleRefTextarea (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 {
handleBlur,
handleChange,
handleClickSuggestion,
handleKeyDown,
handleKeyUp,
handlePaste,
handleRefTextarea,
} = this.handlers;
const {
autoFocus,
disabled,
intl,
onPickEmoji,
suggestions,
value,
} = this.props;
const {
selectedSuggestion,
suggestionsHidden,
} = this.state;
// The result.
return (
<div className='composer--textarea'>
<label>
<span {...hiddenComponent}><FormattedMessage {...messages.placeholder} /></span>
<Textarea
aria-autocomplete='list'
autoFocus={autoFocus}
className='textarea'
disabled={disabled}
inputRef={handleRefTextarea}
onBlur={handleBlur}
onChange={handleChange}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onPaste={handlePaste}
placeholder={intl.formatMessage(messages.placeholder)}
value={value}
style={{ direction: isRtl(value) ? 'rtl' : 'ltr' }}
/>
</label>
<EmojiPicker onPickEmoji={onPickEmoji} />
<ComposerTextareaSuggestions
hidden={suggestionsHidden}
onSuggestionClick={handleClickSuggestion}
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 };

View File

@ -0,0 +1,43 @@
// Package imports.
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,
}) {
// The result.
return (
<div
className='composer--textarea--suggestions'
hidden={hidden || !suggestions || suggestions.isEmpty()}
>
{!hidden && suggestions ? 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.number,
};

View File

@ -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';
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.
handleClick (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 { handleClick } = this.handlers;
const {
selected,
suggestion,
} = this.props;
const computedClass = classNames('composer--textarea--suggestions--item', { selected });
// The result.
return (
<div
className={computedClass}
onMouseDown={handleClick}
role='button'
tabIndex='0'
>
{ // 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]),
};

View File

@ -0,0 +1,53 @@
// 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 ({
intl,
media,
onChangeDescription,
onRemove,
progress,
uploading,
}) {
const computedClass = classNames('composer--upload_form', { uploading });
// The result.
return (
<div className={computedClass}>
{uploading ? <ComposerUploadFormProgress progress={progress} /> : null}
{media ? (
<div className='content'>
{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>
) : null}
</div>
);
}
// Props.
ComposerUploadForm.propTypes = {
intl: PropTypes.object.isRequired,
media: ImmutablePropTypes.list,
onChangeDescription: PropTypes.func,
onRemove: PropTypes.func,
progress: PropTypes.number,
uploading: PropTypes.bool,
};

View File

@ -0,0 +1,177 @@
// 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.
handleBlur () {
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.
handleChange ({ target: { value } }) {
this.setState({ dirtyDescription: value });
},
// Records focus on the media item.
handleFocus () {
this.setState({ focused: true });
},
// Records the start of a hover over the media item.
handleMouseEnter () {
this.setState({ hovered: true });
},
// Records the end of a hover over the media item.
handleMouseLeave () {
this.setState({ hovered: false });
},
// Removes the media item.
handleRemove () {
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(this, handlers);
this.state = {
hovered: false,
focused: false,
dirtyDescription: null,
};
}
// Rendering.
render () {
const {
handleBlur,
handleChange,
handleFocus,
handleMouseEnter,
handleMouseLeave,
handleRemove,
} = 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={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<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
className='close'
icon='times'
onClick={handleRemove}
size={36}
title={intl.formatMessage(messages.undo)}
/>
<label>
<span style={{ display: 'none' }}><FormattedMessage {...messages.description} /></span>
<input
maxLength={420}
onBlur={handleBlur}
onChange={handleChange}
onFocus={handleFocus}
placeholder={intl.formatMessage(messages.description)}
type='text'
value={dirtyDescription || description || ''}
/>
</label>
</div>
)}
</Motion>
</div>
);
}
}
// Props.
ComposerUploadFormItem.propTypes = {
description: PropTypes.string,
id: PropTypes.string,
intl: PropTypes.object.isRequired,
onChangeDescription: PropTypes.func,
onRemove: PropTypes.func,
preview: PropTypes.string,
};

View File

@ -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 };

View File

@ -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 = {};

View File

@ -0,0 +1,71 @@
// Package imports.
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import {
FormattedMessage,
defineMessages,
} from 'react-intl';
// Components.
import Avatar from 'flavours/glitch/components/avatar';
import Permalink from 'flavours/glitch/components/permalink';
// Utils.
import { hiddenComponent } from 'flavours/glitch/util/react_helpers';
// Messages.
const messages = defineMessages({
edit: {
defaultMessage: 'Edit profile',
id: 'navigation_bar.edit_profile',
},
});
// The component.
export default function DrawerAccount ({ account }) {
// We need an account to render.
if (!account) {
return (
<div className='drawer--account'>
<a
className='edit'
href='/settings/profile'
>
<FormattedMessage {...messages.edit} />
</a>
</div>
);
}
// The result.
return (
<div className='drawer--account'>
<Permalink
className='avatar'
href={account.get('url')}
to={`/accounts/${account.get('id')}`}
>
<span {...hiddenComponent}>{account.get('acct')}</span>
<Avatar
account={account}
size={40}
/>
</Permalink>
<Permalink
className='acct'
href={account.get('url')}
to={`/accounts/${account.get('id')}`}
>
<strong>@{account.get('acct')}</strong>
</Permalink>
<a
className='edit'
href='/settings/profile'
><FormattedMessage {...messages.edit} /></a>
</div>
);
}
// Props.
DrawerAccount.propTypes = { account: ImmutablePropTypes.map };

View File

@ -0,0 +1,118 @@
// Package imports.
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages } from 'react-intl';
import { Link } from 'react-router-dom';
// Components.
import Icon from 'flavours/glitch/components/icon';
// Utils.
import { 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',
},
});
// The component.
export default function DrawerHeader ({
columns,
intl,
onSettingsClick,
}) {
// Only renders the component if the column isn't being shown.
const renderForColumn = conditionalRender.bind(null,
columnId => !columns || !columns.some(
column => column.get('id') === columnId
)
);
// The result.
return (
<nav className='drawer--header'>
<Link
aria-label={intl.formatMessage(messages.start)}
title={intl.formatMessage(messages.start)}
to='/getting-started'
><Icon icon='asterisk' /></Link>
{renderForColumn('HOME', (
<Link
aria-label={intl.formatMessage(messages.home_timeline)}
title={intl.formatMessage(messages.home_timeline)}
to='/timelines/home'
><Icon icon='home' /></Link>
))}
{renderForColumn('NOTIFICATIONS', (
<Link
aria-label={intl.formatMessage(messages.notifications)}
title={intl.formatMessage(messages.notifications)}
to='/notifications'
><Icon icon='bell' /></Link>
))}
{renderForColumn('COMMUNITY', (
<Link
aria-label={intl.formatMessage(messages.community)}
title={intl.formatMessage(messages.community)}
to='/timelines/public/local'
><Icon icon='users' /></Link>
))}
{renderForColumn('PUBLIC', (
<Link
aria-label={intl.formatMessage(messages.public)}
title={intl.formatMessage(messages.public)}
to='/timelines/public'
><Icon icon='globe' /></Link>
))}
<a
aria-label={intl.formatMessage(messages.settings)}
onClick={onSettingsClick}
role='button'
title={intl.formatMessage(messages.settings)}
tabIndex='0'
><Icon icon='cogs' /></a>
<a
aria-label={intl.formatMessage(messages.logout)}
data-method='delete'
href='/auth/sign_out'
title={intl.formatMessage(messages.logout)}
><Icon icon='sign-out' /></a>
</nav>
);
}
// Props.
DrawerHeader.propTypes = {
columns: ImmutablePropTypes.list,
intl: PropTypes.object,
onSettingsClick: PropTypes.func,
};

View File

@ -0,0 +1,127 @@
// Package imports.
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
// Actions.
import { openModal } from 'flavours/glitch/actions/modal';
import {
changeSearch,
clearSearch,
showSearch,
submitSearch,
} from 'flavours/glitch/actions/search';
// Components.
import Composer from 'flavours/glitch/features/composer';
import DrawerAccount from './account';
import DrawerHeader from './header';
import DrawerResults from './results';
import DrawerSearch from './search';
// Utils.
import { me } from 'flavours/glitch/util/initial_state';
import { wrap } from 'flavours/glitch/util/redux_helpers';
// State mapping.
const mapStateToProps = state => ({
account: state.getIn(['accounts', me]),
columns: state.getIn(['settings', 'columns']),
results: state.getIn(['search', 'results']),
searchHidden: state.getIn(['search', 'hidden']),
searchValue: state.getIn(['search', 'value']),
submitted: state.getIn(['search', 'submitted']),
});
// Dispatch mapping.
const mapDispatchToProps = {
onChange: changeSearch,
onClear: clearSearch,
onShow: showSearch,
onSubmit: submitSearch,
onOpenSettings: openModal.bind(null, 'SETTINGS', {}),
};
// The component.
class Drawer extends React.Component {
// Constructor.
constructor (props) {
super(props);
}
// Rendering.
render () {
const {
account,
columns,
intl,
multiColumn,
onChange,
onClear,
onOpenSettings,
onShow,
onSubmit,
results,
searchHidden,
searchValue,
submitted,
} = this.props;
// The result.
return (
<div className='drawer'>
{multiColumn ? (
<DrawerHeader
columns={columns}
intl={intl}
onSettingsClick={onOpenSettings}
/>
) : null}
<DrawerSearch
intl={intl}
onChange={onChange}
onClear={onClear}
onShow={onShow}
onSubmit={onSubmit}
submitted={submitted}
value={searchValue}
/>
<div className='contents'>
<DrawerAccount account={account} />
<Composer />
<DrawerResults
results={results}
visible={submitted && !searchHidden}
/>
</div>
</div>
);
}
}
// Props.
Drawer.propTypes = {
intl: PropTypes.object.isRequired,
multiColumn: PropTypes.bool,
// State props.
account: ImmutablePropTypes.map,
columns: ImmutablePropTypes.list,
results: ImmutablePropTypes.map,
searchHidden: PropTypes.bool,
searchValue: PropTypes.string,
submitted: PropTypes.bool,
// Dispatch props.
onChange: PropTypes.func,
onClear: PropTypes.func,
onShow: PropTypes.func,
onSubmit: PropTypes.func,
onOpenSettings: PropTypes.func,
};
// Connecting and export.
export { Drawer as WrappedComponent };
export default wrap(Drawer, mapStateToProps, mapDispatchToProps, true);

View File

@ -0,0 +1,116 @@
// Package imports.
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import {
FormattedMessage,
defineMessages,
} from 'react-intl';
import spring from 'react-motion/lib/spring';
import { Link } from 'react-router-dom';
// Components.
import AccountContainer from 'flavours/glitch/containers/account_container';
import StatusContainer from 'flavours/glitch/containers/status_container';
// Utils.
import Motion from 'flavours/glitch/util/optional_motion';
// Messages.
const messages = defineMessages({
total: {
defaultMessage: '{count, number} {count, plural, one {result} other {results}}',
id: 'search_results.total',
},
});
// The component.
export default function DrawerResults ({
results,
visible,
}) {
const accounts = results ? results.get('accounts') : null;
const statuses = results ? results.get('statuses') : null;
const hashtags = results ? results.get('hashtags') : null;
// This gets the total number of items.
const count = [accounts, statuses, hashtags].reduce(function (size, item) {
if (item && item.size) {
return size + item.size;
}
return size;
}, 0);
// The result.
return (
<Motion
defaultStyle={{ x: -100 }}
style={{
x: spring(visible ? 0 : -100, {
stiffness: 210,
damping: 20,
}),
}}
>
{({ x }) => (
<div
className='drawer--results'
style={{
transform: `translateX(${x}%)`,
visibility: x === -100 ? 'hidden' : 'visible',
}}
>
<header>
<FormattedMessage
{...messages.total}
values={{ count }}
/>
</header>
{accounts && accounts.size ? (
<section>
{accounts.map(
accountId => (
<AccountContainer
id={accountId}
key={accountId}
/>
)
)}
</section>
) : null}
{statuses && statuses.size ? (
<section>
{statuses.map(
statusId => (
<StatusContainer
id={statusId}
key={statusId}
/>
)
)}
</section>
) : null}
{hashtags && hashtags.size ? (
<section>
{hashtags.map(
hashtag => (
<Link
className='hashtag'
key={hashtag}
to={`/timelines/tag/${hashtag}`}
>#{hashtag}</Link>
)
)}
</section>
) : null}
</div>
)}
</Motion>
);
}
// Props.
DrawerResults.propTypes = {
results: ImmutablePropTypes.map,
visible: PropTypes.bool,
};

View File

@ -0,0 +1,151 @@
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import {
FormattedMessage,
defineMessages,
} from 'react-intl';
import Overlay from 'react-overlays/lib/Overlay';
// Components.
import Icon from 'flavours/glitch/components/icon';
import DrawerSearchPopout from './popout';
// Utils.
import { focusRoot } from 'flavours/glitch/util/dom_helpers';
import {
assignHandlers,
hiddenComponent,
} from 'flavours/glitch/util/react_helpers';
// Messages.
const messages = defineMessages({
placeholder: {
defaultMessage: 'Search',
id: 'search.placeholder',
},
});
// Handlers.
const handlers = {
handleBlur () {
this.setState({ expanded: false });
},
handleChange ({ target: { value } }) {
const { onChange } = this.props;
if (onChange) {
onChange(value);
}
},
handleClear (e) {
const {
onClear,
submitted,
value: { length },
} = this.props;
e.preventDefault(); // Prevents focus change ??
if (onClear && (submitted || length)) {
onClear();
}
},
handleFocus () {
const { onShow } = this.props;
this.setState({ expanded: true });
if (onShow) {
onShow();
}
},
handleKeyUp (e) {
const { onSubmit } = this.props;
switch (e.key) {
case 'Enter':
if (onSubmit) {
onSubmit();
}
break;
case 'Escape':
focusRoot();
}
},
};
// The component.
export default class DrawerSearch extends React.PureComponent {
// Constructor.
constructor (props) {
super(props);
assignHandlers(this, handlers);
this.state = { expanded: false };
}
// Rendering.
render () {
const {
handleBlur,
handleChange,
handleClear,
handleFocus,
handleKeyUp,
} = this.handlers;
const {
intl,
submitted,
value,
} = this.props;
const { expanded } = this.state;
const computedClass = classNames('drawer--search', { active: value.length || submitted });
return (
<div className={computedClass}>
<label>
<span {...hiddenComponent}>
<FormattedMessage {...messages.placeholder} />
</span>
<input
type='text'
placeholder={intl.formatMessage(messages.placeholder)}
value={value || ''}
onChange={handleChange}
onKeyUp={handleKeyUp}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</label>
<div
aria-label={intl.formatMessage(messages.placeholder)}
className='icon'
onClick={handleClear}
role='button'
tabIndex='0'
>
<Icon icon='search' />
<Icon icon='fa-times-circle' />
</div>
<Overlay
placement='bottom'
show={expanded && !(value || '').length && !submitted}
target={this}
><DrawerSearchPopout /></Overlay>
</div>
);
}
}
// Props.
DrawerSearch.propTypes = {
value: PropTypes.string,
submitted: PropTypes.bool,
onChange: PropTypes.func,
onSubmit: PropTypes.func,
onClear: PropTypes.func,
onShow: PropTypes.func,
intl: PropTypes.object,
};

View File

@ -0,0 +1,99 @@
// Package imports.
import PropTypes from 'prop-types';
import React from 'react';
import {
FormattedMessage,
defineMessages,
} from 'react-intl';
import spring from 'react-motion/lib/spring';
// Utils.
import Motion from 'flavours/glitch/util/optional_motion';
// Messages.
const messages = defineMessages({
format: {
defaultMessage: 'Advanced search format',
id: 'search_popout.search_format',
},
hashtag: {
defaultMessage: 'hashtag',
id: 'search_popout.tips.hashtag',
},
status: {
defaultMessage: 'status',
id: 'search_popout.tips.status',
},
text: {
defaultMessage: 'Simple text returns matching display names, usernames and hashtags',
id: 'search_popout.tips.text',
},
user: {
defaultMessage: 'user',
id: 'search_popout.tips.user',
},
});
// The spring used by our motion.
const motionSpring = spring(1, { damping: 35, stiffness: 400 });
// The component.
export default function DrawerSearchPopout ({ style }) {
// The result.
return (
<Motion
defaultStyle={{
opacity: 0,
scaleX: 0.85,
scaleY: 0.75,
}}
style={{
opacity: motionSpring,
scaleX: motionSpring,
scaleY: motionSpring,
}}
>
{({ opacity, scaleX, scaleY }) => (
<div
className='drawer--search--popout'
style={{
...style,
position: 'absolute',
width: 285,
opacity: opacity,
transform: `scale(${scaleX}, ${scaleY})`,
}}
>
<h4><FormattedMessage {...messages.format} /></h4>
<ul>
<li>
<em>#example</em>
{' '}
<FormattedMessage {...messages.hashtag} />
</li>
<li>
<em>@username@domain</em>
{' '}
<FormattedMessage {...messages.user} />
</li>
<li>
<em>URL</em>
{' '}
<FormattedMessage {...messages.user} />
</li>
<li>
<em>URL</em>
{' '}
<FormattedMessage {...messages.status} />
</li>
</ul>
<FormattedMessage {...messages.text} />
</div>
)}
</Motion>
);
}
// Props.
DrawerSearchPopout.propTypes = { style: PropTypes.object };

View File

@ -1,3 +1,8 @@
import { connect } from 'react-redux';
import { changeSetting } from 'flavours/glitch/actions/settings';
import { createSelector } from 'reselect';
import { Map as ImmutableMap } from 'immutable';
import { useEmoji } from 'flavours/glitch/actions/emojis';
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl } from 'react-intl';
@ -25,6 +30,80 @@ const messages = defineMessages({
flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' }, flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' },
}); });
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);
}
},
});
const assetHost = process.env.CDN_HOST || ''; const assetHost = process.env.CDN_HOST || '';
let EmojiPicker, Emoji; // load asynchronously let EmojiPicker, Emoji; // load asynchronously
@ -277,6 +356,7 @@ class EmojiPickerMenu extends React.PureComponent {
} }
@connect(mapStateToProps, mapDispatchToProps)
@injectIntl @injectIntl
export default class EmojiPickerDropdown extends React.PureComponent { export default class EmojiPickerDropdown extends React.PureComponent {

View File

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import ComposeFormContainer from 'flavours/glitch/features/compose/containers/compose_form_container'; import Composer from 'flavours/glitch/features/composer';
import NotificationsContainer from 'flavours/glitch/features/ui/containers/notifications_container'; import NotificationsContainer from 'flavours/glitch/features/ui/containers/notifications_container';
import LoadingBarContainer from 'flavours/glitch/features/ui/containers/loading_bar_container'; import LoadingBarContainer from 'flavours/glitch/features/ui/containers/loading_bar_container';
import ModalContainer from 'flavours/glitch/features/ui/containers/modal_container'; import ModalContainer from 'flavours/glitch/features/ui/containers/modal_container';
@ -9,7 +9,7 @@ export default class Compose extends React.PureComponent {
render () { render () {
return ( return (
<div> <div>
<ComposeFormContainer /> <Composer />
<NotificationsContainer /> <NotificationsContainer />
<ModalContainer /> <ModalContainer />
<LoadingBarContainer className='loading-bar' /> <LoadingBarContainer className='loading-bar' />

View File

@ -6,15 +6,26 @@ import StatusContent from 'flavours/glitch/components/status_content';
import Avatar from 'flavours/glitch/components/avatar'; import Avatar from 'flavours/glitch/components/avatar';
import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp'; import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp';
import DisplayName from 'flavours/glitch/components/display_name'; import DisplayName from 'flavours/glitch/components/display_name';
import IconButton from 'flavours/glitch/components/icon_button';
import classNames from 'classnames'; import classNames from 'classnames';
import Icon from 'flavours/glitch/components/icon';
import Link from 'flavours/glitch/components/link';
import Toggle from 'react-toggle';
export default class ActionsModal extends ImmutablePureComponent { export default class ActionsModal extends ImmutablePureComponent {
static propTypes = { static propTypes = {
status: ImmutablePropTypes.map, status: ImmutablePropTypes.map,
actions: PropTypes.array, actions: PropTypes.arrayOf(PropTypes.shape({
active: PropTypes.bool,
href: PropTypes.string,
icon: PropTypes.string,
meta: PropTypes.node,
name: PropTypes.string,
on: PropTypes.bool,
onClick: PropTypes.func, onClick: PropTypes.func,
onPassiveClick: PropTypes.func,
text: PropTypes.node,
})),
}; };
renderAction = (action, i) => { renderAction = (action, i) => {
@ -22,17 +33,57 @@ export default class ActionsModal extends ImmutablePureComponent {
return <li key={`sep-${i}`} className='dropdown-menu__separator' />; return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
} }
const { icon = null, text, meta = null, active = false, href = '#' } = action; const {
active,
href,
icon,
meta,
name,
on,
onClick,
onPassiveClick,
text,
} = action;
return ( return (
<li key={`${text}-${i}`}> <li key={name || i}>
<a href={href} target='_blank' rel='noopener' onClick={this.props.onClick} data-index={i} className={classNames({ active })}> <Link
{icon && <IconButton title={text} icon={icon} role='presentation' tabIndex='-1' />} className={classNames('link', { active })}
href={href}
onClick={on !== null && typeof on !== 'undefined' && onPassiveClick || onClick}
role={onClick ? 'button' : null}
>
{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={onPassiveClick || onClick}
/>
);
case !!icon:
return (
<Icon
className='icon'
fullwidth
icon={icon}
/>
);
default:
return null;
}
}()}
{meta ? (
<div> <div>
<div className={classNames({ 'actions-modal__item-label': !!meta })}>{text}</div> <strong>{text}</strong>
<div>{meta}</div> {meta}
</div> </div>
</a> ) : <div>{text}</div>}
</Link>
</li> </li>
); );
} }

View File

@ -11,13 +11,13 @@ import BundleContainer from '../containers/bundle_container';
import ColumnLoading from './column_loading'; import ColumnLoading from './column_loading';
import DrawerLoading from './drawer_loading'; import DrawerLoading from './drawer_loading';
import BundleColumnError from './bundle_column_error'; import BundleColumnError from './bundle_column_error';
import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, DirectTimeline, FavouritedStatuses, ListTimeline } from 'flavours/glitch/util/async-components'; import { Drawer, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, DirectTimeline, FavouritedStatuses, ListTimeline } from 'flavours/glitch/util/async-components';
import detectPassiveEvents from 'detect-passive-events'; import detectPassiveEvents from 'detect-passive-events';
import { scrollRight } from 'flavours/glitch/util/scroll'; import { scrollRight } from 'flavours/glitch/util/scroll';
const componentMap = { const componentMap = {
'COMPOSE': Compose, 'COMPOSE': Drawer,
'HOME': HomeTimeline, 'HOME': HomeTimeline,
'NOTIFICATIONS': Notifications, 'NOTIFICATIONS': Notifications,
'PUBLIC': PublicTimeline, 'PUBLIC': PublicTimeline,

View File

@ -6,18 +6,12 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ReactSwipeableViews from 'react-swipeable-views'; import ReactSwipeableViews from 'react-swipeable-views';
import classNames from 'classnames'; import classNames from 'classnames';
import Permalink from 'flavours/glitch/components/permalink'; import Permalink from 'flavours/glitch/components/permalink';
import ComposeForm from 'flavours/glitch/features/compose/components/compose_form'; import { WrappedComponent as RawComposer } from 'flavours/glitch/features/composer';
import Search from 'flavours/glitch/features/compose/components/search'; import DrawerAccount from 'flavours/glitch/features/drawer/account';
import NavigationBar from 'flavours/glitch/features/compose/components/navigation_bar'; import DrawerSearch from 'flavours/glitch/features/drawer/search';
import ColumnHeader from './column_header'; import ColumnHeader from './column_header';
import {
List as ImmutableList,
Map as ImmutableMap,
} from 'immutable';
import { me } from 'flavours/glitch/util/initial_state'; import { me } from 'flavours/glitch/util/initial_state';
const noop = () => { };
const messages = defineMessages({ const messages = defineMessages({
home_title: { id: 'column.home', defaultMessage: 'Home' }, home_title: { id: 'column.home', defaultMessage: 'Home' },
notifications_title: { id: 'column.notifications', defaultMessage: 'Notifications' }, notifications_title: { id: 'column.notifications', defaultMessage: 'Notifications' },
@ -44,52 +38,39 @@ PageOne.propTypes = {
domain: PropTypes.string.isRequired, domain: PropTypes.string.isRequired,
}; };
const PageTwo = ({ myAccount }) => ( const composerState = {
showSearch: true,
text: 'Awoo! #introductions',
};
const PageTwo = ({ intl, myAccount }) => (
<div className='onboarding-modal__page onboarding-modal__page-two'> <div className='onboarding-modal__page onboarding-modal__page-two'>
<div className='figure non-interactive'> <div className='figure non-interactive'>
<div className='pseudo-drawer'> <div className='pseudo-drawer'>
<NavigationBar onClose={noop} account={myAccount} /> <DrawerAccount account={myAccount} />
</div> <RawComposer
<ComposeForm intl={intl}
text='Awoo! #introductions' state={composerState}
suggestions={ImmutableList()}
mentionedDomains={[]}
spoiler={false}
onChange={noop}
onSubmit={noop}
onPaste={noop}
onPickEmoji={noop}
onChangeSpoilerText={noop}
onClearSuggestions={noop}
onFetchSuggestions={noop}
onSuggestionSelected={noop}
onPrivacyChange={noop}
showSearch
settings={ImmutableMap.of('side_arm', 'none')}
/> />
</div> </div>
</div>
<p><FormattedMessage id='onboarding.page_two.compose' defaultMessage='Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.' /></p> <p><FormattedMessage id='onboarding.page_two.compose' defaultMessage='Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.' /></p>
</div> </div>
); );
PageTwo.propTypes = { PageTwo.propTypes = {
intl: PropTypes.object.isRequired,
myAccount: ImmutablePropTypes.map.isRequired, myAccount: ImmutablePropTypes.map.isRequired,
}; };
const PageThree = ({ myAccount }) => ( const PageThree = ({ intl, myAccount }) => (
<div className='onboarding-modal__page onboarding-modal__page-three'> <div className='onboarding-modal__page onboarding-modal__page-three'>
<div className='figure non-interactive'> <div className='figure non-interactive'>
<Search <DrawerSearch intl={intl} />
value=''
onChange={noop}
onSubmit={noop}
onClear={noop}
onShow={noop}
/>
<div className='pseudo-drawer'> <div className='pseudo-drawer'>
<NavigationBar onClose={noop} account={myAccount} /> <DrawerAccount account={myAccount} />
</div> </div>
</div> </div>
@ -99,6 +80,7 @@ const PageThree = ({ myAccount }) => (
); );
PageThree.propTypes = { PageThree.propTypes = {
intl: PropTypes.object.isRequired,
myAccount: ImmutablePropTypes.map.isRequired, myAccount: ImmutablePropTypes.map.isRequired,
}; };
@ -192,8 +174,8 @@ export default class OnboardingModal extends React.PureComponent {
const { myAccount, admin, domain, intl } = this.props; const { myAccount, admin, domain, intl } = this.props;
this.pages = [ this.pages = [
<PageOne acct={myAccount.get('acct')} domain={domain} />, <PageOne acct={myAccount.get('acct')} domain={domain} />,
<PageTwo myAccount={myAccount} />, <PageTwo myAccount={myAccount} intl={intl} />,
<PageThree myAccount={myAccount} />, <PageThree myAccount={myAccount} intl={intl} />,
<PageFour domain={domain} intl={intl} />, <PageFour domain={domain} intl={intl} />,
<PageSix admin={admin} domain={domain} />, <PageSix admin={admin} domain={domain} />,
]; ];

View File

@ -17,7 +17,7 @@ import UploadArea from './components/upload_area';
import ColumnsAreaContainer from './containers/columns_area_container'; import ColumnsAreaContainer from './containers/columns_area_container';
import classNames from 'classnames'; import classNames from 'classnames';
import { import {
Compose, Drawer,
Status, Status,
GettingStarted, GettingStarted,
KeyboardShortcuts, KeyboardShortcuts,
@ -56,7 +56,6 @@ const messages = defineMessages({
}); });
const mapStateToProps = state => ({ const mapStateToProps = state => ({
isComposing: state.getIn(['compose', 'is_composing']),
hasComposingText: state.getIn(['compose', 'text']) !== '', hasComposingText: state.getIn(['compose', 'text']) !== '',
layout: state.getIn(['local_settings', 'layout']), layout: state.getIn(['local_settings', 'layout']),
isWide: state.getIn(['local_settings', 'stretch']), isWide: state.getIn(['local_settings', 'stretch']),
@ -120,9 +119,9 @@ export default class UI extends React.Component {
}; };
handleBeforeUnload = (e) => { handleBeforeUnload = (e) => {
const { intl, isComposing, hasComposingText } = this.props; const { intl, hasComposingText } = this.props;
if (isComposing && hasComposingText) { if (hasComposingText) {
// Setting returnValue to any string causes confirmation dialog. // Setting returnValue to any string causes confirmation dialog.
// Many browsers no longer display this text to users, // Many browsers no longer display this text to users,
// but we set user-friendly message for other browsers, e.g. Edge. // but we set user-friendly message for other browsers, e.g. Edge.
@ -227,9 +226,8 @@ export default class UI extends React.Component {
} }
shouldComponentUpdate (nextProps) { shouldComponentUpdate (nextProps) {
if (nextProps.isComposing !== this.props.isComposing) { if (nextProps.navbarUnder !== this.props.navbarUnder) {
// Avoid expensive update just to toggle a class // Avoid expensive update just to toggle a class
this.node.classList.toggle('is-composing', nextProps.isComposing);
this.node.classList.toggle('navbar-under', nextProps.navbarUnder); this.node.classList.toggle('navbar-under', nextProps.navbarUnder);
return false; return false;
@ -427,7 +425,7 @@ export default class UI extends React.Component {
<WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} /> <WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
<WrappedRoute path='/pinned' component={PinnedStatuses} content={children} /> <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
<WrappedRoute path='/statuses/new' component={Compose} content={children} /> <WrappedRoute path='/statuses/new' component={Drawer} content={children} />
<WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} /> <WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} />
<WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} /> <WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} />
<WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} /> <WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} />

View File

@ -21,7 +21,6 @@ import {
COMPOSE_SPOILERNESS_CHANGE, COMPOSE_SPOILERNESS_CHANGE,
COMPOSE_SPOILER_TEXT_CHANGE, COMPOSE_SPOILER_TEXT_CHANGE,
COMPOSE_VISIBILITY_CHANGE, COMPOSE_VISIBILITY_CHANGE,
COMPOSE_COMPOSING_CHANGE,
COMPOSE_EMOJI_INSERT, COMPOSE_EMOJI_INSERT,
COMPOSE_UPLOAD_CHANGE_REQUEST, COMPOSE_UPLOAD_CHANGE_REQUEST,
COMPOSE_UPLOAD_CHANGE_SUCCESS, COMPOSE_UPLOAD_CHANGE_SUCCESS,
@ -48,7 +47,6 @@ const initialState = ImmutableMap({
focusDate: null, focusDate: null,
preselectDate: null, preselectDate: null,
in_reply_to: null, in_reply_to: null,
is_composing: false,
is_submitting: false, is_submitting: false,
is_uploading: false, is_uploading: false,
progress: 0, progress: 0,
@ -134,7 +132,7 @@ function removeMedia(state, mediaId) {
const insertSuggestion = (state, position, token, completion) => { const insertSuggestion = (state, position, token, completion) => {
return state.withMutations(map => { return state.withMutations(map => {
map.update('text', oldText => `${oldText.slice(0, position)}${completion}\u200B${oldText.slice(position + token.length)}`); map.update('text', oldText => `${oldText.slice(0, position)}${completion}${completion[0] === ':' ? '\u200B' : ' '}${oldText.slice(position + token.length)}`);
map.set('suggestion_token', null); map.set('suggestion_token', null);
map.update('suggestions', ImmutableList(), list => list.clear()); map.update('suggestions', ImmutableList(), list => list.clear());
map.set('focusDate', new Date()); map.set('focusDate', new Date());
@ -181,9 +179,7 @@ export default function compose(state = initialState, action) {
case COMPOSE_MOUNT: case COMPOSE_MOUNT:
return state.set('mounted', true); return state.set('mounted', true);
case COMPOSE_UNMOUNT: case COMPOSE_UNMOUNT:
return state return state.set('mounted', false);
.set('mounted', false)
.set('is_composing', false);
case COMPOSE_ADVANCED_OPTIONS_CHANGE: case COMPOSE_ADVANCED_OPTIONS_CHANGE:
return state return state
.set('advanced_options', .set('advanced_options',
@ -219,8 +215,6 @@ export default function compose(state = initialState, action) {
return state return state
.set('text', action.text) .set('text', action.text)
.set('idempotencyKey', uuid()); .set('idempotencyKey', uuid());
case COMPOSE_COMPOSING_CHANGE:
return state.set('is_composing', action.value);
case COMPOSE_REPLY: case COMPOSE_REPLY:
return state.withMutations(map => { return state.withMutations(map => {
map.set('in_reply_to', action.status.get('id')); map.set('in_reply_to', action.status.get('id'));

View File

@ -0,0 +1,419 @@
.composer { padding: 10px }
.composer--spoiler {
input {
display: block;
box-sizing: border-box;
margin: 0;
border: none;
border-radius: 4px;
padding: 10px;
width: 100%;
outline: 0;
color: $ui-base-color;
background: $simple-background-color;
font-size: 14px;
font-family: inherit;
resize: vertical;
&:focus { outline: 0 }
@include single-column('screen and (max-width: 630px)') { font-size: 16px }
}
}
.composer--warning {
color: darken($ui-secondary-color, 65%);
margin-bottom: 15px;
background: $ui-primary-color;
box-shadow: 0 2px 6px rgba($base-shadow-color, 0.3);
padding: 8px 10px;
border-radius: 4px;
font-size: 13px;
font-weight: 400;
a {
color: darken($ui-primary-color, 33%);
font-weight: 500;
text-decoration: underline;
&:active,
&:focus,
&:hover { text-decoration: none }
}
}
.composer--reply {
margin: 0 0 -2px;
border-radius: 4px 4px 0 0;
padding: 10px;
background: $ui-primary-color;
& > header {
margin-bottom: 5px;
overflow: hidden;
& > .account {
& > .avatar {
float: left;
margin-right: 5px;
}
& > .display_name {
color: $ui-base-color;
display: block;
padding-right: 25px;
max-width: 100%;
line-height: 24px;
text-decoration: none;
overflow: hidden;
}
}
& > .cancel {
float: right;
line-height: 24px;
}
}
& > .content {
position: relative;
margin: 10px 0;
padding: 0 12px;
font-size: 14px;
line-height: 20px;
color: $ui-base-color;
word-wrap: break-word;
font-weight: 400;
overflow: visible;
white-space: pre-wrap;
padding-top: 5px;
}
.emojione {
width: 20px;
height: 20px;
margin: -5px 0 0;
}
p {
margin-bottom: 20px;
&:last-child { margin-bottom: 0 }
}
a {
color: lighten($ui-base-color, 20%);
text-decoration: none;
&:hover { text-decoration: underline }
&.mention {
&:hover {
text-decoration: none;
span { text-decoration: underline }
}
}
}
}
.composer--textarea {
position: relative;
& > label {
.textarea {
display: block;
box-sizing: border-box;
margin: 0;
border: none;
border-radius: 4px 4px 0 0;
padding: 10px 32px 0 10px;
width: 100%;
min-height: 100px;
outline: 0;
color: $ui-base-color;
background: $simple-background-color;
font-size: 14px;
font-family: inherit;
resize: none;
&:disabled { background: $ui-secondary-color }
&:focus { outline: 0 }
@include single-column('screen and (max-width: 630px)') { font-size: 16px }
@include limited-single-column('screen and (max-width: 600px)') {
height: 100px !important; // prevent auto-resize textarea
resize: vertical;
}
}
}
}
.composer--textarea--suggestions {
display: block;
position: absolute;
box-sizing: border-box;
top: 100%;
border-radius: 0 0 4px 4px;
padding: 6px;
width: 100%;
color: $ui-base-color;
background: $ui-secondary-color;
box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);
font-size: 14px;
z-index: 99;
&[hidden] { display: none }
}
.composer--textarea--suggestions--item {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
border-radius: 4px;
padding: 10px;
font-size: 14px;
line-height: 18px;
cursor: pointer;
&:hover,
&:focus,
&:active,
&.selected { background: darken($ui-secondary-color, 10%) }
& > .emoji {
img {
display: block;
float: left;
margin-right: 8px;
width: 18px;
height: 18px;
}
}
}
.composer--upload_form {
padding: 5px;
color: $ui-base-color;
background: $simple-background-color;
font-size: 14px;
& > .content {
display: flex;
flex-direction: row;
flex-wrap: wrap;
font-family: inherit;
overflow: hidden;
}
}
.composer--upload_form--item {
flex: 1 1 0;
margin: 5px;
min-width: 40%;
& > div {
position: relative;
border-radius: 4px;
height: 100px;
width: 100%;
background-position: center;
background-size: cover;
background-repeat: no-repeat;
input {
display: block;
position: absolute;
box-sizing: border-box;
bottom: 0;
left: 0;
margin: 0;
border: 0;
padding: 10px;
width: 100%;
color: $ui-secondary-color;
background: linear-gradient(0deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent);
font-size: 14px;
font-family: inherit;
font-weight: 500;
opacity: 0;
z-index: 2;
transition: opacity .1s ease;
&:focus { color: $white }
&::placeholder {
opacity: 0.54;
color: $ui-secondary-color;
}
}
& > .close { mix-blend-mode: difference }
}
&.active {
& > div {
input { opacity: 1 }
}
}
}
.composer--upload_form--progress {
display: flex;
padding: 10px;
color: $ui-base-lighter-color;
overflow: hidden;
& > .fa {
font-size: 34px;
margin-right: 10px;
}
& > .message {
flex: 1 1 auto;
& > span {
display: block;
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
}
& > .backdrop {
position: relative;
margin-top: 5px;
border-radius: 6px;
width: 100%;
height: 6px;
background: $ui-base-lighter-color;
& > .tracker {
position: absolute;
top: 0;
left: 0;
height: 6px;
border-radius: 6px;
background: $ui-highlight-color;
}
}
}
}
.composer--options {
padding: 10px;
background: darken($simple-background-color, 8%);
box-shadow: inset 0 5px 5px rgba($base-shadow-color, 0.05);
border-radius: 0 0 4px 4px;
height: 27px;
& > * {
display: inline-block;
box-sizing: content-box;
padding: 0 3px;
height: 27px;
line-height: 27px;
vertical-align: bottom;
}
& > hr {
display: inline-block;
margin: 0 3px;
border-width: 0 0 0 1px;
border-style: none none none solid;
border-color: transparent transparent transparent darken($simple-background-color, 24%);
padding: 0;
width: 0;
height: 27px;
background: transparent;
}
}
.composer--options--dropdown {
&.open {
& > .value {
border-radius: 4px 4px 0 0;
box-shadow: 0 -4px 4px rgba($base-shadow-color, 0.1);
color: $primary-text-color;
background: $ui-highlight-color;
transition: none;
}
}
}
.composer--options--dropdown--content {
position: absolute;
border-radius: 4px;
box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
background: $simple-background-color;
overflow: hidden;
transform-origin: 50% 0;
}
.composer--options--dropdown--content--item {
display: flex;
align-items: center;
padding: 10px;
color: $ui-base-color;
cursor: pointer;
& > .content {
flex: 1 1 auto;
color: darken($ui-primary-color, 24%);
&:not(:first-child) { margin-left: 10px }
strong {
display: block;
color: $ui-base-color;
font-weight: 500;
}
}
&:hover,
&.active {
background: $ui-highlight-color;
color: $primary-text-color;
& > .content {
color: $primary-text-color;
strong { color: $primary-text-color }
}
}
&.active:hover { background: lighten($ui-highlight-color, 4%) }
}
.composer--publisher {
padding-top: 10px;
text-align: right;
white-space: nowrap;
overflow: hidden;
& > .count {
display: inline-block;
margin: 0 16px 0 8px;
font-size: 16px;
line-height: 36px;
}
& > .primary {
display: inline-block;
margin: 0;
padding: 0 10px;
text-align: center;
}
& > .side_arm {
display: inline-block;
margin: 0 2px 0 0;
padding: 0;
width: 36px;
text-align: center;
}
&.over {
& > .count { color: $warning-red }
}
}

View File

@ -0,0 +1,223 @@
.drawer {
display: flex;
flex-direction: column;
box-sizing: border-box;
padding: 10px 5px;
width: 300px;
flex: none;
contain: strict;
&:first-child {
padding-left: 10px;
}
&:last-child {
padding-right: 10px;
}
@include single-column('screen and (max-width: 630px)') { flex: auto }
@include limited-single-column('screen and (max-width: 630px)') {
&, &:first-child, &:last-child { padding: 0 }
}
.wide & {
min-width: 300px;
max-width: 400px;
flex: 1 1 200px;
}
@include single-column('screen and (max-width: 630px)') {
:root & { // Overrides `.wide` for single-column view
flex: auto;
width: 100%;
min-width: 0;
max-width: none;
padding: 0;
}
}
.react-swipeable-view-container & { height: 100% }
& > .contents {
position: relative;
padding: 0;
width: 100%;
height: 100%;
background: lighten($ui-base-color, 13%);
overflow-x: hidden;
overflow-y: auto;
contain: strict;
}
}
.drawer--header {
display: flex;
flex-direction: row;
margin-bottom: 10px;
flex: none;
background: lighten($ui-base-color, 8%);
font-size: 16px;
& > * {
display: block;
box-sizing: border-box;
border-bottom: 2px solid transparent;
padding: 15px 5px 13px;
height: 48px;
flex: 1 1 auto;
color: $ui-primary-color;
text-align: center;
text-decoration: none;
cursor: pointer;
}
a {
transition: background 100ms ease-in;
&:focus,
&:hover {
outline: none;
background: lighten($ui-base-color, 3%);
transition: background 200ms ease-out;
}
}
}
.drawer--search {
position: relative;
margin-bottom: 10px;
flex: none;
@include limited-single-column('screen and (max-width: 360px)') { margin-bottom: 0 }
@include single-column('screen and (max-width: 630px)') { font-size: 16px }
input {
display: block;
box-sizing: border-box;
margin: 0;
border: none;
padding: 10px 30px 10px 10px;
width: 100%;
height: 36px;
outline: 0;
color: $ui-primary-color;
background: $ui-base-color;
font-size: 14px;
font-family: inherit;
line-height: 16px;
&:focus {
outline: 0;
background: lighten($ui-base-color, 4%);
}
}
& > .icon {
.fa {
display: inline-block;
position: absolute;
top: 10px;
right: 10px;
width: 18px;
height: 18px;
color: $ui-secondary-color;
font-size: 18px;
opacity: 0;
cursor: default;
pointer-events: none;
z-index: 2;
transition: all 100ms linear;
}
.fa-search {
opacity: 0.3;
transform: rotate(0deg);
}
.fa-times-circle {
top: 11px;
transform: rotate(-90deg);
cursor: pointer;
&:hover { color: $primary-text-color }
}
&.active {
.fa-search {
opacity: 0;
transform: rotate(90deg);
}
.fa-times-circle {
opacity: 0.3;
pointer-events: auto;
transform: rotate(0deg);
}
}
}
}
.drawer--account {
padding: 10px;
color: $ui-primary-color;
& > a {
color: inherit;
text-decoration: none;
}
& > .avatar {
float: left;
margin-right: 10px;
}
& > .acct {
display: block;
color: $primary-text-color;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.drawer--results {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
padding: 0;
background: $ui-base-color;
overflow-x: hidden;
overflow-y: auto;
contain: strict;
& > header {
border-bottom: 1px solid darken($ui-base-color, 4%);
padding: 15px 10px;
color: $ui-base-lighter-color;
background: lighten($ui-base-color, 2%);
font-size: 14px;
font-weight: 500;
}
& > section {
background: $ui-base-color;
& > .hashtag {
display: block;
padding: 10px;
color: $ui-secondary-color;
text-decoration: none;
&:hover,
&:active,
&:focus {
color: lighten($ui-secondary-color, 4%);
text-decoration: underline;
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -11,8 +11,8 @@ pack:
home: home:
filename: packs/home.js filename: packs/home.js
preload: preload:
- flavours/glitch/async/drawer
- flavours/glitch/async/getting_started - flavours/glitch/async/getting_started
- flavours/glitch/async/compose
- flavours/glitch/async/home_timeline - flavours/glitch/async/home_timeline
- flavours/glitch/async/notifications - flavours/glitch/async/notifications
modal: modal:

View File

@ -2,8 +2,8 @@ export function EmojiPicker () {
return import(/* webpackChunkName: "flavours/glitch/async/emoji_picker" */'flavours/glitch/util/emoji/emoji_picker'); return import(/* webpackChunkName: "flavours/glitch/async/emoji_picker" */'flavours/glitch/util/emoji/emoji_picker');
} }
export function Compose () { export function Drawer () {
return import(/* webpackChunkName: "flavours/glitch/async/compose" */'flavours/glitch/features/compose'); return import(/* webpackChunkName: "flavours/glitch/async/drawer" */'flavours/glitch/features/drawer');
} }
export function Notifications () { export function Notifications () {

View File

@ -0,0 +1,14 @@
// 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;
// Focuses the root element.
export function focusRoot () {
let e;
if (document && (e = document.querySelector('.ui')) && (e = e.parentElement)) {
e.focus();
}
}

View File

@ -70,6 +70,7 @@ const emojify = (str, customEmojis = {}) => {
}; };
export default emojify; export default emojify;
export { unicodeMapping };
export const buildCustomEmojis = (customEmojis) => { export const buildCustomEmojis = (customEmojis) => {
const emojis = []; const emojis = [];

View File

@ -18,5 +18,6 @@ export const boostModal = getMeta('boost_modal');
export const favouriteModal = getMeta('favourite_modal'); export const favouriteModal = getMeta('favourite_modal');
export const deleteModal = getMeta('delete_modal'); export const deleteModal = getMeta('delete_modal');
export const me = getMeta('me'); export const me = getMeta('me');
export const maxChars = getMeta('max_toot_chars') || 500;
export default initialState; export default initialState;

View File

@ -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 = {};
Object.keys(handlers).forEach(
key => handle[key] = handlers[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(data) ? component : null;
}
// This object provides props to make the component not visible.
export const hiddenComponent = { style: { display: 'none' } };

View File

@ -0,0 +1,8 @@
import { injectIntl } from 'react-intl';
import { connect } from 'react-redux';
// Connects a component.
export function wrap (Component, mapStateToProps, mapDispatchToProps, options) {
const withIntl = typeof options === 'object' ? options.withIntl : !!options;
return (withIntl ? injectIntl : i => i)(connect(mapStateToProps, mapDispatchToProps)(Component));
}