diff options
Diffstat (limited to 'app/assets/javascripts/components/features')
58 files changed, 1698 insertions, 705 deletions
diff --git a/app/assets/javascripts/components/features/account/components/action_bar.jsx b/app/assets/javascripts/components/features/account/components/action_bar.jsx index fe110954d..80a32d3e2 100644 --- a/app/assets/javascripts/components/features/account/components/action_bar.jsx +++ b/app/assets/javascripts/components/features/account/components/action_bar.jsx @@ -5,13 +5,16 @@ import { Link } from 'react-router'; import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl'; const messages = defineMessages({ - mention: { id: 'account.mention', defaultMessage: 'Mention' }, + mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' }, edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' }, - unblock: { id: 'account.unblock', defaultMessage: 'Unblock' }, + unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, - block: { id: 'account.block', defaultMessage: 'Block' }, + unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, + block: { id: 'account.block', defaultMessage: 'Block @{name}' }, + mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, follow: { id: 'account.follow', defaultMessage: 'Follow' }, - block: { id: 'account.block', defaultMessage: 'Block' } + report: { id: 'account.report', defaultMessage: 'Report @{name}' }, + disclaimer: { id: 'account.disclaimer', defaultMessage: 'This user is from another instance. This number may be larger.' } }); const outerDropdownStyle = { @@ -32,7 +35,10 @@ const ActionBar = React.createClass({ me: React.PropTypes.number.isRequired, onFollow: React.PropTypes.func, onBlock: React.PropTypes.func.isRequired, - onMention: React.PropTypes.func.isRequired + onMention: React.PropTypes.func.isRequired, + onReport: React.PropTypes.func.isRequired, + onMute: React.PropTypes.func.isRequired, + intl: React.PropTypes.object.isRequired }, mixins: [PureRenderMixin], @@ -41,17 +47,31 @@ const ActionBar = React.createClass({ const { account, me, intl } = this.props; let menu = []; + let extraInfo = ''; - menu.push({ text: intl.formatMessage(messages.mention), action: this.props.onMention }); + menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention }); + menu.push(null); if (account.get('id') === me) { menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' }); - } else if (account.getIn(['relationship', 'blocking'])) { - menu.push({ text: intl.formatMessage(messages.unblock), action: this.props.onBlock }); - } else if (account.getIn(['relationship', 'following'])) { - menu.push({ text: intl.formatMessage(messages.block), action: this.props.onBlock }); } else { - menu.push({ text: intl.formatMessage(messages.block), action: this.props.onBlock }); + if (account.getIn(['relationship', 'muting'])) { + menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.props.onMute }); + } else { + menu.push({ text: intl.formatMessage(messages.mute, { name: account.get('username') }), action: this.props.onMute }); + } + + if (account.getIn(['relationship', 'blocking'])) { + menu.push({ text: intl.formatMessage(messages.unblock, { name: account.get('username') }), action: this.props.onBlock }); + } else { + menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.props.onBlock }); + } + + menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.props.onReport }); + } + + if (account.get('acct') !== account.get('username')) { + extraInfo = <abbr title={intl.formatMessage(messages.disclaimer)}>*</abbr>; } return ( @@ -63,17 +83,17 @@ const ActionBar = React.createClass({ <div style={outerLinksStyle}> <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}`}> <span><FormattedMessage id='account.posts' defaultMessage='Posts' /></span> - <strong><FormattedNumber value={account.get('statuses_count')} /></strong> + <strong><FormattedNumber value={account.get('statuses_count')} /> {extraInfo}</strong> </Link> <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/following`}> <span><FormattedMessage id='account.follows' defaultMessage='Follows' /></span> - <strong><FormattedNumber value={account.get('following_count')} /></strong> + <strong><FormattedNumber value={account.get('following_count')} /> {extraInfo}</strong> </Link> <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/followers`}> <span><FormattedMessage id='account.followers' defaultMessage='Followers' /></span> - <strong><FormattedNumber value={account.get('followers_count')} /></strong> + <strong><FormattedNumber value={account.get('followers_count')} /> {extraInfo}</strong> </Link> </div> </div> diff --git a/app/assets/javascripts/components/features/account/components/header.jsx b/app/assets/javascripts/components/features/account/components/header.jsx index b2d943c1c..a359963c4 100644 --- a/app/assets/javascripts/components/features/account/components/header.jsx +++ b/app/assets/javascripts/components/features/account/components/header.jsx @@ -1,9 +1,10 @@ import PureRenderMixin from 'react-addons-pure-render-mixin'; import ImmutablePropTypes from 'react-immutable-proptypes'; import emojify from '../../../emoji'; -import escapeTextContentForBrowser from 'react/lib/escapeTextContentForBrowser'; +import escapeTextContentForBrowser from 'escape-html'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import IconButton from '../../../components/icon_button'; +import { Motion, spring } from 'react-motion'; const messages = defineMessages({ unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, @@ -11,10 +12,51 @@ const messages = defineMessages({ requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' } }); +const Avatar = React.createClass({ + + propTypes: { + account: ImmutablePropTypes.map.isRequired + }, + + getInitialState () { + return { + isHovered: false + }; + }, + + mixins: [PureRenderMixin], + + handleMouseOver () { + if (this.state.isHovered) return; + this.setState({ isHovered: true }); + }, + + handleMouseOut () { + if (!this.state.isHovered) return; + this.setState({ isHovered: false }); + }, + + render () { + const { account } = this.props; + const { isHovered } = this.state; + + return ( + <Motion defaultStyle={{ radius: 90 }} style={{ radius: spring(isHovered ? 30 : 90, { stiffness: 180, damping: 12 }) }}> + {({ radius }) => + <a href={account.get('url')} className='account__header__avatar' target='_blank' rel='noopener' style={{ display: 'block', width: '90px', height: '90px', margin: '0 auto', marginBottom: '10px', borderRadius: `${radius}px`, overflow: 'hidden' }} onMouseOver={this.handleMouseOver} onMouseOut={this.handleMouseOut}> + <img src={account.get('avatar')} alt={account.get('acct')} style={{ display: 'block', width: '90px', height: '90px' }} /> + </a> + } + </Motion> + ); + } + +}); + const Header = React.createClass({ propTypes: { - account: ImmutablePropTypes.map.isRequired, + account: ImmutablePropTypes.map, me: React.PropTypes.number.isRequired, onFollow: React.PropTypes.func.isRequired, intl: React.PropTypes.object.isRequired @@ -25,6 +67,10 @@ const Header = React.createClass({ render () { const { account, me, intl } = this.props; + if (!account) { + return null; + } + let displayName = account.get('display_name'); let info = ''; let actionBtn = ''; @@ -35,7 +81,7 @@ const Header = React.createClass({ } if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) { - info = <span style={{ position: 'absolute', top: '10px', right: '10px', opacity: '0.7', display: 'inline-block', verticalAlign: 'top', background: 'rgba(0, 0, 0, 0.4)', color: '#fff', textTransform: 'uppercase', fontSize: '11px', fontWeight: '500', padding: '4px', borderRadius: '4px' }}><FormattedMessage id='account.follows_you' defaultMessage='Follows you' /></span> + info = <span className='account--follows-info' style={{ position: 'absolute', top: '10px', right: '10px', opacity: '0.7', display: 'inline-block', verticalAlign: 'top', background: 'rgba(0, 0, 0, 0.4)', textTransform: 'uppercase', fontSize: '11px', fontWeight: '500', padding: '4px', borderRadius: '4px' }}><FormattedMessage id='account.follows_you' defaultMessage='Follows you' /></span> } if (me !== account.get('id')) { @@ -64,14 +110,9 @@ const Header = React.createClass({ return ( <div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}> <div style={{ padding: '20px 10px' }}> - <a href={account.get('url')} target='_blank' rel='noopener' style={{ display: 'block', color: 'inherit', textDecoration: 'none' }}> - <div className='account__header__avatar' style={{ width: '90px', margin: '0 auto', marginBottom: '10px' }}> - <img src={account.get('avatar')} alt='' style={{ display: 'block', width: '90px', height: '90px', borderRadius: '90px' }} /> - </div> - - <span style={{ display: 'inline-block', fontSize: '20px', lineHeight: '27px', fontWeight: '500' }} className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} /> - </a> + <Avatar account={account} /> + <span style={{ display: 'inline-block', fontSize: '20px', lineHeight: '27px', fontWeight: '500' }} className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} /> <span className='account__header__username' style={{ fontSize: '14px', fontWeight: '400', display: 'block', marginBottom: '10px' }}>@{account.get('acct')} {lockedIcon}</span> <div style={{ fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} /> diff --git a/app/assets/javascripts/components/features/account_timeline/components/header.jsx b/app/assets/javascripts/components/features/account_timeline/components/header.jsx index ff3e8af2d..99a10562e 100644 --- a/app/assets/javascripts/components/features/account_timeline/components/header.jsx +++ b/app/assets/javascripts/components/features/account_timeline/components/header.jsx @@ -2,6 +2,7 @@ import PureRenderMixin from 'react-addons-pure-render-mixin'; import ImmutablePropTypes from 'react-immutable-proptypes'; import InnerHeader from '../../account/components/header'; import ActionBar from '../../account/components/action_bar'; +import MissingIndicator from '../../../components/missing_indicator'; const Header = React.createClass({ contextTypes: { @@ -9,11 +10,13 @@ const Header = React.createClass({ }, propTypes: { - account: ImmutablePropTypes.map.isRequired, + account: ImmutablePropTypes.map, me: React.PropTypes.number.isRequired, onFollow: React.PropTypes.func.isRequired, onBlock: React.PropTypes.func.isRequired, - onMention: React.PropTypes.func.isRequired + onMention: React.PropTypes.func.isRequired, + onReport: React.PropTypes.func.isRequired, + onMute: React.PropTypes.func.isRequired }, mixins: [PureRenderMixin], @@ -30,11 +33,20 @@ const Header = React.createClass({ this.props.onMention(this.props.account, this.context.router); }, + handleReport () { + this.props.onReport(this.props.account); + this.context.router.push('/report'); + }, + + handleMute() { + this.props.onMute(this.props.account); + }, + render () { const { account, me } = this.props; - if (!account) { - return null; + if (account === null) { + return <MissingIndicator />; } return ( @@ -50,6 +62,8 @@ const Header = React.createClass({ me={me} onBlock={this.handleBlock} onMention={this.handleMention} + onReport={this.handleReport} + onMute={this.handleMute} /> </div> ); diff --git a/app/assets/javascripts/components/features/account_timeline/containers/header_container.jsx b/app/assets/javascripts/components/features/account_timeline/containers/header_container.jsx index dca826596..8472d25a5 100644 --- a/app/assets/javascripts/components/features/account_timeline/containers/header_container.jsx +++ b/app/assets/javascripts/components/features/account_timeline/containers/header_container.jsx @@ -5,9 +5,12 @@ import { followAccount, unfollowAccount, blockAccount, - unblockAccount + unblockAccount, + muteAccount, + unmuteAccount } from '../../../actions/accounts'; import { mentionCompose } from '../../../actions/compose'; +import { initReport } from '../../../actions/reports'; const makeMapStateToProps = () => { const getAccount = makeGetAccount(); @@ -39,6 +42,18 @@ const mapDispatchToProps = dispatch => ({ onMention (account, router) { dispatch(mentionCompose(account, router)); + }, + + onReport (account) { + dispatch(initReport(account)); + }, + + onMute (account) { + if (account.getIn(['relationship', 'muting'])) { + dispatch(unmuteAccount(account.get('id'))); + } else { + dispatch(muteAccount(account.get('id'))); + } } }); diff --git a/app/assets/javascripts/components/features/account_timeline/index.jsx b/app/assets/javascripts/components/features/account_timeline/index.jsx index 349510295..f92e1b49c 100644 --- a/app/assets/javascripts/components/features/account_timeline/index.jsx +++ b/app/assets/javascripts/components/features/account_timeline/index.jsx @@ -16,6 +16,7 @@ import Immutable from 'immutable'; const mapStateToProps = (state, props) => ({ statusIds: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId), 'items'], Immutable.List()), isLoading: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId), 'isLoading']), + hasMore: !!state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId), 'next']), me: state.getIn(['meta', 'me']) }); @@ -26,6 +27,7 @@ const AccountTimeline = React.createClass({ dispatch: React.PropTypes.func.isRequired, statusIds: ImmutablePropTypes.list, isLoading: React.PropTypes.bool, + hasMore: React.PropTypes.bool, me: React.PropTypes.number.isRequired }, @@ -48,7 +50,7 @@ const AccountTimeline = React.createClass({ }, render () { - const { statusIds, isLoading, me } = this.props; + const { statusIds, isLoading, hasMore, me } = this.props; if (!statusIds && isLoading) { return ( @@ -66,6 +68,7 @@ const AccountTimeline = React.createClass({ prepend={<HeaderContainer accountId={this.props.params.accountId} />} statusIds={statusIds} isLoading={isLoading} + hasMore={hasMore} me={me} onScrollToBottom={this.handleScrollToBottom} /> diff --git a/app/assets/javascripts/components/features/community_timeline/index.jsx b/app/assets/javascripts/components/features/community_timeline/index.jsx new file mode 100644 index 000000000..0957338cf --- /dev/null +++ b/app/assets/javascripts/components/features/community_timeline/index.jsx @@ -0,0 +1,95 @@ +import { connect } from 'react-redux'; +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import StatusListContainer from '../ui/containers/status_list_container'; +import Column from '../ui/components/column'; +import { + refreshTimeline, + updateTimeline, + deleteFromTimelines, + connectTimeline, + disconnectTimeline +} from '../../actions/timelines'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ColumnBackButtonSlim from '../../components/column_back_button_slim'; +import createStream from '../../stream'; + +const messages = defineMessages({ + title: { id: 'column.community', defaultMessage: 'Local' } +}); + +const mapStateToProps = state => ({ + hasUnread: state.getIn(['timelines', 'community', 'unread']) > 0, + accessToken: state.getIn(['meta', 'access_token']) +}); + +let subscription; + +const CommunityTimeline = React.createClass({ + + propTypes: { + dispatch: React.PropTypes.func.isRequired, + intl: React.PropTypes.object.isRequired, + accessToken: React.PropTypes.string.isRequired, + hasUnread: React.PropTypes.bool + }, + + mixins: [PureRenderMixin], + + componentDidMount () { + const { dispatch, accessToken } = this.props; + + dispatch(refreshTimeline('community')); + + if (typeof subscription !== 'undefined') { + return; + } + + subscription = createStream(accessToken, 'public:local', { + + connected () { + dispatch(connectTimeline('community')); + }, + + reconnected () { + dispatch(connectTimeline('community')); + }, + + disconnected () { + dispatch(disconnectTimeline('community')); + }, + + received (data) { + switch(data.event) { + case 'update': + dispatch(updateTimeline('community', JSON.parse(data.payload))); + break; + case 'delete': + dispatch(deleteFromTimelines(data.payload)); + break; + } + } + + }); + }, + + componentWillUnmount () { + // if (typeof subscription !== 'undefined') { + // subscription.close(); + // subscription = null; + // } + }, + + render () { + const { intl, hasUnread } = this.props; + + return ( + <Column icon='users' active={hasUnread} heading={intl.formatMessage(messages.title)}> + <ColumnBackButtonSlim /> + <StatusListContainer type='community' emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />} /> + </Column> + ); + }, + +}); + +export default connect(mapStateToProps)(injectIntl(CommunityTimeline)); diff --git a/app/assets/javascripts/components/features/compose/components/autosuggest_account.jsx b/app/assets/javascripts/components/features/compose/components/autosuggest_account.jsx index 9ea7f190f..5591b45cf 100644 --- a/app/assets/javascripts/components/features/compose/components/autosuggest_account.jsx +++ b/app/assets/javascripts/components/features/compose/components/autosuggest_account.jsx @@ -1,11 +1,16 @@ import Avatar from '../../../components/avatar'; import DisplayName from '../../../components/display_name'; +import ImmutablePropTypes from 'react-immutable-proptypes'; const AutosuggestAccount = ({ account }) => ( - <div style={{ overflow: 'hidden' }}> + <div style={{ overflow: 'hidden' }} className='autosuggest-account'> <div style={{ float: 'left', marginRight: '5px' }}><Avatar src={account.get('avatar')} size={18} /></div> <DisplayName account={account} /> </div> ); +AutosuggestAccount.propTypes = { + account: ImmutablePropTypes.map.isRequired +}; + export default AutosuggestAccount; diff --git a/app/assets/javascripts/components/features/compose/components/autosuggest_status.jsx b/app/assets/javascripts/components/features/compose/components/autosuggest_status.jsx new file mode 100644 index 000000000..086488649 --- /dev/null +++ b/app/assets/javascripts/components/features/compose/components/autosuggest_status.jsx @@ -0,0 +1,15 @@ +import { FormattedMessage } from 'react-intl'; +import DisplayName from '../../../components/display_name'; +import ImmutablePropTypes from 'react-immutable-proptypes'; + +const AutosuggestStatus = ({ status }) => ( + <div style={{ overflow: 'hidden' }} className='autosuggest-status'> + <FormattedMessage id='search.status_by' defaultMessage='Status by {name}' values={{ name: <strong>@{status.getIn(['account', 'acct'])}</strong> }} /> + </div> +); + +AutosuggestStatus.propTypes = { + status: ImmutablePropTypes.map.isRequired +}; + +export default AutosuggestStatus; diff --git a/app/assets/javascripts/components/features/compose/components/character_counter.jsx b/app/assets/javascripts/components/features/compose/components/character_counter.jsx index f0c1b7c8d..e6b675354 100644 --- a/app/assets/javascripts/components/features/compose/components/character_counter.jsx +++ b/app/assets/javascripts/components/features/compose/components/character_counter.jsx @@ -10,7 +10,7 @@ const CharacterCounter = React.createClass({ mixins: [PureRenderMixin], render () { - const diff = this.props.max - this.props.text.length; + const diff = this.props.max - this.props.text.replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, "_").length; return ( <span style={{ fontSize: '16px', cursor: 'default' }}> diff --git a/app/assets/javascripts/components/features/compose/components/compose_form.jsx b/app/assets/javascripts/components/features/compose/components/compose_form.jsx index 46b62964a..b016d3f28 100644 --- a/app/assets/javascripts/components/features/compose/components/compose_form.jsx +++ b/app/assets/javascripts/components/features/compose/components/compose_form.jsx @@ -2,15 +2,19 @@ import CharacterCounter from './character_counter'; import Button from '../../../components/button'; import PureRenderMixin from 'react-addons-pure-render-mixin'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import ReplyIndicator from './reply_indicator'; -import UploadButton from './upload_button'; +import ReplyIndicatorContainer from '../containers/reply_indicator_container'; import AutosuggestTextarea from '../../../components/autosuggest_textarea'; -import AutosuggestAccountContainer from '../../compose/containers/autosuggest_account_container'; import { debounce } from 'react-decoration'; import UploadButtonContainer from '../containers/upload_button_container'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import Toggle from 'react-toggle'; -import { Motion, spring } from 'react-motion'; +import Collapsable from '../../../components/collapsable'; +import SpoilerButtonContainer from '../containers/spoiler_button_container'; +import PrivacyDropdownContainer from '../containers/privacy_dropdown_container'; +import SensitiveButtonContainer from '../containers/sensitive_button_container'; +import EmojiPickerDropdown from './emoji_picker_dropdown'; +import UploadFormContainer from '../containers/upload_form_container'; +import TextIconButton from './text_icon_button'; const messages = defineMessages({ placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' }, @@ -25,28 +29,24 @@ const ComposeForm = React.createClass({ text: React.PropTypes.string.isRequired, suggestion_token: React.PropTypes.string, suggestions: ImmutablePropTypes.list, - sensitive: React.PropTypes.bool, spoiler: React.PropTypes.bool, + privacy: React.PropTypes.string, spoiler_text: React.PropTypes.string, - unlisted: React.PropTypes.bool, - private: React.PropTypes.bool, - fileDropDate: React.PropTypes.instanceOf(Date), + focusDate: React.PropTypes.instanceOf(Date), + preselectDate: React.PropTypes.instanceOf(Date), is_submitting: React.PropTypes.bool, is_uploading: React.PropTypes.bool, - in_reply_to: ImmutablePropTypes.map, - media_count: React.PropTypes.number, me: React.PropTypes.number, + needsPrivacyWarning: React.PropTypes.bool, + mentionedDomains: React.PropTypes.array.isRequired, onChange: React.PropTypes.func.isRequired, onSubmit: React.PropTypes.func.isRequired, - onCancelReply: React.PropTypes.func.isRequired, onClearSuggestions: React.PropTypes.func.isRequired, onFetchSuggestions: React.PropTypes.func.isRequired, onSuggestionSelected: React.PropTypes.func.isRequired, - onChangeSensitivity: React.PropTypes.func.isRequired, - onChangeSpoilerness: React.PropTypes.func.isRequired, onChangeSpoilerText: React.PropTypes.func.isRequired, - onChangeVisibility: React.PropTypes.func.isRequired, - onChangeListability: React.PropTypes.func.isRequired, + onPaste: React.PropTypes.func.isRequired, + onPickEmoji: React.PropTypes.func.isRequired }, mixins: [PureRenderMixin], @@ -75,37 +75,31 @@ const ComposeForm = React.createClass({ }, onSuggestionSelected (tokenStart, token, value) { + this._restoreCaret = null; this.props.onSuggestionSelected(tokenStart, token, value); }, - handleChangeSensitivity (e) { - this.props.onChangeSensitivity(e.target.checked); - }, - - handleChangeSpoilerness (e) { - this.props.onChangeSpoilerness(e.target.checked); - this.props.onChangeSpoilerText(''); - }, - handleChangeSpoilerText (e) { this.props.onChangeSpoilerText(e.target.value); }, - handleChangeVisibility (e) { - this.props.onChangeVisibility(e.target.checked); - }, - - handleChangeListability (e) { - this.props.onChangeListability(e.target.checked); - }, - componentDidUpdate (prevProps) { - if ((prevProps.in_reply_to === null && this.props.in_reply_to !== null) || (prevProps.in_reply_to !== null && this.props.in_reply_to !== null && prevProps.in_reply_to.get('id') !== this.props.in_reply_to.get('id'))) { + if (this.props.focusDate !== prevProps.focusDate) { // If replying to zero or one users, places the cursor at the end of the textbox. // If replying to more than one user, selects any usernames past the first; // this provides a convenient shortcut to drop everyone else from the conversation. - const selectionStart = this.props.text.search(/\s/) + 1; - const selectionEnd = this.props.text.length; + 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(); @@ -116,83 +110,85 @@ const ComposeForm = React.createClass({ this.autosuggestTextarea = c; }, - render () { - const { intl } = this.props; - let replyArea = ''; - let publishText = ''; - const disabled = this.props.is_submitting || this.props.is_uploading; + handleEmojiPick (data) { + const position = this.autosuggestTextarea.textarea.selectionStart; + this._restoreCaret = position + data.shortname.length + 1; + this.props.onPickEmoji(position, data); + }, - if (this.props.in_reply_to) { - replyArea = <ReplyIndicator status={this.props.in_reply_to} onCancel={this.props.onCancelReply} />; + render () { + const { intl, needsPrivacyWarning, mentionedDomains, onPaste } = this.props; + const disabled = this.props.is_submitting || this.props.is_uploading; + + let publishText = ''; + let privacyWarning = ''; + let reply_to_other = false; + + if (needsPrivacyWarning) { + privacyWarning = ( + <div className='compose-form__warning'> + <FormattedMessage + id='compose_form.privacy_disclaimer' + defaultMessage='Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}} to not leak your status?' + values={{ domains: <strong>{mentionedDomains.join(', ')}</strong>, domainsCount: mentionedDomains.length }} + /> + </div> + ); } - let reply_to_other = !!this.props.in_reply_to && (this.props.in_reply_to.getIn(['account', 'id']) !== this.props.me); - - if (this.props.private) { + if (this.props.privacy === 'private' || this.props.privacy === 'direct') { publishText = <span><i className='fa fa-lock' /> {intl.formatMessage(messages.publish)}</span>; } else { - publishText = intl.formatMessage(messages.publish) + (!this.props.unlisted ? '!' : ''); + publishText = intl.formatMessage(messages.publish) + (this.props.privacy !== 'unlisted' ? '!' : ''); } return ( <div style={{ padding: '10px' }}> - <Motion defaultStyle={{ opacity: !this.props.spoiler ? 0 : 100, height: !this.props.spoiler ? 50 : 0 }} style={{ opacity: spring(!this.props.spoiler ? 0 : 100), height: spring(!this.props.spoiler ? 0 : 50) }}> - {({ opacity, height }) => - <div className="spoiler-input" style={{ height: `${height}px`, overflow: 'hidden', opacity: opacity / 100 }}> - <input placeholder={intl.formatMessage(messages.spoiler_placeholder)} value={this.props.spoiler_text} onChange={this.handleChangeSpoilerText} type="text" className="spoiler-input__input" /> - </div> - } - </Motion> - - {replyArea} - - <AutosuggestTextarea - ref={this.setAutosuggestTextarea} - placeholder={intl.formatMessage(messages.placeholder)} - disabled={disabled} - fileDropDate={this.props.fileDropDate} - value={this.props.text} - onChange={this.handleChange} - suggestions={this.props.suggestions} - onKeyDown={this.handleKeyDown} - onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} - onSuggestionsClearRequested={this.onSuggestionsClearRequested} - onSuggestionSelected={this.onSuggestionSelected} - /> - - <div style={{ marginTop: '10px', overflow: 'hidden' }}> - <div style={{ float: 'right' }}><Button text={publishText} onClick={this.handleSubmit} disabled={disabled} /></div> - <div style={{ float: 'right', marginRight: '16px', lineHeight: '36px' }}><CharacterCounter max={500} text={[this.props.spoiler_text, this.props.text].join('')} /></div> - <UploadButtonContainer style={{ paddingTop: '4px' }} /> + <Collapsable isVisible={this.props.spoiler} fullHeight={50}> + <div className="spoiler-input"> + <input placeholder={intl.formatMessage(messages.spoiler_placeholder)} value={this.props.spoiler_text} onChange={this.handleChangeSpoilerText} onKeyDown={this.handleKeyDown} type="text" className="spoiler-input__input" /> + </div> + </Collapsable> + + {privacyWarning} + + <ReplyIndicatorContainer /> + + <div style={{ position: 'relative' }}> + <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} + /> + + <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} /> + </div> + + <div className='compose-form__modifiers'> + <UploadFormContainer /> </div> - <label className='compose-form__label with-border' style={{ marginTop: '10px' }}> - <Toggle checked={this.props.spoiler} onChange={this.handleChangeSpoilerness} /> - <span className='compose-form__label__text'><FormattedMessage id='compose_form.spoiler' defaultMessage='Hide text behind warning' /></span> - </label> - - <label className='compose-form__label with-border'> - <Toggle checked={this.props.private} onChange={this.handleChangeVisibility} /> - <span className='compose-form__label__text'><FormattedMessage id='compose_form.private' defaultMessage='Mark as private' /></span> - </label> - - <Motion defaultStyle={{ opacity: (this.props.private || reply_to_other) ? 0 : 100, height: (this.props.private || reply_to_other) ? 39.5 : 0 }} style={{ opacity: spring((this.props.private || reply_to_other) ? 0 : 100), height: spring((this.props.private || reply_to_other) ? 0 : 39.5) }}> - {({ opacity, height }) => - <label className='compose-form__label' style={{ height: `${height}px`, overflow: 'hidden', opacity: opacity / 100 }}> - <Toggle checked={this.props.unlisted} onChange={this.handleChangeListability} /> - <span className='compose-form__label__text'><FormattedMessage id='compose_form.unlisted' defaultMessage='Do not display in public timeline' /></span> - </label> - } - </Motion> - - <Motion defaultStyle={{ opacity: 0, height: 0 }} style={{ opacity: spring(this.props.media_count === 0 ? 0 : 100), height: spring(this.props.media_count === 0 ? 0 : 39.5) }}> - {({ opacity, height }) => - <label className='compose-form__label' style={{ height: `${height}px`, overflow: 'hidden', opacity: opacity / 100 }}> - <Toggle checked={this.props.sensitive} onChange={this.handleChangeSensitivity} /> - <span className='compose-form__label__text'><FormattedMessage id='compose_form.sensitive' defaultMessage='Mark media as sensitive' /></span> - </label> - } - </Motion> + <div style={{ display: 'flex', justifyContent: 'space-between' }}> + <div className='compose-form__buttons'> + <UploadButtonContainer /> + <PrivacyDropdownContainer /> + <SensitiveButtonContainer /> + <SpoilerButtonContainer /> + </div> + + <div style={{ display: 'flex' }}> + <div style={{ paddingTop: '10px', marginRight: '16px', lineHeight: '36px' }}><CharacterCounter max={500} text={[this.props.spoiler_text, this.props.text].join('')} /></div> + <div style={{ paddingTop: '10px' }}><Button text={publishText} onClick={this.handleSubmit} disabled={disabled} /></div> + </div> + </div> </div> ); } diff --git a/app/assets/javascripts/components/features/compose/components/drawer.jsx b/app/assets/javascripts/components/features/compose/components/drawer.jsx deleted file mode 100644 index 83f3fa27d..000000000 --- a/app/assets/javascripts/components/features/compose/components/drawer.jsx +++ /dev/null @@ -1,42 +0,0 @@ -import { Link } from 'react-router'; -import { injectIntl, defineMessages } from 'react-intl'; - -const messages = defineMessages({ - start: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, - public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Public timeline' }, - preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, - logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' } -}); - -const Drawer = ({ children, withHeader, intl }) => { - let header = ''; - - if (withHeader) { - header = ( - <div className='drawer__header'> - <Link title={intl.formatMessage(messages.start)} className='drawer__tab' to='/getting-started'><i className='fa fa-fw fa-asterisk' /></Link> - <Link title={intl.formatMessage(messages.public)} className='drawer__tab' to='/timelines/public'><i className='fa fa-fw fa-globe' /></Link> - <a title={intl.formatMessage(messages.preferences)} className='drawer__tab' href='/settings/preferences'><i className='fa fa-fw fa-cog' /></a> - <a title={intl.formatMessage(messages.logout)} className='drawer__tab' href='/auth/sign_out' data-method='delete'><i className='fa fa-fw fa-sign-out' /></a> - </div> - ); - } - - return ( - <div className='drawer'> - {header} - - <div className='drawer__inner'> - {children} - </div> - </div> - ); -}; - -Drawer.propTypes = { - withHeader: React.PropTypes.bool, - children: React.PropTypes.node, - intl: React.PropTypes.object -}; - -export default injectIntl(Drawer); diff --git a/app/assets/javascripts/components/features/compose/components/emoji_picker_dropdown.jsx b/app/assets/javascripts/components/features/compose/components/emoji_picker_dropdown.jsx new file mode 100644 index 000000000..1920b29bf --- /dev/null +++ b/app/assets/javascripts/components/features/compose/components/emoji_picker_dropdown.jsx @@ -0,0 +1,58 @@ +import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown'; +import EmojiPicker from 'emojione-picker'; +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import { defineMessages, injectIntl } from 'react-intl'; + +const messages = defineMessages({ + emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' } +}); + +const settings = { + imageType: 'png', + sprites: false, + imagePathPNG: '/emoji/' +}; + +const style = { + position: 'absolute', + right: '5px', + top: '5px' +}; + +const EmojiPickerDropdown = React.createClass({ + + propTypes: { + intl: React.PropTypes.object.isRequired, + onPickEmoji: React.PropTypes.func.isRequired + }, + + mixins: [PureRenderMixin], + + setRef (c) { + this.dropdown = c; + }, + + handleChange (data) { + this.dropdown.hide(); + this.props.onPickEmoji(data); + }, + + render () { + const { intl } = this.props; + + return ( + <Dropdown ref={this.setRef} style={style}> + <DropdownTrigger className='emoji-button' title={intl.formatMessage(messages.emoji)} style={{ fontSize: `24px`, width: `24px`, lineHeight: `24px`, display: 'block', marginLeft: '2px' }}> + <img draggable="false" className="emojione" alt="🙂" src="/emoji/1f602.svg" /> + </DropdownTrigger> + + <DropdownContent className='dropdown__left'> + <EmojiPicker emojione={settings} onChange={this.handleChange} /> + </DropdownContent> + </Dropdown> + ); + } + +}); + +export default injectIntl(EmojiPickerDropdown); diff --git a/app/assets/javascripts/components/features/compose/components/privacy_dropdown.jsx b/app/assets/javascripts/components/features/compose/components/privacy_dropdown.jsx new file mode 100644 index 000000000..e54fa4d28 --- /dev/null +++ b/app/assets/javascripts/components/features/compose/components/privacy_dropdown.jsx @@ -0,0 +1,101 @@ +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import { injectIntl, defineMessages } from 'react-intl'; +import IconButton from '../../../components/icon_button'; + +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: 'Private' }, + 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 iconStyle = { + lineHeight: '27px', + height: null +}; + +const PrivacyDropdown = React.createClass({ + + propTypes: { + value: React.PropTypes.string.isRequired, + onChange: React.PropTypes.func.isRequired, + intl: React.PropTypes.object.isRequired + }, + + getInitialState () { + return { + open: false + }; + }, + + mixins: [PureRenderMixin], + + handleToggle () { + this.setState({ open: !this.state.open }); + }, + + handleClick (value, e) { + e.preventDefault(); + this.setState({ open: false }); + this.props.onChange(value); + }, + + 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); + }, + + setRef (c) { + this.node = c; + }, + + render () { + const { value, onChange, intl } = this.props; + const { open } = this.state; + + const options = [ + { icon: 'globe', value: 'public', shortText: intl.formatMessage(messages.public_short), longText: intl.formatMessage(messages.public_long) }, + { icon: 'unlock-alt', value: 'unlisted', shortText: intl.formatMessage(messages.unlisted_short), longText: intl.formatMessage(messages.unlisted_long) }, + { icon: 'lock', value: 'private', shortText: intl.formatMessage(messages.private_short), longText: intl.formatMessage(messages.private_long) }, + { icon: 'envelope', value: 'direct', shortText: intl.formatMessage(messages.direct_short), longText: intl.formatMessage(messages.direct_long) } + ]; + + const valueOption = options.find(item => item.value === value); + + return ( + <div ref={this.setRef} className={`privacy-dropdown ${open ? 'active' : ''}`}> + <div className='privacy-dropdown__value'><IconButton icon={valueOption.icon} title={intl.formatMessage(messages.change_privacy)} size={18} active={open} inverted onClick={this.handleToggle} style={iconStyle} /></div> + <div className='privacy-dropdown__dropdown'> + {options.map(item => + <div key={item.value} onClick={this.handleClick.bind(this, item.value)} className={`privacy-dropdown__option ${item.value === value ? 'active' : ''}`}> + <div className='privacy-dropdown__option__icon'><i className={`fa fa-fw fa-${item.icon}`} /></div> + <div className='privacy-dropdown__option__content'> + <strong>{item.shortText}</strong> + {item.longText} + </div> + </div> + )} + </div> + </div> + ); + } + +}); + +export default injectIntl(PrivacyDropdown); diff --git a/app/assets/javascripts/components/features/compose/components/reply_indicator.jsx b/app/assets/javascripts/components/features/compose/components/reply_indicator.jsx index 73e5ee99e..a72bd32c2 100644 --- a/app/assets/javascripts/components/features/compose/components/reply_indicator.jsx +++ b/app/assets/javascripts/components/features/compose/components/reply_indicator.jsx @@ -17,7 +17,7 @@ const ReplyIndicator = React.createClass({ }, propTypes: { - status: ImmutablePropTypes.map.isRequired, + status: ImmutablePropTypes.map, onCancel: React.PropTypes.func.isRequired, intl: React.PropTypes.object.isRequired }, @@ -36,17 +36,22 @@ const ReplyIndicator = React.createClass({ }, render () { - const { intl } = this.props; - const content = { __html: emojify(this.props.status.get('content')) }; + const { status, intl } = this.props; + + if (!status) { + return null; + } + + const content = { __html: emojify(status.get('content')) }; return ( <div className='reply-indicator'> <div style={{ overflow: 'hidden', marginBottom: '5px' }}> <div style={{ float: 'right', lineHeight: '24px' }}><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} /></div> - <a href={this.props.status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name' style={{ display: 'block', maxWidth: '100%', paddingRight: '25px', textDecoration: 'none', overflow: 'hidden', lineHeight: '24px' }}> - <div style={{ float: 'left', marginRight: '5px' }}><Avatar size={24} src={this.props.status.getIn(['account', 'avatar'])} /></div> - <DisplayName account={this.props.status.get('account')} /> + <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name' style={{ display: 'block', maxWidth: '100%', paddingRight: '25px', textDecoration: 'none', overflow: 'hidden', lineHeight: '24px' }}> + <div style={{ float: 'left', marginRight: '5px' }}><Avatar size={24} src={status.getIn(['account', 'avatar'])} /></div> + <DisplayName account={status.get('account')} /> </a> </div> diff --git a/app/assets/javascripts/components/features/compose/components/search.jsx b/app/assets/javascripts/components/features/compose/components/search.jsx index c1f23939d..936e003f2 100644 --- a/app/assets/javascripts/components/features/compose/components/search.jsx +++ b/app/assets/javascripts/components/features/compose/components/search.jsx @@ -1,118 +1,68 @@ import PureRenderMixin from 'react-addons-pure-render-mixin'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import Autosuggest from 'react-autosuggest'; -import AutosuggestAccountContainer from '../containers/autosuggest_account_container'; -import { debounce } from 'react-decoration'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; const messages = defineMessages({ placeholder: { id: 'search.placeholder', defaultMessage: 'Search' } }); -const getSuggestionValue = suggestion => suggestion.value; - -const renderSuggestion = suggestion => { - if (suggestion.type === 'account') { - return <AutosuggestAccountContainer id={suggestion.id} />; - } else { - return <span>#{suggestion.id}</span> - } -}; - -const renderSectionTitle = section => ( - <strong><FormattedMessage id={`search.${section.title}`} defaultMessage={section.title} /></strong> -); - -const getSectionSuggestions = section => section.items; - -const outerStyle = { - padding: '10px', - lineHeight: '20px', - position: 'relative' -}; - -const iconStyle = { - position: 'absolute', - top: '18px', - right: '20px', - fontSize: '18px', - pointerEvents: 'none' -}; - const Search = React.createClass({ - contextTypes: { - router: React.PropTypes.object - }, - propTypes: { - suggestions: React.PropTypes.array.isRequired, value: React.PropTypes.string.isRequired, + submitted: React.PropTypes.bool, onChange: React.PropTypes.func.isRequired, + onSubmit: React.PropTypes.func.isRequired, onClear: React.PropTypes.func.isRequired, - onFetch: React.PropTypes.func.isRequired, - onReset: React.PropTypes.func.isRequired, + onShow: React.PropTypes.func.isRequired, intl: React.PropTypes.object.isRequired }, mixins: [PureRenderMixin], - onChange (_, { newValue }) { - if (typeof newValue !== 'string') { - return; - } - - this.props.onChange(newValue); + handleChange (e) { + this.props.onChange(e.target.value); }, - onSuggestionsClearRequested () { + handleClear (e) { + e.preventDefault(); this.props.onClear(); }, - @debounce(500) - onSuggestionsFetchRequested ({ value }) { - value = value.replace('#', ''); - this.props.onFetch(value.trim()); + handleKeyDown (e) { + if (e.key === 'Enter') { + e.preventDefault(); + this.props.onSubmit(); + } }, - onSuggestionSelected (_, { suggestion }) { - if (suggestion.type === 'account') { - this.context.router.push(`/accounts/${suggestion.id}`); - } else { - this.context.router.push(`/timelines/tag/${suggestion.id}`); - } + handleFocus () { + this.props.onShow(); }, render () { - const inputProps = { - placeholder: this.props.intl.formatMessage(messages.placeholder), - value: this.props.value, - onChange: this.onChange, - className: 'search__input' - }; + const { intl, value, submitted } = this.props; + const hasValue = value.length > 0 || submitted; return ( - <div className='search' style={outerStyle}> - <Autosuggest - multiSection={true} - suggestions={this.props.suggestions} - focusFirstSuggestion={true} - focusInputOnSuggestionClick={false} - alwaysRenderSuggestions={false} - onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} - onSuggestionsClearRequested={this.onSuggestionsClearRequested} - onSuggestionSelected={this.onSuggestionSelected} - getSuggestionValue={getSuggestionValue} - renderSuggestion={renderSuggestion} - renderSectionTitle={renderSectionTitle} - getSectionSuggestions={getSectionSuggestions} - inputProps={inputProps} + <div className='search'> + <input + className='search__input' + type='text' + placeholder={intl.formatMessage(messages.placeholder)} + value={value} + onChange={this.handleChange} + onKeyUp={this.handleKeyDown} + onFocus={this.handleFocus} /> - <div style={iconStyle}><i className='fa fa-search' /></div> + <div className='search__icon'> + <i className={`fa fa-search ${hasValue ? '' : 'active'}`} /> + <i className={`fa fa-times-circle ${hasValue ? 'active' : ''}`} onClick={this.handleClear} /> + </div> </div> ); - }, + } }); diff --git a/app/assets/javascripts/components/features/compose/components/search_results.jsx b/app/assets/javascripts/components/features/compose/components/search_results.jsx new file mode 100644 index 000000000..fd05e7f7e --- /dev/null +++ b/app/assets/javascripts/components/features/compose/components/search_results.jsx @@ -0,0 +1,68 @@ +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import AccountContainer from '../../../containers/account_container'; +import StatusContainer from '../../../containers/status_container'; +import { Link } from 'react-router'; + +const SearchResults = React.createClass({ + + propTypes: { + results: ImmutablePropTypes.map.isRequired + }, + + mixins: [PureRenderMixin], + + 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 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} {count, plural, one {result} other {results}}' values={{ count }} /> + </div> + + {accounts} + {statuses} + {hashtags} + </div> + ); + } + +}); + +export default SearchResults; diff --git a/app/assets/javascripts/components/features/compose/components/text_icon_button.jsx b/app/assets/javascripts/components/features/compose/components/text_icon_button.jsx new file mode 100644 index 000000000..e3ac63d87 --- /dev/null +++ b/app/assets/javascripts/components/features/compose/components/text_icon_button.jsx @@ -0,0 +1,31 @@ +import PureRenderMixin from 'react-addons-pure-render-mixin'; + +const TextIconButton = React.createClass({ + + propTypes: { + label: React.PropTypes.string.isRequired, + title: React.PropTypes.string, + active: React.PropTypes.bool, + onClick: React.PropTypes.func.isRequired + }, + + mixins: [PureRenderMixin], + + handleClick (e) { + e.preventDefault(); + this.props.onClick(); + }, + + render () { + const { label, title, active } = this.props; + + return ( + <button title={title} aria-label={title} className={`text-icon-button ${active ? 'active' : ''}`} onClick={this.handleClick}> + {label} + </button> + ); + } + +}); + +export default TextIconButton; diff --git a/app/assets/javascripts/components/features/compose/components/upload_button.jsx b/app/assets/javascripts/components/features/compose/components/upload_button.jsx index 4c8181aa1..2ba0e8fd2 100644 --- a/app/assets/javascripts/components/features/compose/components/upload_button.jsx +++ b/app/assets/javascripts/components/features/compose/components/upload_button.jsx @@ -6,6 +6,11 @@ const messages = defineMessages({ upload: { id: 'upload_button.label', defaultMessage: 'Add media' } }); +const iconStyle = { + lineHeight: '27px', + height: null +}; + const UploadButton = React.createClass({ propTypes: { @@ -37,7 +42,7 @@ const UploadButton = React.createClass({ return ( <div style={this.props.style}> - <IconButton icon='photo' title={intl.formatMessage(messages.upload)} disabled={disabled} onClick={this.handleClick} size={24} /> + <IconButton icon='camera' title={intl.formatMessage(messages.upload)} disabled={disabled} onClick={this.handleClick} style={iconStyle} size={18} inverted /> <input key={resetFileKey} ref={this.setRef} type='file' multiple={false} onChange={this.handleChange} disabled={disabled} style={{ display: 'none' }} /> </div> ); diff --git a/app/assets/javascripts/components/features/compose/components/upload_form.jsx b/app/assets/javascripts/components/features/compose/components/upload_form.jsx index 94c94b4b7..77590d90d 100644 --- a/app/assets/javascripts/components/features/compose/components/upload_form.jsx +++ b/app/assets/javascripts/components/features/compose/components/upload_form.jsx @@ -2,6 +2,8 @@ import PureRenderMixin from 'react-addons-pure-render-mixin'; import ImmutablePropTypes from 'react-immutable-proptypes'; import IconButton from '../../../components/icon_button'; import { defineMessages, injectIntl } from 'react-intl'; +import UploadProgressContainer from '../containers/upload_progress_container'; +import { Motion, spring } from 'react-motion'; const messages = defineMessages({ undo: { id: 'upload_form.undo', defaultMessage: 'Undo' } @@ -11,7 +13,6 @@ const UploadForm = React.createClass({ propTypes: { media: ImmutablePropTypes.list.isRequired, - is_uploading: React.PropTypes.bool, onRemoveFile: React.PropTypes.func.isRequired, intl: React.PropTypes.object.isRequired }, @@ -21,21 +22,22 @@ const UploadForm = React.createClass({ render () { const { intl, media } = this.props; - if (!media.size) { - return null; - } - - const uploads = media.map(attachment => ( - <div key={attachment.get('id')} style={{ borderRadius: '4px', marginBottom: '10px' }} className='transparent-background'> - <div style={{ width: '100%', height: '100px', borderRadius: '4px', background: `url(${attachment.get('preview_url')}) no-repeat center`, backgroundSize: 'cover' }}> - <IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.props.onRemoveFile.bind(this, attachment.get('id'))} /> - </div> + const uploads = media.map(attachment => + <div key={attachment.get('id')} style={{ margin: '5px', flex: '1 1 0' }}> + <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}> + {({ scale }) => + <div style={{ transform: `translateZ(0) scale(${scale})`, width: '100%', height: '100px', borderRadius: '4px', background: `url(${attachment.get('preview_url')}) no-repeat center`, backgroundSize: 'cover' }}> + <IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.props.onRemoveFile.bind(this, attachment.get('id'))} /> + </div> + } + </Motion> </div> - )); + ); return ( - <div style={{ marginBottom: '20px', padding: '10px', overflow: 'hidden', flexShrink: '0' }}> - {uploads} + <div style={{ overflow: 'hidden' }}> + <UploadProgressContainer /> + <div style={{ display: 'flex', padding: '5px' }}>{uploads}</div> </div> ); } diff --git a/app/assets/javascripts/components/features/compose/components/upload_progress.jsx b/app/assets/javascripts/components/features/compose/components/upload_progress.jsx new file mode 100644 index 000000000..86ffbf936 --- /dev/null +++ b/app/assets/javascripts/components/features/compose/components/upload_progress.jsx @@ -0,0 +1,44 @@ +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import { Motion, spring } from 'react-motion'; +import { FormattedMessage } from 'react-intl'; + +const UploadProgress = React.createClass({ + + propTypes: { + active: React.PropTypes.bool, + progress: React.PropTypes.number + }, + + mixins: [PureRenderMixin], + + render () { + const { active, progress } = this.props; + + if (!active) { + return null; + } + + return ( + <div className='upload-progress'> + <div> + <i className='fa fa-upload' /> + </div> + + <div style={{ flex: '1 1 auto' }}> + <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> + ); + } + +}); + +export default UploadProgress; diff --git a/app/assets/javascripts/components/features/compose/containers/autosuggest_status_container.jsx b/app/assets/javascripts/components/features/compose/containers/autosuggest_status_container.jsx new file mode 100644 index 000000000..ef46eb09c --- /dev/null +++ b/app/assets/javascripts/components/features/compose/containers/autosuggest_status_container.jsx @@ -0,0 +1,15 @@ +import { connect } from 'react-redux'; +import AutosuggestStatus from '../components/autosuggest_status'; +import { makeGetStatus } from '../../../selectors'; + +const makeMapStateToProps = () => { + const getStatus = makeGetStatus(); + + const mapStateToProps = (state, { id }) => ({ + status: getStatus(state, id) + }); + + return mapStateToProps; +}; + +export default connect(makeMapStateToProps)(AutosuggestStatus); diff --git a/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx b/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx index c027875cd..604e1182f 100644 --- a/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx +++ b/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx @@ -1,91 +1,78 @@ import { connect } from 'react-redux'; import ComposeForm from '../components/compose_form'; +import { uploadCompose } from '../../../actions/compose'; +import { createSelector } from 'reselect'; import { changeCompose, submitCompose, - cancelReplyCompose, clearComposeSuggestions, fetchComposeSuggestions, selectComposeSuggestion, - changeComposeSensitivity, - changeComposeSpoilerness, changeComposeSpoilerText, - changeComposeVisibility, - changeComposeListability + insertEmojiCompose } from '../../../actions/compose'; -import { makeGetStatus } from '../../../selectors'; -const makeMapStateToProps = () => { - const getStatus = makeGetStatus(); +const getMentionedUsernames = createSelector(state => state.getIn(['compose', 'text']), text => text.match(/(?:^|[^\/\w])@([a-z0-9_]+@[a-z0-9\.\-]+)/ig)); - const mapStateToProps = function (state, props) { - return { - text: state.getIn(['compose', 'text']), - suggestion_token: state.getIn(['compose', 'suggestion_token']), - suggestions: state.getIn(['compose', 'suggestions']), - sensitive: state.getIn(['compose', 'sensitive']), - spoiler: state.getIn(['compose', 'spoiler']), - spoiler_text: state.getIn(['compose', 'spoiler_text']), - unlisted: state.getIn(['compose', 'unlisted'], ), - private: state.getIn(['compose', 'private']), - fileDropDate: state.getIn(['compose', 'fileDropDate']), - is_submitting: state.getIn(['compose', 'is_submitting']), - is_uploading: state.getIn(['compose', 'is_uploading']), - in_reply_to: getStatus(state, state.getIn(['compose', 'in_reply_to'])), - media_count: state.getIn(['compose', 'media_attachments']).size, - me: state.getIn(['compose', 'me']), - }; - }; +const getMentionedDomains = createSelector(getMentionedUsernames, mentionedUsernamesWithDomains => { + return mentionedUsernamesWithDomains !== null ? [...new Set(mentionedUsernamesWithDomains.map(item => item.split('@')[2]))] : []; +}); - return mapStateToProps; -}; +const mapStateToProps = (state, props) => { + const mentionedUsernames = getMentionedUsernames(state); + const mentionedUsernamesWithDomains = getMentionedDomains(state); -const mapDispatchToProps = function (dispatch) { return { - onChange (text) { - dispatch(changeCompose(text)); - }, + text: state.getIn(['compose', 'text']), + suggestion_token: state.getIn(['compose', 'suggestion_token']), + suggestions: state.getIn(['compose', 'suggestions']), + 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']), + me: state.getIn(['compose', 'me']), + needsPrivacyWarning: (state.getIn(['compose', 'privacy']) === 'private' || state.getIn(['compose', 'privacy']) === 'direct') && mentionedUsernames !== null, + mentionedDomains: mentionedUsernamesWithDomains + }; +}; - onSubmit () { - dispatch(submitCompose()); - }, +const mapDispatchToProps = (dispatch) => ({ - onCancelReply () { - dispatch(cancelReplyCompose()); - }, + onChange (text) { + dispatch(changeCompose(text)); + }, - onClearSuggestions () { - dispatch(clearComposeSuggestions()); - }, + onSubmit () { + dispatch(submitCompose()); + }, - onFetchSuggestions (token) { - dispatch(fetchComposeSuggestions(token)); - }, + onClearSuggestions () { + dispatch(clearComposeSuggestions()); + }, - onSuggestionSelected (position, token, accountId) { - dispatch(selectComposeSuggestion(position, token, accountId)); - }, + onFetchSuggestions (token) { + dispatch(fetchComposeSuggestions(token)); + }, - onChangeSensitivity (checked) { - dispatch(changeComposeSensitivity(checked)); - }, + onSuggestionSelected (position, token, accountId) { + dispatch(selectComposeSuggestion(position, token, accountId)); + }, - onChangeSpoilerness (checked) { - dispatch(changeComposeSpoilerness(checked)); - }, + onChangeSpoilerText (checked) { + dispatch(changeComposeSpoilerText(checked)); + }, - onChangeSpoilerText (checked) { - dispatch(changeComposeSpoilerText(checked)); - }, + onPaste (files) { + dispatch(uploadCompose(files)); + }, - onChangeVisibility (checked) { - dispatch(changeComposeVisibility(checked)); - }, + onPickEmoji (position, data) { + dispatch(insertEmojiCompose(position, data)); + }, - onChangeListability (checked) { - dispatch(changeComposeListability(checked)); - } - } -}; +}); -export default connect(makeMapStateToProps, mapDispatchToProps)(ComposeForm); +export default connect(mapStateToProps, mapDispatchToProps)(ComposeForm); diff --git a/app/assets/javascripts/components/features/compose/containers/privacy_dropdown_container.jsx b/app/assets/javascripts/components/features/compose/containers/privacy_dropdown_container.jsx new file mode 100644 index 000000000..1eee8f84c --- /dev/null +++ b/app/assets/javascripts/components/features/compose/containers/privacy_dropdown_container.jsx @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import PrivacyDropdown from '../components/privacy_dropdown'; +import { changeComposeVisibility } from '../../../actions/compose'; + +const mapStateToProps = state => ({ + value: state.getIn(['compose', 'privacy']) +}); + +const mapDispatchToProps = dispatch => ({ + + onChange (value) { + dispatch(changeComposeVisibility(value)); + } + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(PrivacyDropdown); diff --git a/app/assets/javascripts/components/features/compose/containers/reply_indicator_container.jsx b/app/assets/javascripts/components/features/compose/containers/reply_indicator_container.jsx new file mode 100644 index 000000000..39b48f3b6 --- /dev/null +++ b/app/assets/javascripts/components/features/compose/containers/reply_indicator_container.jsx @@ -0,0 +1,24 @@ +import { connect } from 'react-redux'; +import { cancelReplyCompose } from '../../../actions/compose'; +import { makeGetStatus } from '../../../selectors'; +import ReplyIndicator from '../components/reply_indicator'; + +const makeMapStateToProps = () => { + const getStatus = makeGetStatus(); + + const mapStateToProps = (state, props) => ({ + status: getStatus(state, state.getIn(['compose', 'in_reply_to'])), + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = dispatch => ({ + + onCancel () { + dispatch(cancelReplyCompose()); + } + +}); + +export default connect(makeMapStateToProps, mapDispatchToProps)(ReplyIndicator); diff --git a/app/assets/javascripts/components/features/compose/containers/search_container.jsx b/app/assets/javascripts/components/features/compose/containers/search_container.jsx index 17a68f2fc..906c0c28c 100644 --- a/app/assets/javascripts/components/features/compose/containers/search_container.jsx +++ b/app/assets/javascripts/components/features/compose/containers/search_container.jsx @@ -1,15 +1,15 @@ import { connect } from 'react-redux'; import { changeSearch, - clearSearchSuggestions, - fetchSearchSuggestions, - resetSearch + clearSearch, + submitSearch, + showSearch } from '../../../actions/search'; import Search from '../components/search'; const mapStateToProps = state => ({ - suggestions: state.getIn(['search', 'suggestions']), - value: state.getIn(['search', 'value']) + value: state.getIn(['search', 'value']), + submitted: state.getIn(['search', 'submitted']) }); const mapDispatchToProps = dispatch => ({ @@ -19,15 +19,15 @@ const mapDispatchToProps = dispatch => ({ }, onClear () { - dispatch(clearSearchSuggestions()); + dispatch(clearSearch()); }, - onFetch (value) { - dispatch(fetchSearchSuggestions(value)); + onSubmit () { + dispatch(submitSearch()); }, - onReset () { - dispatch(resetSearch()); + onShow () { + dispatch(showSearch()); } }); diff --git a/app/assets/javascripts/components/features/compose/containers/search_results_container.jsx b/app/assets/javascripts/components/features/compose/containers/search_results_container.jsx new file mode 100644 index 000000000..e5911fd38 --- /dev/null +++ b/app/assets/javascripts/components/features/compose/containers/search_results_container.jsx @@ -0,0 +1,8 @@ +import { connect } from 'react-redux'; +import SearchResults from '../components/search_results'; + +const mapStateToProps = state => ({ + results: state.getIn(['search', 'results']) +}); + +export default connect(mapStateToProps)(SearchResults); diff --git a/app/assets/javascripts/components/features/compose/containers/sensitive_button_container.jsx b/app/assets/javascripts/components/features/compose/containers/sensitive_button_container.jsx new file mode 100644 index 000000000..074b568f4 --- /dev/null +++ b/app/assets/javascripts/components/features/compose/containers/sensitive_button_container.jsx @@ -0,0 +1,49 @@ +import { connect } from 'react-redux'; +import TextIconButton from '../components/text_icon_button'; +import { changeComposeSensitivity } from '../../../actions/compose'; +import { Motion, spring } from 'react-motion'; +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']) +}); + +const mapDispatchToProps = dispatch => ({ + + onClick () { + dispatch(changeComposeSensitivity()); + } + +}); + +const SensitiveButton = React.createClass({ + + propTypes: { + visible: React.PropTypes.bool, + active: React.PropTypes.bool, + onClick: React.PropTypes.func.isRequired, + intl: React.PropTypes.object.isRequired + }, + + render () { + const { visible, active, onClick, intl } = this.props; + + return ( + <Motion defaultStyle={{ scale: 0.87 }} style={{ scale: spring(visible ? 1 : 0.87, { stiffness: 200, damping: 3 }) }}> + {({ scale }) => + <div style={{ display: visible ? 'block' : 'none', transform: `translateZ(0) scale(${scale})` }}> + <TextIconButton onClick={onClick} label='NSFW' title={intl.formatMessage(messages.title)} active={active} /> + </div> + } + </Motion> + ); + } + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(SensitiveButton)); diff --git a/app/assets/javascripts/components/features/compose/containers/spoiler_button_container.jsx b/app/assets/javascripts/components/features/compose/containers/spoiler_button_container.jsx new file mode 100644 index 000000000..61ac32b85 --- /dev/null +++ b/app/assets/javascripts/components/features/compose/containers/spoiler_button_container.jsx @@ -0,0 +1,24 @@ +import { connect } from 'react-redux'; +import TextIconButton from '../components/text_icon_button'; +import { changeComposeSpoilerness } from '../../../actions/compose'; +import { injectIntl, defineMessages } from 'react-intl'; + +const messages = defineMessages({ + title: { id: 'compose_form.spoiler', defaultMessage: 'Hide text behind content warning' } +}); + +const mapStateToProps = (state, { intl }) => ({ + label: 'CW', + title: intl.formatMessage(messages.title), + active: state.getIn(['compose', 'spoiler']) +}); + +const mapDispatchToProps = dispatch => ({ + + onClick () { + dispatch(changeComposeSpoilerness()); + } + +}); + +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(TextIconButton)); diff --git a/app/assets/javascripts/components/features/compose/containers/upload_progress_container.jsx b/app/assets/javascripts/components/features/compose/containers/upload_progress_container.jsx new file mode 100644 index 000000000..b0f1d4d19 --- /dev/null +++ b/app/assets/javascripts/components/features/compose/containers/upload_progress_container.jsx @@ -0,0 +1,9 @@ +import { connect } from 'react-redux'; +import UploadProgress from '../components/upload_progress'; + +const mapStateToProps = (state, props) => ({ + active: state.getIn(['compose', 'is_uploading']), + progress: state.getIn(['compose', 'progress']) +}); + +export default connect(mapStateToProps)(UploadProgress); diff --git a/app/assets/javascripts/components/features/compose/index.jsx b/app/assets/javascripts/components/features/compose/index.jsx index f6095c0c6..9421de3ff 100644 --- a/app/assets/javascripts/components/features/compose/index.jsx +++ b/app/assets/javascripts/components/features/compose/index.jsx @@ -1,17 +1,34 @@ -import Drawer from './components/drawer'; import ComposeFormContainer from './containers/compose_form_container'; import UploadFormContainer from './containers/upload_form_container'; import NavigationContainer from './containers/navigation_container'; import PureRenderMixin from 'react-addons-pure-render-mixin'; -import SearchContainer from './containers/search_container'; import { connect } from 'react-redux'; import { mountCompose, unmountCompose } from '../../actions/compose'; +import { Link } from 'react-router'; +import { injectIntl, defineMessages } from 'react-intl'; +import SearchContainer from './containers/search_container'; +import { Motion, spring } from 'react-motion'; +import SearchResultsContainer from './containers/search_results_container'; + +const messages = defineMessages({ + start: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, + public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Whole Known Network' }, + community: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' }, + preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, + logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' } +}); + +const mapStateToProps = state => ({ + showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']) +}); const Compose = React.createClass({ propTypes: { dispatch: React.PropTypes.func.isRequired, - withHeader: React.PropTypes.bool + withHeader: React.PropTypes.bool, + showSearch: React.PropTypes.bool, + intl: React.PropTypes.object.isRequired }, mixins: [PureRenderMixin], @@ -25,16 +42,46 @@ const Compose = React.createClass({ }, render () { + const { withHeader, showSearch, intl } = this.props; + + let header = ''; + + if (withHeader) { + header = ( + <div className='drawer__header'> + <Link title={intl.formatMessage(messages.start)} className='drawer__tab' to='/getting-started'><i className='fa fa-fw fa-asterisk' /></Link> + <Link title={intl.formatMessage(messages.community)} className='drawer__tab' to='/timelines/public/local'><i className='fa fa-fw fa-users' /></Link> + <Link title={intl.formatMessage(messages.public)} className='drawer__tab' to='/timelines/public'><i className='fa fa-fw fa-globe' /></Link> + <a title={intl.formatMessage(messages.preferences)} className='drawer__tab' href='/settings/preferences'><i className='fa fa-fw fa-cog' /></a> + <a title={intl.formatMessage(messages.logout)} className='drawer__tab' href='/auth/sign_out' data-method='delete'><i className='fa fa-fw fa-sign-out' /></a> + </div> + ); + } + return ( - <Drawer withHeader={this.props.withHeader}> + <div className='drawer'> + {header} + <SearchContainer /> - <NavigationContainer /> - <ComposeFormContainer /> - <UploadFormContainer /> - </Drawer> + + <div className='drawer__pager'> + <div className='drawer__inner'> + <NavigationContainer /> + <ComposeFormContainer /> + </div> + + <Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}> + {({ x }) => + <div className='drawer__inner darker' style={{ transform: `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}> + <SearchResultsContainer /> + </div> + } + </Motion> + </div> + </div> ); } }); -export default connect()(Compose); +export default connect(mapStateToProps)(injectIntl(Compose)); diff --git a/app/assets/javascripts/components/features/follow_requests/components/account_authorize.jsx b/app/assets/javascripts/components/features/follow_requests/components/account_authorize.jsx index 0d41d192f..1766655c2 100644 --- a/app/assets/javascripts/components/features/follow_requests/components/account_authorize.jsx +++ b/app/assets/javascripts/components/features/follow_requests/components/account_authorize.jsx @@ -16,11 +16,8 @@ const outerStyle = { }; const panelStyle = { - background: '#2f3441', display: 'flex', flexDirection: 'row', - borderTop: '1px solid #363c4b', - borderBottom: '1px solid #363c4b', padding: '10px 0' }; @@ -40,10 +37,10 @@ const AccountAuthorize = ({ intl, account, onAuthorize, onReject }) => { <DisplayName account={account} /> </Permalink> - <div style={{ color: '#616b86', fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} /> + <div style={{ fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} /> </div> - <div style={panelStyle}> + <div className='account--panel' style={panelStyle}> <div style={btnStyle}><IconButton title={intl.formatMessage(messages.authorize)} icon='check' onClick={onAuthorize} /></div> <div style={btnStyle}><IconButton title={intl.formatMessage(messages.reject)} icon='times' onClick={onReject} /></div> </div> diff --git a/app/assets/javascripts/components/features/getting_started/index.jsx b/app/assets/javascripts/components/features/getting_started/index.jsx index 0e1937b43..d7a78d9cc 100644 --- a/app/assets/javascripts/components/features/getting_started/index.jsx +++ b/app/assets/javascripts/components/features/getting_started/index.jsx @@ -7,7 +7,8 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; const messages = defineMessages({ heading: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, - public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Public timeline' }, + public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Whole Known Network' }, + community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' }, preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, sign_out: { id: 'navigation_bar.logout', defaultMessage: 'Sign out' }, @@ -30,6 +31,7 @@ const GettingStarted = ({ intl, me }) => { return ( <Column icon='asterisk' heading={intl.formatMessage(messages.heading)}> <div style={{ position: 'relative' }}> + <ColumnLink icon='users' text={intl.formatMessage(messages.community_timeline)} to='/timelines/public/local' /> <ColumnLink icon='globe' text={intl.formatMessage(messages.public_timeline)} to='/timelines/public' /> <ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' /> <ColumnLink icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' /> @@ -39,12 +41,9 @@ const GettingStarted = ({ intl, me }) => { <ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' /> </div> - <div className='scrollable optionally-scrollable'> + <div className='scrollable optionally-scrollable' style={{ display: 'flex', flexDirection: 'column' }}> <div className='static-content getting-started'> - <p><FormattedMessage id='getting_started.about_addressing' defaultMessage='You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the form at the top of the sidebar.' /></p> - <p><FormattedMessage id='getting_started.about_shortcuts' defaultMessage='If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.' /></p> - <p><FormattedMessage id='getting_started.about_developer' defaultMessage='The developer of this project can be followed as Gargron@mastodon.social' /></p> - <p><FormattedMessage id='getting_started.open_source_notice' defaultMessage='Mastodon is open source software. You can contribute or report issues on github at {github}' values={{ github: <a href="https://github.com/tootsuite/mastodon">tootsuite/mastodon</a> }} /></p> + <p><FormattedMessage id='getting_started.open_source_notice' defaultMessage='Mastodon is open source software. You can contribute or report issues on GitHub at {github}. {apps}.' values={{ github: <a href="https://github.com/tootsuite/mastodon" target="_blank">tootsuite/mastodon</a>, apps: <a href="https://github.com/tootsuite/mastodon/blob/master/docs/Using-Mastodon/Apps.md" target="_blank"><FormattedMessage id='getting_started.apps' defaultMessage='Various apps are available' /></a> }} /></p> </div> </div> </Column> diff --git a/app/assets/javascripts/components/features/hashtag_timeline/index.jsx b/app/assets/javascripts/components/features/hashtag_timeline/index.jsx index 4a0e7684d..7fb413336 100644 --- a/app/assets/javascripts/components/features/hashtag_timeline/index.jsx +++ b/app/assets/javascripts/components/features/hashtag_timeline/index.jsx @@ -8,9 +8,11 @@ import { deleteFromTimelines } from '../../actions/timelines'; import ColumnBackButtonSlim from '../../components/column_back_button_slim'; +import { FormattedMessage } from 'react-intl'; import createStream from '../../stream'; const mapStateToProps = state => ({ + hasUnread: state.getIn(['timelines', 'tag', 'unread']) > 0, accessToken: state.getIn(['meta', 'access_token']) }); @@ -19,7 +21,8 @@ const HashtagTimeline = React.createClass({ propTypes: { params: React.PropTypes.object.isRequired, dispatch: React.PropTypes.func.isRequired, - accessToken: React.PropTypes.string.isRequired + accessToken: React.PropTypes.string.isRequired, + hasUnread: React.PropTypes.bool }, mixins: [PureRenderMixin], @@ -71,12 +74,12 @@ const HashtagTimeline = React.createClass({ }, render () { - const { id } = this.props.params; + const { id, hasUnread } = this.props.params; return ( - <Column icon='hashtag' heading={id}> + <Column icon='hashtag' active={hasUnread} heading={id}> <ColumnBackButtonSlim /> - <StatusListContainer type='tag' id={id} /> + <StatusListContainer type='tag' id={id} emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />} /> </Column> ); }, diff --git a/app/assets/javascripts/components/features/home_timeline/components/column_settings.jsx b/app/assets/javascripts/components/features/home_timeline/components/column_settings.jsx index 714be309b..92e700874 100644 --- a/app/assets/javascripts/components/features/home_timeline/components/column_settings.jsx +++ b/app/assets/javascripts/components/features/home_timeline/components/column_settings.jsx @@ -6,11 +6,10 @@ import SettingToggle from '../../notifications/components/setting_toggle'; import SettingText from './setting_text'; const messages = defineMessages({ - filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter by regular expressions' } + filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' } }); const outerStyle = { - background: '#373b4a', padding: '15px' }; @@ -18,7 +17,6 @@ const sectionStyle = { cursor: 'default', display: 'block', fontWeight: '500', - color: '#9baec8', marginBottom: '10px' }; @@ -42,18 +40,18 @@ const ColumnSettings = React.createClass({ return ( <ColumnCollapsable icon='sliders' fullHeight={209} onCollapse={onSave}> - <div style={outerStyle}> - <span style={sectionStyle}><FormattedMessage id='home.column_settings.basic' defaultMessage='Basic' /></span> + <div className='column-settings--outer' style={outerStyle}> + <span className='column-settings--section' style={sectionStyle}><FormattedMessage id='home.column_settings.basic' defaultMessage='Basic' /></span> <div style={rowStyle}> - <SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_reblogs' defaultMessage='Show reblogs' />} /> + <SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_reblogs' defaultMessage='Show boosts' />} /> </div> <div style={rowStyle}> <SettingToggle settings={settings} settingKey={['shows', 'reply']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_replies' defaultMessage='Show replies' />} /> </div> - <span style={sectionStyle}><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span> + <span className='column-settings--section' style={sectionStyle}><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span> <div style={rowStyle}> <SettingText settings={settings} settingKey={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} /> diff --git a/app/assets/javascripts/components/features/home_timeline/index.jsx b/app/assets/javascripts/components/features/home_timeline/index.jsx index 5d2263f15..a2b775764 100644 --- a/app/assets/javascripts/components/features/home_timeline/index.jsx +++ b/app/assets/javascripts/components/features/home_timeline/index.jsx @@ -1,32 +1,39 @@ +import { connect } from 'react-redux'; import PureRenderMixin from 'react-addons-pure-render-mixin'; import StatusListContainer from '../ui/containers/status_list_container'; import Column from '../ui/components/column'; -import { defineMessages, injectIntl } from 'react-intl'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ColumnSettingsContainer from './containers/column_settings_container'; +import { Link } from 'react-router'; const messages = defineMessages({ title: { id: 'column.home', defaultMessage: 'Home' } }); +const mapStateToProps = state => ({ + hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0 +}); + const HomeTimeline = React.createClass({ propTypes: { - intl: React.PropTypes.object.isRequired + intl: React.PropTypes.object.isRequired, + hasUnread: React.PropTypes.bool }, mixins: [PureRenderMixin], render () { - const { intl } = this.props; + const { intl, hasUnread } = this.props; return ( - <Column icon='home' heading={intl.formatMessage(messages.title)}> + <Column icon='home' active={hasUnread} heading={intl.formatMessage(messages.title)}> <ColumnSettingsContainer /> - <StatusListContainer {...this.props} type='home' /> + <StatusListContainer {...this.props} type='home' emptyMessage={<FormattedMessage id='empty_column.home' defaultMessage="You aren't following anyone yet. Visit {public} or use search to get started and meet other users." values={{ public: <Link to='/timelines/public'><FormattedMessage id='empty_column.home.public_timeline' defaultMessage='the public timeline' /></Link> }} />} /> </Column> ); }, }); -export default injectIntl(HomeTimeline); +export default connect(mapStateToProps)(injectIntl(HomeTimeline)); diff --git a/app/assets/javascripts/components/features/notifications/components/clear_column_button.jsx b/app/assets/javascripts/components/features/notifications/components/clear_column_button.jsx index d20a4d170..6aa9d1efa 100644 --- a/app/assets/javascripts/components/features/notifications/components/clear_column_button.jsx +++ b/app/assets/javascripts/components/features/notifications/components/clear_column_button.jsx @@ -5,11 +5,11 @@ const iconStyle = { right: '48px', top: '0', cursor: 'pointer', - background: '#2f3441' + zIndex: '2' }; const ClearColumnButton = ({ onClick }) => ( - <div className='column-icon' style={iconStyle} onClick={onClick}> + <div className='column-icon' tabindex='0' style={iconStyle} onClick={onClick}> <i className='fa fa-trash' /> </div> ); diff --git a/app/assets/javascripts/components/features/notifications/components/column_settings.jsx b/app/assets/javascripts/components/features/notifications/components/column_settings.jsx index b63c1881a..f1b8ef57f 100644 --- a/app/assets/javascripts/components/features/notifications/components/column_settings.jsx +++ b/app/assets/javascripts/components/features/notifications/components/column_settings.jsx @@ -5,7 +5,6 @@ import ColumnCollapsable from '../../../components/column_collapsable'; import SettingToggle from './setting_toggle'; const outerStyle = { - background: '#373b4a', padding: '15px' }; @@ -13,7 +12,6 @@ const sectionStyle = { cursor: 'default', display: 'block', fontWeight: '500', - color: '#9baec8', marginBottom: '10px' }; @@ -40,8 +38,8 @@ const ColumnSettings = React.createClass({ return ( <ColumnCollapsable icon='sliders' fullHeight={616} onCollapse={onSave}> - <div style={outerStyle}> - <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span> + <div className='column-settings--outer' style={outerStyle}> + <span className='column-settings--section' style={sectionStyle}><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span> <div style={rowStyle}> <SettingToggle settings={settings} settingKey={['alerts', 'follow']} onChange={onChange} label={alertStr} /> @@ -49,7 +47,7 @@ const ColumnSettings = React.createClass({ <SettingToggle settings={settings} settingKey={['sounds', 'follow']} onChange={onChange} label={soundStr} /> </div> - <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span> + <span className='column-settings--section' style={sectionStyle}><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span> <div style={rowStyle}> <SettingToggle settings={settings} settingKey={['alerts', 'favourite']} onChange={onChange} label={alertStr} /> @@ -57,7 +55,7 @@ const ColumnSettings = React.createClass({ <SettingToggle settings={settings} settingKey={['sounds', 'favourite']} onChange={onChange} label={soundStr} /> </div> - <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span> + <span className='column-settings--section' style={sectionStyle}><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span> <div style={rowStyle}> <SettingToggle settings={settings} settingKey={['alerts', 'mention']} onChange={onChange} label={alertStr} /> @@ -65,7 +63,7 @@ const ColumnSettings = React.createClass({ <SettingToggle settings={settings} settingKey={['sounds', 'mention']} onChange={onChange} label={soundStr} /> </div> - <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span> + <span className='column-settings--section' style={sectionStyle}><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span> <div style={rowStyle}> <SettingToggle settings={settings} settingKey={['alerts', 'reblog']} onChange={onChange} label={alertStr} /> diff --git a/app/assets/javascripts/components/features/notifications/components/notification.jsx b/app/assets/javascripts/components/features/notifications/components/notification.jsx index 140ba9134..0de4df52e 100644 --- a/app/assets/javascripts/components/features/notifications/components/notification.jsx +++ b/app/assets/javascripts/components/features/notifications/components/notification.jsx @@ -5,17 +5,7 @@ import AccountContainer from '../../../containers/account_container'; import { FormattedMessage } from 'react-intl'; import Permalink from '../../../components/permalink'; import emojify from '../../../emoji'; -import escapeTextContentForBrowser from 'react/lib/escapeTextContentForBrowser'; - -const messageStyle = { - marginLeft: '68px', - padding: '8px 0', - paddingBottom: '0', - cursor: 'default', - color: '#d9e1e8', - fontSize: '15px', - position: 'relative' -}; +import escapeTextContentForBrowser from 'escape-html'; const linkStyle = { fontWeight: '500' @@ -32,9 +22,9 @@ const Notification = React.createClass({ renderFollow (account, link) { return ( <div className='notification'> - <div style={messageStyle}> + <div className='notification__message'> <div style={{ position: 'absolute', 'left': '-26px'}}> - <i className='fa fa-fw fa-user-plus' style={{ color: '#2b90d9' }} /> + <i className='fa fa-fw fa-user-plus' /> </div> <FormattedMessage id='notification.follow' defaultMessage='{name} followed you' values={{ name: link }} /> @@ -52,7 +42,7 @@ const Notification = React.createClass({ renderFavourite (notification, link) { return ( <div className='notification'> - <div style={messageStyle}> + <div className='notification__message'> <div style={{ position: 'absolute', 'left': '-26px'}}> <i className='fa fa-fw fa-star' style={{ color: '#ca8f04' }} /> </div> @@ -68,9 +58,9 @@ const Notification = React.createClass({ renderReblog (notification, link) { return ( <div className='notification'> - <div style={messageStyle}> + <div className='notification__message'> <div style={{ position: 'absolute', 'left': '-26px'}}> - <i className='fa fa-fw fa-retweet' style={{ color: '#2b90d9' }} /> + <i className='fa fa-fw fa-retweet' /> </div> <FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} /> diff --git a/app/assets/javascripts/components/features/notifications/components/setting_toggle.jsx b/app/assets/javascripts/components/features/notifications/components/setting_toggle.jsx index c2438f716..eae3c2be2 100644 --- a/app/assets/javascripts/components/features/notifications/components/setting_toggle.jsx +++ b/app/assets/javascripts/components/features/notifications/components/setting_toggle.jsx @@ -11,14 +11,13 @@ const labelSpanStyle = { display: 'inline-block', verticalAlign: 'middle', marginBottom: '14px', - marginLeft: '8px', - color: '#9baec8' + marginLeft: '8px' }; const SettingToggle = ({ settings, settingKey, label, onChange }) => ( <label style={labelStyle}> <Toggle checked={settings.getIn(settingKey)} onChange={(e) => onChange(settingKey, e.target.checked)} /> - <span style={labelSpanStyle}>{label}</span> + <span className='setting-toggle' style={labelSpanStyle}>{label}</span> </label> ); diff --git a/app/assets/javascripts/components/features/notifications/index.jsx b/app/assets/javascripts/components/features/notifications/index.jsx index 6d10768de..74b914ffd 100644 --- a/app/assets/javascripts/components/features/notifications/index.jsx +++ b/app/assets/javascripts/components/features/notifications/index.jsx @@ -2,10 +2,10 @@ import { connect } from 'react-redux'; import PureRenderMixin from 'react-addons-pure-render-mixin'; import ImmutablePropTypes from 'react-immutable-proptypes'; import Column from '../ui/components/column'; -import { expandNotifications, clearNotifications } from '../../actions/notifications'; +import { expandNotifications, clearNotifications, scrollTopNotifications } from '../../actions/notifications'; import NotificationContainer from './containers/notification_container'; import { ScrollContainer } from 'react-router-scroll'; -import { defineMessages, injectIntl } from 'react-intl'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ColumnSettingsContainer from './containers/column_settings_container'; import { createSelector } from 'reselect'; import Immutable from 'immutable'; @@ -13,7 +13,8 @@ import LoadMore from '../../components/load_more'; import ClearColumnButton from './components/clear_column_button'; const messages = defineMessages({ - title: { id: 'column.notifications', defaultMessage: 'Notifications' } + title: { id: 'column.notifications', defaultMessage: 'Notifications' }, + confirm: { id: 'notifications.clear_confirmation', defaultMessage: 'Are you sure you want to clear all your notifications?' } }); const getNotifications = createSelector([ @@ -23,7 +24,8 @@ const getNotifications = createSelector([ const mapStateToProps = state => ({ notifications: getNotifications(state), - isLoading: state.getIn(['notifications', 'isLoading'], true) + isLoading: state.getIn(['notifications', 'isLoading'], true), + isUnread: state.getIn(['notifications', 'unread']) > 0 }); const Notifications = React.createClass({ @@ -33,7 +35,8 @@ const Notifications = React.createClass({ dispatch: React.PropTypes.func.isRequired, trackScroll: React.PropTypes.bool, intl: React.PropTypes.object.isRequired, - isLoading: React.PropTypes.bool + isLoading: React.PropTypes.bool, + isUnread: React.PropTypes.bool }, getDefaultProps () { @@ -51,6 +54,10 @@ const Notifications = React.createClass({ if (250 > offset && !this.props.isLoading) { this.props.dispatch(expandNotifications()); + } else if (scrollTop < 100) { + this.props.dispatch(scrollTopNotifications(true)); + } else { + this.props.dispatch(scrollTopNotifications(false)); } }, @@ -66,7 +73,9 @@ const Notifications = React.createClass({ }, handleClear () { - this.props.dispatch(clearNotifications()); + if (window.confirm(this.props.intl.formatMessage(messages.confirm))) { + this.props.dispatch(clearNotifications()); + } }, setRef (c) { @@ -74,26 +83,42 @@ const Notifications = React.createClass({ }, render () { - const { intl, notifications, trackScroll, isLoading } = this.props; + const { intl, notifications, trackScroll, isLoading, isUnread } = this.props; - let loadMore = ''; + let loadMore = ''; + let scrollableArea = ''; + let unread = ''; if (!isLoading && notifications.size > 0) { loadMore = <LoadMore onClick={this.handleLoadMore} />; } - const scrollableArea = ( - <div className='scrollable' onScroll={this.handleScroll} ref={this.setRef}> - <div> - {notifications.map(item => <NotificationContainer key={item.get('id')} notification={item} accountId={item.get('account')} />)} - {loadMore} + if (isUnread) { + unread = <div className='notifications__unread-indicator' />; + } + + if (isLoading || notifications.size > 0) { + scrollableArea = ( + <div className='scrollable' onScroll={this.handleScroll} ref={this.setRef}> + {unread} + + <div> + {notifications.map(item => <NotificationContainer key={item.get('id')} notification={item} accountId={item.get('account')} />)} + {loadMore} + </div> </div> - </div> - ); + ); + } else { + scrollableArea = ( + <div className='empty-column-indicator' ref={this.setRef}> + <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." /> + </div> + ); + } if (trackScroll) { return ( - <Column icon='bell' heading={intl.formatMessage(messages.title)}> + <Column icon='bell' active={isUnread} heading={intl.formatMessage(messages.title)}> <ColumnSettingsContainer /> <ClearColumnButton onClick={this.handleClear} /> <ScrollContainer scrollKey='notifications'> @@ -103,7 +128,7 @@ const Notifications = React.createClass({ ); } else { return ( - <Column icon='bell' heading={intl.formatMessage(messages.title)}> + <Column icon='bell' active={isUnread} heading={intl.formatMessage(messages.title)}> <ColumnSettingsContainer /> <ClearColumnButton onClick={this.handleClear} /> {scrollableArea} diff --git a/app/assets/javascripts/components/features/public_timeline/index.jsx b/app/assets/javascripts/components/features/public_timeline/index.jsx index 36d68dbbb..6d766a83b 100644 --- a/app/assets/javascripts/components/features/public_timeline/index.jsx +++ b/app/assets/javascripts/components/features/public_timeline/index.jsx @@ -5,26 +5,32 @@ import Column from '../ui/components/column'; import { refreshTimeline, updateTimeline, - deleteFromTimelines + deleteFromTimelines, + connectTimeline, + disconnectTimeline } from '../../actions/timelines'; -import { defineMessages, injectIntl } from 'react-intl'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ColumnBackButtonSlim from '../../components/column_back_button_slim'; import createStream from '../../stream'; const messages = defineMessages({ - title: { id: 'column.public', defaultMessage: 'Public' } + title: { id: 'column.public', defaultMessage: 'Whole Known Network' } }); const mapStateToProps = state => ({ + hasUnread: state.getIn(['timelines', 'public', 'unread']) > 0, accessToken: state.getIn(['meta', 'access_token']) }); +let subscription; + const PublicTimeline = React.createClass({ propTypes: { dispatch: React.PropTypes.func.isRequired, intl: React.PropTypes.object.isRequired, - accessToken: React.PropTypes.string.isRequired + accessToken: React.PropTypes.string.isRequired, + hasUnread: React.PropTypes.bool }, mixins: [PureRenderMixin], @@ -34,7 +40,23 @@ const PublicTimeline = React.createClass({ dispatch(refreshTimeline('public')); - this.subscription = createStream(accessToken, 'public', { + if (typeof subscription !== 'undefined') { + return; + } + + subscription = createStream(accessToken, 'public', { + + connected () { + dispatch(connectTimeline('public')); + }, + + reconnected () { + dispatch(connectTimeline('public')); + }, + + disconnected () { + dispatch(disconnectTimeline('public')); + }, received (data) { switch(data.event) { @@ -51,19 +73,19 @@ const PublicTimeline = React.createClass({ }, componentWillUnmount () { - if (typeof this.subscription !== 'undefined') { - this.subscription.close(); - this.subscription = null; - } + // if (typeof subscription !== 'undefined') { + // subscription.close(); + // subscription = null; + // } }, render () { - const { intl } = this.props; + const { intl, hasUnread } = this.props; return ( - <Column icon='globe' heading={intl.formatMessage(messages.title)}> + <Column icon='globe' active={hasUnread} heading={intl.formatMessage(messages.title)}> <ColumnBackButtonSlim /> - <StatusListContainer type='public' /> + <StatusListContainer type='public' emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other instances to fill it up' />} /> </Column> ); }, diff --git a/app/assets/javascripts/components/features/report/components/status_check_box.jsx b/app/assets/javascripts/components/features/report/components/status_check_box.jsx new file mode 100644 index 000000000..6d976582b --- /dev/null +++ b/app/assets/javascripts/components/features/report/components/status_check_box.jsx @@ -0,0 +1,42 @@ +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import emojify from '../../../emoji'; +import Toggle from 'react-toggle'; + +const StatusCheckBox = React.createClass({ + + propTypes: { + status: ImmutablePropTypes.map.isRequired, + checked: React.PropTypes.bool, + onToggle: React.PropTypes.func.isRequired, + disabled: React.PropTypes.bool + }, + + mixins: [PureRenderMixin], + + render () { + const { status, checked, onToggle, disabled } = this.props; + const content = { __html: emojify(status.get('content')) }; + + if (status.get('reblog')) { + return null; + } + + return ( + <div className='status-check-box' style={{ display: 'flex' }}> + <div + className='status__content' + style={{ flex: '1 1 auto', padding: '10px' }} + dangerouslySetInnerHTML={content} + /> + + <div style={{ flex: '0 0 auto', padding: '10px', display: 'flex', justifyContent: 'center', alignItems: 'center' }}> + <Toggle checked={checked} onChange={onToggle} disabled={disabled} /> + </div> + </div> + ); + } + +}); + +export default StatusCheckBox; diff --git a/app/assets/javascripts/components/features/report/containers/status_check_box_container.jsx b/app/assets/javascripts/components/features/report/containers/status_check_box_container.jsx new file mode 100644 index 000000000..67ce9d9f3 --- /dev/null +++ b/app/assets/javascripts/components/features/report/containers/status_check_box_container.jsx @@ -0,0 +1,19 @@ +import { connect } from 'react-redux'; +import StatusCheckBox from '../components/status_check_box'; +import { toggleStatusReport } from '../../../actions/reports'; +import Immutable from 'immutable'; + +const mapStateToProps = (state, { id }) => ({ + status: state.getIn(['statuses', id]), + checked: state.getIn(['reports', 'new', 'status_ids'], Immutable.Set()).includes(id) +}); + +const mapDispatchToProps = (dispatch, { id }) => ({ + + onToggle (e) { + dispatch(toggleStatusReport(id, e.target.checked)); + } + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(StatusCheckBox); diff --git a/app/assets/javascripts/components/features/report/index.jsx b/app/assets/javascripts/components/features/report/index.jsx new file mode 100644 index 000000000..3177d28b1 --- /dev/null +++ b/app/assets/javascripts/components/features/report/index.jsx @@ -0,0 +1,130 @@ +import { connect } from 'react-redux'; +import { cancelReport, changeReportComment, submitReport } from '../../actions/reports'; +import { fetchAccountTimeline } from '../../actions/accounts'; +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Column from '../ui/components/column'; +import Button from '../../components/button'; +import { makeGetAccount } from '../../selectors'; +import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; +import StatusCheckBox from './containers/status_check_box_container'; +import Immutable from 'immutable'; +import ColumnBackButtonSlim from '../../components/column_back_button_slim'; + +const messages = defineMessages({ + heading: { id: 'report.heading', defaultMessage: 'New report' }, + placeholder: { id: 'report.placeholder', defaultMessage: 'Additional comments' }, + submit: { id: 'report.submit', defaultMessage: 'Submit' } +}); + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = state => { + const accountId = state.getIn(['reports', 'new', 'account_id']); + + return { + isSubmitting: state.getIn(['reports', 'new', 'isSubmitting']), + account: getAccount(state, accountId), + comment: state.getIn(['reports', 'new', 'comment']), + statusIds: Immutable.OrderedSet(state.getIn(['timelines', 'accounts_timelines', accountId, 'items'])).union(state.getIn(['reports', 'new', 'status_ids'])) + }; + }; + + return mapStateToProps; +}; + +const textareaStyle = { + marginBottom: '10px' +}; + +const Report = React.createClass({ + + contextTypes: { + router: React.PropTypes.object + }, + + propTypes: { + isSubmitting: React.PropTypes.bool, + account: ImmutablePropTypes.map, + statusIds: ImmutablePropTypes.list.isRequired, + comment: React.PropTypes.string.isRequired, + dispatch: React.PropTypes.func.isRequired, + intl: React.PropTypes.object.isRequired + }, + + mixins: [PureRenderMixin], + + componentWillMount () { + if (!this.props.account) { + this.context.router.replace('/'); + } + }, + + componentDidMount () { + if (!this.props.account) { + return; + } + + this.props.dispatch(fetchAccountTimeline(this.props.account.get('id'))); + }, + + componentWillReceiveProps (nextProps) { + if (this.props.account !== nextProps.account && nextProps.account) { + this.props.dispatch(fetchAccountTimeline(nextProps.account.get('id'))); + } + }, + + handleCommentChange (e) { + this.props.dispatch(changeReportComment(e.target.value)); + }, + + handleSubmit () { + this.props.dispatch(submitReport()); + this.context.router.replace('/'); + }, + + render () { + const { account, comment, intl, statusIds, isSubmitting } = this.props; + + if (!account) { + return null; + } + + return ( + <Column heading={intl.formatMessage(messages.heading)} icon='flag'> + <ColumnBackButtonSlim /> + <div className='report' style={{ display: 'flex', flexDirection: 'column', maxHeight: '100%', boxSizing: 'border-box' }}> + <div className='report__target' style={{ flex: '0 0 auto', padding: '10px' }}> + <FormattedMessage id='report.target' defaultMessage='Reporting' /> + <strong>{account.get('acct')}</strong> + </div> + + <div style={{ flex: '1 1 auto' }} className='scrollable'> + <div> + {statusIds.map(statusId => <StatusCheckBox id={statusId} key={statusId} disabled={isSubmitting} />)} + </div> + </div> + + <div style={{ flex: '0 0 160px', padding: '10px' }}> + <textarea + className='report__textarea' + placeholder={intl.formatMessage(messages.placeholder)} + value={comment} + onChange={this.handleCommentChange} + style={textareaStyle} + disabled={isSubmitting} + /> + + <div style={{ marginTop: '10px', overflow: 'hidden' }}> + <div style={{ float: 'right' }}><Button disabled={isSubmitting} text={intl.formatMessage(messages.submit)} onClick={this.handleSubmit} /></div> + </div> + </div> + </div> + </Column> + ); + } + +}); + +export default connect(makeMapStateToProps)(injectIntl(Report)); diff --git a/app/assets/javascripts/components/features/status/components/action_bar.jsx b/app/assets/javascripts/components/features/status/components/action_bar.jsx index 0e92acf55..2aebcd709 100644 --- a/app/assets/javascripts/components/features/status/components/action_bar.jsx +++ b/app/assets/javascripts/components/features/status/components/action_bar.jsx @@ -6,10 +6,11 @@ import { defineMessages, injectIntl } from 'react-intl'; const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, - mention: { id: 'status.mention', defaultMessage: 'Mention' }, + mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, reply: { id: 'status.reply', defaultMessage: 'Reply' }, reblog: { id: 'status.reblog', defaultMessage: 'Reblog' }, - favourite: { id: 'status.favourite', defaultMessage: 'Favourite' } + favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, + report: { id: 'status.report', defaultMessage: 'Report @{name}' } }); const ActionBar = React.createClass({ @@ -25,6 +26,7 @@ const ActionBar = React.createClass({ onFavourite: React.PropTypes.func.isRequired, onDelete: React.PropTypes.func.isRequired, onMention: React.PropTypes.func.isRequired, + onReport: React.PropTypes.func, me: React.PropTypes.number.isRequired, intl: React.PropTypes.object.isRequired }, @@ -51,6 +53,11 @@ const ActionBar = React.createClass({ this.props.onMention(this.props.status.get('account'), this.context.router); }, + handleReport () { + this.props.onReport(this.props.status); + this.context.router.push('/report'); + }, + render () { const { status, me, intl } = this.props; @@ -59,13 +66,15 @@ const ActionBar = React.createClass({ if (me === status.getIn(['account', 'id'])) { menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); } else { - menu.push({ text: intl.formatMessage(messages.mention), action: this.handleMentionClick }); + menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); + menu.push(null); + menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport }); } return ( <div className='detailed-status__action-bar'> <div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton title={intl.formatMessage(messages.reply)} icon='reply' onClick={this.handleReplyClick} /></div> - <div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton disabled={status.get('visibility') === 'private'} active={status.get('reblogged')} title={intl.formatMessage(messages.reblog)} icon={status.get('visibility') === 'private' ? 'lock' : 'retweet'} onClick={this.handleReblogClick} /></div> + <div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton disabled={status.get('visibility') === 'direct' || status.get('visibility') === 'private'} active={status.get('reblogged')} title={intl.formatMessage(messages.reblog)} icon={status.get('visibility') === 'direct' ? 'envelope' : (status.get('visibility') === 'private' ? 'lock' : 'retweet')} onClick={this.handleReblogClick} /></div> <div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton animate={true} active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div> <div style={{ flex: '1 1 auto', textAlign: 'center' }}><DropdownMenu size={18} icon='ellipsis-h' items={menu} direction="left" /></div> </div> diff --git a/app/assets/javascripts/components/features/status/components/card.jsx b/app/assets/javascripts/components/features/status/components/card.jsx index ccb06dfd5..d016212fd 100644 --- a/app/assets/javascripts/components/features/status/components/card.jsx +++ b/app/assets/javascripts/components/features/status/components/card.jsx @@ -1,18 +1,6 @@ import PureRenderMixin from 'react-addons-pure-render-mixin'; import ImmutablePropTypes from 'react-immutable-proptypes'; -const outerStyle = { - display: 'flex', - cursor: 'pointer', - fontSize: '14px', - border: '1px solid #363c4b', - borderRadius: '4px', - color: '#616b86', - marginTop: '14px', - textDecoration: 'none', - overflow: 'hidden' -}; - const contentStyle = { flex: '1 1 auto', padding: '8px', @@ -20,25 +8,6 @@ const contentStyle = { overflow: 'hidden' }; -const titleStyle = { - display: 'block', - fontWeight: '500', - marginBottom: '5px', - color: '#d9e1e8', - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap' -}; - -const descriptionStyle = { - color: '#d9e1e8' -}; - -const imageOuterStyle = { - flex: '0 0 100px', - background: '#373b4a' -}; - const imageStyle = { display: 'block', width: '100%', @@ -77,20 +46,20 @@ const Card = React.createClass({ if (card.get('image')) { image = ( - <div style={imageOuterStyle}> + <div className='status-card__image'> <img src={card.get('image')} alt={card.get('title')} style={imageStyle} /> </div> ); } return ( - <a style={outerStyle} href={card.get('url')} className='status-card'> + <a href={card.get('url')} className='status-card' target='_blank' rel='noopener'> {image} - <div style={contentStyle}> - <strong style={titleStyle} title={card.get('title')}>{card.get('title')}</strong> - <p style={descriptionStyle}>{card.get('description').substring(0, 50)}</p> - <span style={hostStyle}>{getHostname(card.get('url'))}</span> + <div className='status-card__content' style={contentStyle}> + <strong className='status-card__title' title={card.get('title')}>{card.get('title')}</strong> + <p className='status-card__description'>{card.get('description').substring(0, 50)}</p> + <span className='status-card__host' style={hostStyle}>{getHostname(card.get('url'))}</span> </div> </a> ); diff --git a/app/assets/javascripts/components/features/status/components/detailed_status.jsx b/app/assets/javascripts/components/features/status/components/detailed_status.jsx index f2d6ae48a..caa46ff3c 100644 --- a/app/assets/javascripts/components/features/status/components/detailed_status.jsx +++ b/app/assets/javascripts/components/features/status/components/detailed_status.jsx @@ -39,7 +39,7 @@ const DetailedStatus = React.createClass({ if (status.get('media_attachments').size > 0) { if (status.getIn(['media_attachments', 0, 'type']) === 'video') { - media = <VideoPlayer sensitive={status.get('sensitive')} media={status.getIn(['media_attachments', 0])} width={317} height={178} />; + media = <VideoPlayer sensitive={status.get('sensitive')} media={status.getIn(['media_attachments', 0])} width={300} height={150} autoplay />; } else { media = <MediaGallery sensitive={status.get('sensitive')} media={status.get('media_attachments')} height={300} onOpenMedia={this.props.onOpenMedia} />; } @@ -52,7 +52,7 @@ const DetailedStatus = React.createClass({ } return ( - <div style={{ background: '#2f3441', padding: '14px 10px' }} className='detailed-status'> + <div style={{ padding: '14px 10px' }} className='detailed-status'> <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name' style={{ display: 'block', overflow: 'hidden', marginBottom: '15px' }}> <div style={{ float: 'left', marginRight: '10px' }}><Avatar src={status.getIn(['account', 'avatar'])} size={48} /></div> <DisplayName account={status.get('account')} /> @@ -62,7 +62,7 @@ const DetailedStatus = React.createClass({ {media} - <div style={{ marginTop: '15px', color: '#616b86', fontSize: '14px', lineHeight: '18px' }}> + <div className='detailed-status__meta'> <a className='detailed-status__datetime' style={{ color: 'inherit' }} href={status.get('url')} target='_blank' rel='noopener'><FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' /></a>{applicationLink} · <Link to={`/statuses/${status.get('id')}/reblogs`} style={{ color: 'inherit', textDecoration: 'none' }}><i className='fa fa-retweet' /><span style={{ fontWeight: '500', fontSize: '12px', marginLeft: '6px', display: 'inline-block' }}><FormattedNumber value={status.get('reblogs_count')} /></span></Link> · <Link to={`/statuses/${status.get('id')}/favourites`} style={{ color: 'inherit', textDecoration: 'none' }}><i className='fa fa-star' /><span style={{ fontWeight: '500', fontSize: '12px', marginLeft: '6px', display: 'inline-block' }}><FormattedNumber value={status.get('favourites_count')} /></span></Link> </div> </div> diff --git a/app/assets/javascripts/components/features/status/index.jsx b/app/assets/javascripts/components/features/status/index.jsx index 894fa3176..f98fe1b01 100644 --- a/app/assets/javascripts/components/features/status/index.jsx +++ b/app/assets/javascripts/components/features/status/index.jsx @@ -1,28 +1,34 @@ -import { connect } from 'react-redux'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { fetchStatus } from '../../actions/statuses'; -import Immutable from 'immutable'; -import EmbeddedStatus from '../../components/status'; -import LoadingIndicator from '../../components/loading_indicator'; -import DetailedStatus from './components/detailed_status'; -import ActionBar from './components/action_bar'; -import Column from '../ui/components/column'; -import { favourite, reblog } from '../../actions/interactions'; +import { connect } from 'react-redux'; +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { fetchStatus } from '../../actions/statuses'; +import Immutable from 'immutable'; +import EmbeddedStatus from '../../components/status'; +import MissingIndicator from '../../components/missing_indicator'; +import DetailedStatus from './components/detailed_status'; +import ActionBar from './components/action_bar'; +import Column from '../ui/components/column'; +import { + favourite, + unfavourite, + reblog, + unreblog +} from '../../actions/interactions'; import { replyCompose, mentionCompose -} from '../../actions/compose'; -import { deleteStatus } from '../../actions/statuses'; +} from '../../actions/compose'; +import { deleteStatus } from '../../actions/statuses'; +import { initReport } from '../../actions/reports'; import { makeGetStatus, getStatusAncestors, getStatusDescendants -} from '../../selectors'; -import { ScrollContainer } from 'react-router-scroll'; -import ColumnBackButton from '../../components/column_back_button'; -import StatusContainer from '../../containers/status_container'; -import { openMedia } from '../../actions/modal'; +} from '../../selectors'; +import { ScrollContainer } from 'react-router-scroll'; +import ColumnBackButton from '../../components/column_back_button'; +import StatusContainer from '../../containers/status_container'; +import { openModal } from '../../actions/modal'; import { isMobile } from '../../is_mobile' const makeMapStateToProps = () => { @@ -65,7 +71,11 @@ const Status = React.createClass({ }, handleFavouriteClick (status) { - this.props.dispatch(favourite(status)); + if (status.get('favourited')) { + this.props.dispatch(unfavourite(status)); + } else { + this.props.dispatch(favourite(status)); + } }, handleReplyClick (status) { @@ -73,7 +83,11 @@ const Status = React.createClass({ }, handleReblogClick (status) { - this.props.dispatch(reblog(status)); + if (status.get('reblogged')) { + this.props.dispatch(unreblog(status)); + } else { + this.props.dispatch(reblog(status)); + } }, handleDeleteClick (status) { @@ -85,7 +99,11 @@ const Status = React.createClass({ }, handleOpenMedia (media, index) { - this.props.dispatch(openMedia(media, index)); + this.props.dispatch(openModal('MEDIA', { media, index })); + }, + + handleReport (status) { + this.props.dispatch(initReport(status.get('account'), status)); }, renderChildren (list) { @@ -99,7 +117,8 @@ const Status = React.createClass({ if (status === null) { return ( <Column> - <LoadingIndicator /> + <ColumnBackButton /> + <MissingIndicator /> </Column> ); } @@ -123,7 +142,7 @@ const Status = React.createClass({ {ancestors} <DetailedStatus status={status} me={me} onOpenMedia={this.handleOpenMedia} /> - <ActionBar status={status} me={me} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} onDelete={this.handleDeleteClick} onMention={this.handleMentionClick} /> + <ActionBar status={status} me={me} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} onDelete={this.handleDeleteClick} onMention={this.handleMentionClick} onReport={this.handleReport} /> {descendants} </div> diff --git a/app/assets/javascripts/components/features/ui/components/column.jsx b/app/assets/javascripts/components/features/ui/components/column.jsx index 5b0603ee9..2b7e11bf1 100644 --- a/app/assets/javascripts/components/features/ui/components/column.jsx +++ b/app/assets/javascripts/components/features/ui/components/column.jsx @@ -34,7 +34,8 @@ const Column = React.createClass({ propTypes: { heading: React.PropTypes.string, icon: React.PropTypes.string, - children: React.PropTypes.node + children: React.PropTypes.node, + active: React.PropTypes.bool }, mixins: [PureRenderMixin], @@ -51,12 +52,12 @@ const Column = React.createClass({ }, render () { - const { heading, icon, children } = this.props; + const { heading, icon, children, active } = this.props; let header = ''; if (heading) { - header = <ColumnHeader icon={icon} type={heading} onClick={this.handleHeaderClick} />; + header = <ColumnHeader icon={icon} active={active} type={heading} onClick={this.handleHeaderClick} />; } return ( diff --git a/app/assets/javascripts/components/features/ui/components/column_header.jsx b/app/assets/javascripts/components/features/ui/components/column_header.jsx index 8b072d723..de55fa748 100644 --- a/app/assets/javascripts/components/features/ui/components/column_header.jsx +++ b/app/assets/javascripts/components/features/ui/components/column_header.jsx @@ -5,6 +5,7 @@ const ColumnHeader = React.createClass({ propTypes: { icon: React.PropTypes.string, type: React.PropTypes.string, + active: React.PropTypes.bool, onClick: React.PropTypes.func }, @@ -15,6 +16,8 @@ const ColumnHeader = React.createClass({ }, render () { + const { type, active } = this.props; + let icon = ''; if (this.props.icon) { @@ -22,9 +25,9 @@ const ColumnHeader = React.createClass({ } return ( - <div className='column-header' onClick={this.handleClick}> + <div className={`column-header ${active ? 'active' : ''}`} onClick={this.handleClick}> {icon} - {this.props.type} + {type} </div> ); } diff --git a/app/assets/javascripts/components/features/ui/components/column_link.jsx b/app/assets/javascripts/components/features/ui/components/column_link.jsx index 901a29f5c..2bd1e1017 100644 --- a/app/assets/javascripts/components/features/ui/components/column_link.jsx +++ b/app/assets/javascripts/components/features/ui/components/column_link.jsx @@ -4,7 +4,6 @@ const outerStyle = { display: 'block', padding: '15px', fontSize: '16px', - color: '#fff', textDecoration: 'none' }; diff --git a/app/assets/javascripts/components/features/ui/components/media_modal.jsx b/app/assets/javascripts/components/features/ui/components/media_modal.jsx new file mode 100644 index 000000000..35eb2cb0c --- /dev/null +++ b/app/assets/javascripts/components/features/ui/components/media_modal.jsx @@ -0,0 +1,133 @@ +import LoadingIndicator from '../../../components/loading_indicator'; +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ExtendedVideoPlayer from '../../../components/extended_video_player'; +import ImageLoader from 'react-imageloader'; +import { defineMessages, injectIntl } from 'react-intl'; +import IconButton from '../../../components/icon_button'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' } +}); + +const leftNavStyle = { + position: 'absolute', + background: 'rgba(0, 0, 0, 0.5)', + padding: '30px 15px', + cursor: 'pointer', + fontSize: '24px', + top: '0', + left: '-61px', + boxSizing: 'border-box', + height: '100%', + display: 'flex', + alignItems: 'center' +}; + +const rightNavStyle = { + position: 'absolute', + background: 'rgba(0, 0, 0, 0.5)', + padding: '30px 15px', + cursor: 'pointer', + fontSize: '24px', + top: '0', + right: '-61px', + boxSizing: 'border-box', + height: '100%', + display: 'flex', + alignItems: 'center' +}; + +const closeStyle = { + position: 'absolute', + top: '4px', + right: '4px' +}; + +const MediaModal = React.createClass({ + + propTypes: { + media: ImmutablePropTypes.list.isRequired, + index: React.PropTypes.number.isRequired, + onClose: React.PropTypes.func.isRequired, + intl: React.PropTypes.object.isRequired + }, + + getInitialState () { + return { + index: null + }; + }, + + mixins: [PureRenderMixin], + + handleNextClick () { + this.setState({ index: (this.getIndex() + 1) % this.props.media.size}); + }, + + handlePrevClick () { + this.setState({ index: (this.getIndex() - 1) % this.props.media.size}); + }, + + handleKeyUp (e) { + switch(e.key) { + case 'ArrowLeft': + this.handlePrevClick(); + break; + case 'ArrowRight': + this.handleNextClick(); + break; + } + }, + + componentDidMount () { + window.addEventListener('keyup', this.handleKeyUp, false); + }, + + componentWillUnmount () { + window.removeEventListener('keyup', this.handleKeyUp); + }, + + getIndex () { + return this.state.index !== null ? this.state.index : this.props.index; + }, + + render () { + const { media, intl, onClose } = this.props; + + const index = this.getIndex(); + const attachment = media.get(index); + const url = attachment.get('url'); + + let leftNav, rightNav, content; + + leftNav = rightNav = content = ''; + + if (media.size > 1) { + leftNav = <div style={leftNavStyle} className='modal-container__nav' onClick={this.handlePrevClick}><i className='fa fa-fw fa-chevron-left' /></div>; + rightNav = <div style={rightNavStyle} className='modal-container__nav' onClick={this.handleNextClick}><i className='fa fa-fw fa-chevron-right' /></div>; + } + + if (attachment.get('type') === 'image') { + content = <ImageLoader src={url} imgProps={{ style: { display: 'block' } }} />; + } else if (attachment.get('type') === 'gifv') { + content = <ExtendedVideoPlayer src={url} />; + } + + return ( + <div className='modal-root__modal media-modal'> + {leftNav} + + <div> + <IconButton title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} style={closeStyle} /> + {content} + </div> + + {rightNav} + </div> + ); + } + +}); + +export default injectIntl(MediaModal); diff --git a/app/assets/javascripts/components/features/ui/components/modal_root.jsx b/app/assets/javascripts/components/features/ui/components/modal_root.jsx new file mode 100644 index 000000000..d2ae5e145 --- /dev/null +++ b/app/assets/javascripts/components/features/ui/components/modal_root.jsx @@ -0,0 +1,80 @@ +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import MediaModal from './media_modal'; +import { TransitionMotion, spring } from 'react-motion'; + +const MODAL_COMPONENTS = { + 'MEDIA': MediaModal +}; + +const ModalRoot = React.createClass({ + + propTypes: { + type: React.PropTypes.string, + props: React.PropTypes.object, + onClose: React.PropTypes.func.isRequired + }, + + mixins: [PureRenderMixin], + + handleKeyUp (e) { + if (e.key === 'Escape' && !!this.props.type) { + this.props.onClose(); + } + }, + + componentDidMount () { + window.addEventListener('keyup', this.handleKeyUp, false); + }, + + componentWillUnmount () { + window.removeEventListener('keyup', this.handleKeyUp); + }, + + willEnter () { + return { opacity: 0, scale: 0.98 }; + }, + + willLeave () { + return { opacity: spring(0), scale: spring(0.98) }; + }, + + render () { + const { type, props, onClose } = this.props; + const items = []; + + if (!!type) { + items.push({ + key: type, + data: { type, props }, + style: { opacity: spring(1), scale: spring(1, { stiffness: 120, damping: 14 }) } + }); + } + + return ( + <TransitionMotion + styles={items} + willEnter={this.willEnter} + willLeave={this.willLeave}> + {interpolatedStyles => + <div className='modal-root'> + {interpolatedStyles.map(({ key, data: { type, props }, style }) => { + const SpecificComponent = MODAL_COMPONENTS[type]; + + return ( + <div key={key}> + <div className='modal-root__overlay' style={{ opacity: style.opacity, transform: `translateZ(0px)` }} onClick={onClose} /> + <div className='modal-root__container' style={{ opacity: style.opacity, transform: `translateZ(0px) scale(${style.scale})` }}> + <SpecificComponent {...props} onClose={onClose} /> + </div> + </div> + ); + })} + </div> + } + </TransitionMotion> + ); + } + +}); + +export default ModalRoot; diff --git a/app/assets/javascripts/components/features/ui/components/tabs_bar.jsx b/app/assets/javascripts/components/features/ui/components/tabs_bar.jsx index 225a6a5fc..6cdb29dbf 100644 --- a/app/assets/javascripts/components/features/ui/components/tabs_bar.jsx +++ b/app/assets/javascripts/components/features/ui/components/tabs_bar.jsx @@ -1,15 +1,23 @@ import { Link } from 'react-router'; import { FormattedMessage } from 'react-intl'; -const TabsBar = () => { - return ( - <div className='tabs-bar'> - <Link className='tabs-bar__link' activeClassName='active' to='/statuses/new'><i className='fa fa-fw fa-pencil' /> <FormattedMessage id='tabs_bar.compose' defaultMessage='Compose' /></Link> - <Link className='tabs-bar__link' activeClassName='active' to='/timelines/home'><i className='fa fa-fw fa-home' /> <FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></Link> - <Link className='tabs-bar__link' activeClassName='active' to='/notifications'><i className='fa fa-fw fa-bell' /> <FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></Link> - <Link className='tabs-bar__link' activeClassName='active' style={{ flexGrow: '0', flexBasis: '30px' }} to='/getting-started'><i className='fa fa-fw fa-bars' /></Link> - </div> - ); -}; +const TabsBar = React.createClass({ + + render () { + return ( + <div className='tabs-bar'> + <Link className='tabs-bar__link primary' activeClassName='active' to='/statuses/new'><i className='fa fa-fw fa-pencil' /><FormattedMessage id='tabs_bar.compose' defaultMessage='Compose' /></Link> + <Link className='tabs-bar__link primary' activeClassName='active' to='/timelines/home'><i className='fa fa-fw fa-home' /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></Link> + <Link className='tabs-bar__link primary' activeClassName='active' to='/notifications'><i className='fa fa-fw fa-bell' /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></Link> + + <Link className='tabs-bar__link secondary' activeClassName='active' to='/timelines/public/local'><i className='fa fa-fw fa-users' /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></Link> + <Link className='tabs-bar__link secondary' activeClassName='active' to='/timelines/public'><i className='fa fa-fw fa-globe' /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></Link> + + <Link className='tabs-bar__link primary' activeClassName='active' style={{ flexGrow: '0', flexBasis: '30px' }} to='/getting-started'><i className='fa fa-fw fa-bars' /></Link> + </div> + ); + } + +}); export default TabsBar; diff --git a/app/assets/javascripts/components/features/ui/components/upload_area.jsx b/app/assets/javascripts/components/features/ui/components/upload_area.jsx new file mode 100644 index 000000000..70b687019 --- /dev/null +++ b/app/assets/javascripts/components/features/ui/components/upload_area.jsx @@ -0,0 +1,32 @@ +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import { Motion, spring } from 'react-motion'; +import { FormattedMessage } from 'react-intl'; + +const UploadArea = React.createClass({ + + propTypes: { + active: React.PropTypes.bool + }, + + mixins: [PureRenderMixin], + + render () { + const { active } = this.props; + + return ( + <Motion defaultStyle={{ backgroundOpacity: 0, backgroundScale: 0.95 }} style={{ backgroundOpacity: spring(active ? 1 : 0, { stiffness: 150, damping: 15 }), backgroundScale: spring(active ? 1 : 0.95, { stiffness: 200, damping: 3 }) }}> + {({ backgroundOpacity, backgroundScale }) => + <div className='upload-area' style={{ visibility: active ? 'visible' : 'hidden', opacity: backgroundOpacity }}> + <div className='upload-area__drop'> + <div className='upload-area__background' style={{ transform: `translateZ(0) scale(${backgroundScale})` }} /> + <div className='upload-area__content'><FormattedMessage id='upload_area.title' defaultMessage='Drag & drop to upload' /></div> + </div> + </div> + } + </Motion> + ); + } + +}); + +export default UploadArea; diff --git a/app/assets/javascripts/components/features/ui/containers/modal_container.jsx b/app/assets/javascripts/components/features/ui/containers/modal_container.jsx index 334e5c199..26d77818c 100644 --- a/app/assets/javascripts/components/features/ui/containers/modal_container.jsx +++ b/app/assets/javascripts/components/features/ui/containers/modal_container.jsx @@ -1,170 +1,16 @@ import { connect } from 'react-redux'; -import { - closeModal, - decreaseIndexInModal, - increaseIndexInModal -} from '../../../actions/modal'; -import Lightbox from '../../../components/lightbox'; -import ImageLoader from 'react-imageloader'; -import LoadingIndicator from '../../../components/loading_indicator'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; -import ImmutablePropTypes from 'react-immutable-proptypes'; +import { closeModal } from '../../../actions/modal'; +import ModalRoot from '../components/modal_root'; const mapStateToProps = state => ({ - media: state.getIn(['modal', 'media']), - index: state.getIn(['modal', 'index']), - isVisible: state.getIn(['modal', 'open']) + type: state.get('modal').modalType, + props: state.get('modal').modalProps }); const mapDispatchToProps = dispatch => ({ - onCloseClicked () { + onClose () { dispatch(closeModal()); }, - - onOverlayClicked () { - dispatch(closeModal()); - }, - - onNextClicked () { - dispatch(increaseIndexInModal()); - }, - - onPrevClicked () { - dispatch(decreaseIndexInModal()); - } -}); - -const imageStyle = { - display: 'block', - maxWidth: '80vw', - maxHeight: '80vh' -}; - -const loadingStyle = { - background: '#373b4a', - width: '400px', - paddingBottom: '120px' -}; - -const preloader = () => ( - <div style={loadingStyle}> - <LoadingIndicator /> - </div> -); - -const leftNavStyle = { - position: 'absolute', - background: 'rgba(0, 0, 0, 0.5)', - padding: '30px 15px', - cursor: 'pointer', - color: '#fff', - fontSize: '24px', - top: '0', - left: '-61px', - boxSizing: 'border-box', - height: '100%', - display: 'flex', - alignItems: 'center' -}; - -const rightNavStyle = { - position: 'absolute', - background: 'rgba(0, 0, 0, 0.5)', - padding: '30px 15px', - cursor: 'pointer', - color: '#fff', - fontSize: '24px', - top: '0', - right: '-61px', - boxSizing: 'border-box', - height: '100%', - display: 'flex', - alignItems: 'center' -}; - -const Modal = React.createClass({ - - propTypes: { - media: ImmutablePropTypes.list, - index: React.PropTypes.number.isRequired, - isVisible: React.PropTypes.bool, - onCloseClicked: React.PropTypes.func, - onOverlayClicked: React.PropTypes.func, - onNextClicked: React.PropTypes.func, - onPrevClicked: React.PropTypes.func - }, - - mixins: [PureRenderMixin], - - handleNextClick () { - this.props.onNextClicked(); - }, - - handlePrevClick () { - this.props.onPrevClicked(); - }, - - componentDidMount () { - this._listener = e => { - if (!this.props.isVisible) { - return; - } - - switch(e.key) { - case 'ArrowLeft': - this.props.onPrevClicked(); - break; - case 'ArrowRight': - this.props.onNextClicked(); - break; - } - }; - - window.addEventListener('keyup', this._listener); - }, - - componentWillUnmount () { - window.removeEventListener('keyup', this._listener); - }, - - render () { - const { media, index, ...other } = this.props; - - if (!media) { - return null; - } - - const url = media.get(index).get('url'); - const hasLeft = index > 0; - const hasRight = index + 1 < media.size; - - let leftNav, rightNav; - - leftNav = rightNav = ''; - - if (hasLeft) { - leftNav = <div style={leftNavStyle} onClick={this.handlePrevClick}><i className='fa fa-fw fa-chevron-left' /></div>; - } - - if (hasRight) { - rightNav = <div style={rightNavStyle} onClick={this.handleNextClick}><i className='fa fa-fw fa-chevron-right' /></div>; - } - - return ( - <Lightbox {...other}> - {leftNav} - - <ImageLoader - src={url} - preloader={preloader} - imgProps={{ style: imageStyle }} - /> - - {rightNav} - </Lightbox> - ); - } - }); -export default connect(mapStateToProps, mapDispatchToProps)(Modal); +export default connect(mapStateToProps, mapDispatchToProps)(ModalRoot); diff --git a/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx b/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx index 100989d22..f249240d8 100644 --- a/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx +++ b/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx @@ -3,8 +3,9 @@ import StatusList from '../../../components/status_list'; import { expandTimeline, scrollTopTimeline } from '../../../actions/timelines'; import Immutable from 'immutable'; import { createSelector } from 'reselect'; +import { debounce } from 'react-decoration'; -const getStatusIds = createSelector([ +const makeGetStatusIds = () => createSelector([ (state, { type }) => state.getIn(['settings', type], Immutable.Map()), (state, { type }) => state.getIn(['timelines', type, 'items'], Immutable.List()), (state) => state.get('statuses'), @@ -33,26 +34,37 @@ const getStatusIds = createSelector([ return showStatus; })); -const mapStateToProps = (state, props) => ({ - statusIds: getStatusIds(state, props), - isLoading: state.getIn(['timelines', props.type, 'isLoading'], true) -}); +const makeMapStateToProps = () => { + const getStatusIds = makeGetStatusIds(); + + const mapStateToProps = (state, props) => ({ + statusIds: getStatusIds(state, props), + isLoading: state.getIn(['timelines', props.type, 'isLoading'], true), + isUnread: state.getIn(['timelines', props.type, 'unread']) > 0, + hasMore: !!state.getIn(['timelines', props.type, 'next']) + }); + + return mapStateToProps; +}; const mapDispatchToProps = (dispatch, { type, id }) => ({ + @debounce(300, true) onScrollToBottom () { dispatch(scrollTopTimeline(type, false)); dispatch(expandTimeline(type, id)); }, + @debounce(100) onScrollToTop () { dispatch(scrollTopTimeline(type, true)); }, + @debounce(100) onScroll () { dispatch(scrollTopTimeline(type, false)); } }); -export default connect(mapStateToProps, mapDispatchToProps)(StatusList); +export default connect(makeMapStateToProps, mapDispatchToProps)(StatusList); diff --git a/app/assets/javascripts/components/features/ui/index.jsx b/app/assets/javascripts/components/features/ui/index.jsx index 900d83dba..89fb82568 100644 --- a/app/assets/javascripts/components/features/ui/index.jsx +++ b/app/assets/javascripts/components/features/ui/index.jsx @@ -13,6 +13,7 @@ import { debounce } from 'react-decoration'; import { uploadCompose } from '../../actions/compose'; import { refreshTimeline } from '../../actions/timelines'; import { refreshNotifications } from '../../actions/notifications'; +import UploadArea from './components/upload_area'; const UI = React.createClass({ @@ -23,7 +24,8 @@ const UI = React.createClass({ getInitialState () { return { - width: window.innerWidth + width: window.innerWidth, + draggingOver: false }; }, @@ -34,29 +36,64 @@ const UI = React.createClass({ this.setState({ width: window.innerWidth }); }, + handleDragEnter (e) { + e.preventDefault(); + + if (!this.dragTargets) { + this.dragTargets = []; + } + + if (this.dragTargets.indexOf(e.target) === -1) { + this.dragTargets.push(e.target); + } + + if (e.dataTransfer && e.dataTransfer.files.length > 0) { + this.setState({ draggingOver: true }); + } + }, + handleDragOver (e) { e.preventDefault(); e.stopPropagation(); - e.dataTransfer.dropEffect = 'copy'; + try { + e.dataTransfer.dropEffect = 'copy'; + } catch (err) { - if (e.dataTransfer.effectAllowed === 'all' || e.dataTransfer.effectAllowed === 'uninitialized') { - // } + + return false; }, handleDrop (e) { e.preventDefault(); + this.setState({ draggingOver: false }); + if (e.dataTransfer && e.dataTransfer.files.length === 1) { this.props.dispatch(uploadCompose(e.dataTransfer.files)); } }, + handleDragLeave (e) { + e.preventDefault(); + e.stopPropagation(); + + this.dragTargets = this.dragTargets.filter(el => el !== e.target && this.node.contains(el)); + + if (this.dragTargets.length > 0) { + return; + } + + this.setState({ draggingOver: false }); + }, + componentWillMount () { window.addEventListener('resize', this.handleResize, { passive: true }); - window.addEventListener('dragover', this.handleDragOver); - window.addEventListener('drop', this.handleDrop); + document.addEventListener('dragenter', this.handleDragEnter, false); + document.addEventListener('dragover', this.handleDragOver, false); + document.addEventListener('drop', this.handleDrop, false); + document.addEventListener('dragleave', this.handleDragLeave, false); this.props.dispatch(refreshTimeline('home')); this.props.dispatch(refreshNotifications()); @@ -64,17 +101,26 @@ const UI = React.createClass({ componentWillUnmount () { window.removeEventListener('resize', this.handleResize); - window.removeEventListener('dragover', this.handleDragOver); - window.removeEventListener('drop', this.handleDrop); + document.removeEventListener('dragenter', this.handleDragEnter); + document.removeEventListener('dragover', this.handleDragOver); + document.removeEventListener('drop', this.handleDrop); + document.removeEventListener('dragleave', this.handleDragLeave); + }, + + setRef (c) { + this.node = c; }, render () { + const { width, draggingOver } = this.state; + const { children } = this.props; + let mountedColumns; - if (isMobile(this.state.width)) { + if (isMobile(width)) { mountedColumns = ( <ColumnsArea> - {this.props.children} + {children} </ColumnsArea> ); } else { @@ -83,13 +129,13 @@ const UI = React.createClass({ <Compose withHeader={true} /> <HomeTimeline trackScroll={false} /> <Notifications trackScroll={false} /> - {this.props.children} + {children} </ColumnsArea> ); } return ( - <div className='ui'> + <div className='ui' ref={this.setRef}> <TabsBar /> {mountedColumns} @@ -97,6 +143,7 @@ const UI = React.createClass({ <NotificationsContainer /> <LoadingBarContainer style={{ backgroundColor: '#2b90d9', left: '0', top: '0' }} /> <ModalContainer /> + <UploadArea active={draggingOver} /> </div> ); } |