diff options
Diffstat (limited to 'app/assets')
43 files changed, 661 insertions, 318 deletions
diff --git a/app/assets/javascripts/components/actions/compose.jsx b/app/assets/javascripts/components/actions/compose.jsx index 03aae885e..8d030fd30 100644 --- a/app/assets/javascripts/components/actions/compose.jsx +++ b/app/assets/javascripts/components/actions/compose.jsx @@ -85,6 +85,7 @@ export function submitCompose() { dispatch(updateTimeline('home', { ...response.data })); if (response.data.in_reply_to_id === null && response.data.visibility === 'public') { + dispatch(updateTimeline('community', { ...response.data })); dispatch(updateTimeline('public', { ...response.data })); } }).catch(function (error) { diff --git a/app/assets/javascripts/components/actions/notifications.jsx b/app/assets/javascripts/components/actions/notifications.jsx index df82e73fc..980b7d63e 100644 --- a/app/assets/javascripts/components/actions/notifications.jsx +++ b/app/assets/javascripts/components/actions/notifications.jsx @@ -14,7 +14,8 @@ export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST'; export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS'; export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL'; -export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR'; +export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR'; +export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP'; const fetchRelatedRelationships = (dispatch, notifications) => { const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id); @@ -151,3 +152,10 @@ export function clearNotifications() { api(getState).post('/api/v1/notifications/clear'); }; }; + +export function scrollTopNotifications(top) { + return { + type: NOTIFICATIONS_SCROLL_TOP, + top + }; +}; diff --git a/app/assets/javascripts/components/actions/timelines.jsx b/app/assets/javascripts/components/actions/timelines.jsx index 1531b89a3..311b08033 100644 --- a/app/assets/javascripts/components/actions/timelines.jsx +++ b/app/assets/javascripts/components/actions/timelines.jsx @@ -1,4 +1,4 @@ -import api from '../api' +import api, { getLinks } from '../api' import Immutable from 'immutable'; export const TIMELINE_UPDATE = 'TIMELINE_UPDATE'; @@ -14,12 +14,13 @@ export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL'; export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP'; -export function refreshTimelineSuccess(timeline, statuses, skipLoading) { +export function refreshTimelineSuccess(timeline, statuses, skipLoading, next) { return { type: TIMELINE_REFRESH_SUCCESS, timeline, statuses, - skipLoading + skipLoading, + next }; }; @@ -69,25 +70,22 @@ export function refreshTimeline(timeline, id = null) { const ids = getState().getIn(['timelines', timeline, 'items'], Immutable.List()); const newestId = ids.size > 0 ? ids.first() : null; + let params = getState().getIn(['timelines', timeline, 'params'], {}); + const path = getState().getIn(['timelines', timeline, 'path'])(id); - let params = ''; - let path = timeline; let skipLoading = false; if (newestId !== null && getState().getIn(['timelines', timeline, 'loaded']) && (id === null || getState().getIn(['timelines', timeline, 'id']) === id)) { - params = `?since_id=${newestId}`; - skipLoading = true; - } - - if (id) { - path = `${path}/${id}` + params = { ...params, since_id: newestId }; + skipLoading = true; } dispatch(refreshTimelineRequest(timeline, id, skipLoading)); - api(getState).get(`/api/v1/timelines/${path}${params}`).then(function (response) { - dispatch(refreshTimelineSuccess(timeline, response.data, skipLoading)); - }).catch(function (error) { + api(getState).get(path, { params }).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(refreshTimelineSuccess(timeline, response.data, skipLoading, next ? next.uri : null)); + }).catch(error => { dispatch(refreshTimelineFail(timeline, error, skipLoading)); }); }; @@ -102,50 +100,48 @@ export function refreshTimelineFail(timeline, error, skipLoading) { }; }; -export function expandTimeline(timeline, id = null) { +export function expandTimeline(timeline) { return (dispatch, getState) => { - const lastId = getState().getIn(['timelines', timeline, 'items'], Immutable.List()).last(); - - if (!lastId || getState().getIn(['timelines', timeline, 'isLoading'])) { - // If timeline is empty, don't try to load older posts since there are none - // Also if already loading + if (getState().getIn(['timelines', timeline, 'isLoading'])) { return; } - dispatch(expandTimelineRequest(timeline, id)); + const next = getState().getIn(['timelines', timeline, 'next']); + const params = getState().getIn(['timelines', timeline, 'params'], {}); - let path = timeline; - - if (id) { - path = `${path}/${id}` + if (next === null) { + return; } - api(getState).get(`/api/v1/timelines/${path}`, { + dispatch(expandTimelineRequest(timeline)); + + api(getState).get(next, { params: { - limit: 10, - max_id: lastId + ...params, + limit: 10 } }).then(response => { - dispatch(expandTimelineSuccess(timeline, response.data)); + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(expandTimelineSuccess(timeline, response.data, next ? next.uri : null)); }).catch(error => { dispatch(expandTimelineFail(timeline, error)); }); }; }; -export function expandTimelineRequest(timeline, id) { +export function expandTimelineRequest(timeline) { return { type: TIMELINE_EXPAND_REQUEST, - timeline, - id + timeline }; }; -export function expandTimelineSuccess(timeline, statuses) { +export function expandTimelineSuccess(timeline, statuses, next) { return { type: TIMELINE_EXPAND_SUCCESS, timeline, - statuses + statuses, + next }; }; diff --git a/app/assets/javascripts/components/components/column_collapsable.jsx b/app/assets/javascripts/components/components/column_collapsable.jsx index 676759055..729d00617 100644 --- a/app/assets/javascripts/components/components/column_collapsable.jsx +++ b/app/assets/javascripts/components/components/column_collapsable.jsx @@ -7,7 +7,8 @@ const iconStyle = { position: 'absolute', right: '0', top: '-48px', - cursor: 'pointer' + cursor: 'pointer', + zIndex: '3' }; const ColumnCollapsable = React.createClass({ @@ -41,7 +42,7 @@ const ColumnCollapsable = React.createClass({ const { icon, fullHeight, children } = this.props; const { collapsed } = this.state; const collapsedClassName = collapsed ? 'collapsable-collapsed' : 'collapsable'; - + return ( <div style={{ position: 'relative' }}> <div style={{...iconStyle }} className={collapsedClassName} onClick={this.handleToggleCollapsed}><i className={`fa fa-${icon}`} /></div> diff --git a/app/assets/javascripts/components/components/display_name.jsx b/app/assets/javascripts/components/components/display_name.jsx index 053b5290c..aa48608d3 100644 --- a/app/assets/javascripts/components/components/display_name.jsx +++ b/app/assets/javascripts/components/components/display_name.jsx @@ -1,6 +1,6 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import PureRenderMixin from 'react-addons-pure-render-mixin'; -import escapeTextContentForBrowser from 'react/lib/escapeTextContentForBrowser'; +import escapeTextContentForBrowser from 'escape-html'; import emojify from '../emoji'; const DisplayName = React.createClass({ diff --git a/app/assets/javascripts/components/components/status.jsx b/app/assets/javascripts/components/components/status.jsx index 66c41b5f7..110d26c6d 100644 --- a/app/assets/javascripts/components/components/status.jsx +++ b/app/assets/javascripts/components/components/status.jsx @@ -9,7 +9,7 @@ import StatusContent from './status_content'; import StatusActionBar from './status_action_bar'; import { FormattedMessage } from 'react-intl'; import emojify from '../emoji'; -import escapeTextContentForBrowser from 'react/lib/escapeTextContentForBrowser'; +import escapeTextContentForBrowser from 'escape-html'; const Status = React.createClass({ diff --git a/app/assets/javascripts/components/components/status_content.jsx b/app/assets/javascripts/components/components/status_content.jsx index c0397e81c..43bbb9582 100644 --- a/app/assets/javascripts/components/components/status_content.jsx +++ b/app/assets/javascripts/components/components/status_content.jsx @@ -1,6 +1,6 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import PureRenderMixin from 'react-addons-pure-render-mixin'; -import escapeTextContentForBrowser from 'react/lib/escapeTextContentForBrowser'; +import escapeTextContentForBrowser from 'escape-html'; import emojify from '../emoji'; import { FormattedMessage } from 'react-intl'; import Permalink from './permalink'; diff --git a/app/assets/javascripts/components/components/status_list.jsx b/app/assets/javascripts/components/components/status_list.jsx index 69a2354c7..345944e4d 100644 --- a/app/assets/javascripts/components/components/status_list.jsx +++ b/app/assets/javascripts/components/components/status_list.jsx @@ -14,6 +14,8 @@ const StatusList = React.createClass({ onScroll: React.PropTypes.func, trackScroll: React.PropTypes.bool, isLoading: React.PropTypes.bool, + isUnread: React.PropTypes.bool, + hasMore: React.PropTypes.bool, prepend: React.PropTypes.node, emptyMessage: React.PropTypes.node }, @@ -72,18 +74,25 @@ const StatusList = React.createClass({ }, render () { - const { statusIds, onScrollToBottom, trackScroll, isLoading, prepend, emptyMessage } = this.props; + const { statusIds, onScrollToBottom, trackScroll, isLoading, isUnread, hasMore, prepend, emptyMessage } = this.props; - let loadMore = ''; + let loadMore = ''; let scrollableArea = ''; + let unread = ''; - if (!isLoading && statusIds.size > 0) { + if (!isLoading && statusIds.size > 0 && hasMore) { loadMore = <LoadMore onClick={this.handleLoadMore} />; } + if (isUnread) { + unread = <div className='status-list__unread-indicator' />; + } + if (isLoading || statusIds.size > 0 || !emptyMessage) { scrollableArea = ( <div className='scrollable' ref={this.setRef}> + {unread} + <div> {prepend} diff --git a/app/assets/javascripts/components/containers/mastodon.jsx b/app/assets/javascripts/components/containers/mastodon.jsx index ebef5c81b..40fbac525 100644 --- a/app/assets/javascripts/components/containers/mastodon.jsx +++ b/app/assets/javascripts/components/containers/mastodon.jsx @@ -21,6 +21,7 @@ import UI from '../features/ui'; import Status from '../features/status'; import GettingStarted from '../features/getting_started'; import PublicTimeline from '../features/public_timeline'; +import CommunityTimeline from '../features/community_timeline'; import AccountTimeline from '../features/account_timeline'; import HomeTimeline from '../features/home_timeline'; import Compose from '../features/compose'; @@ -116,6 +117,7 @@ const Mastodon = React.createClass({ <Route path='getting-started' component={GettingStarted} /> <Route path='timelines/home' component={HomeTimeline} /> <Route path='timelines/public' component={PublicTimeline} /> + <Route path='timelines/public/local' component={CommunityTimeline} /> <Route path='timelines/tag/:id' component={HashtagTimeline} /> <Route path='notifications' component={Notifications} /> diff --git a/app/assets/javascripts/components/containers/status_container.jsx b/app/assets/javascripts/components/containers/status_container.jsx index fc096a375..81265bc50 100644 --- a/app/assets/javascripts/components/containers/status_container.jsx +++ b/app/assets/javascripts/components/containers/status_container.jsx @@ -18,45 +18,12 @@ import { openMedia } from '../actions/modal'; import { createSelector } from 'reselect' import { isMobile } from '../is_mobile' -const mapStateToProps = (state, props) => ({ - statusBase: state.getIn(['statuses', props.id]), - me: state.getIn(['meta', 'me']) -}); - -const makeMapStateToPropsInner = () => { - const getStatus = (() => { - return createSelector( - [ - (_, base) => base, - (state, base) => (base ? state.getIn(['accounts', base.get('account')]) : null), - (state, base) => (base ? state.getIn(['statuses', base.get('reblog')], null) : null) - ], - - (base, account, reblog) => (base ? base.set('account', account).set('reblog', reblog) : null) - ); - })(); - - const mapStateToProps = (state, { statusBase }) => ({ - status: getStatus(state, statusBase) - }); - - return mapStateToProps; -}; - -const makeMapStateToPropsLast = () => { - const getStatus = (() => { - return createSelector( - [ - (_, status) => status, - (state, status) => (status ? state.getIn(['accounts', status.getIn(['reblog', 'account'])], null) : null) - ], - - (status, reblogAccount) => (status && status.get('reblog') ? status.setIn(['reblog', 'account'], reblogAccount) : status) - ); - })(); +const makeMapStateToProps = () => { + const getStatus = makeGetStatus(); - const mapStateToProps = (state, { status }) => ({ - status: getStatus(state, status) + const mapStateToProps = (state, props) => ({ + status: getStatus(state, props.id), + me: state.getIn(['meta', 'me']) }); return mapStateToProps; @@ -106,8 +73,4 @@ const mapDispatchToProps = (dispatch) => ({ }); -export default connect(mapStateToProps, mapDispatchToProps)( - connect(makeMapStateToPropsInner)( - connect(makeMapStateToPropsLast)(Status) - ) -); +export default connect(makeMapStateToProps, mapDispatchToProps)(Status); diff --git a/app/assets/javascripts/components/features/account/components/header.jsx b/app/assets/javascripts/components/features/account/components/header.jsx index a4f0ca768..e1aae3c77 100644 --- a/app/assets/javascripts/components/features/account/components/header.jsx +++ b/app/assets/javascripts/components/features/account/components/header.jsx @@ -1,7 +1,7 @@ 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'; @@ -14,7 +14,7 @@ const messages = defineMessages({ 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 +25,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 = ''; 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..aa1b8368e --- /dev/null +++ b/app/assets/javascripts/components/features/community_timeline/index.jsx @@ -0,0 +1,75 @@ +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 +} 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']) +}); + +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')); + + this.subscription = createStream(accessToken, 'public:local', { + + 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 this.subscription !== 'undefined') { + this.subscription.close(); + this.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/compose_form.jsx b/app/assets/javascripts/components/features/compose/components/compose_form.jsx index 9edc01ed7..31ae8e034 100644 --- a/app/assets/javascripts/components/features/compose/components/compose_form.jsx +++ b/app/assets/javascripts/components/features/compose/components/compose_form.jsx @@ -2,7 +2,7 @@ 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 ReplyIndicatorContainer from '../containers/reply_indicator_container'; import UploadButton from './upload_button'; import AutosuggestTextarea from '../../../components/autosuggest_textarea'; import AutosuggestAccountContainer from '../../compose/containers/autosuggest_account_container'; @@ -11,6 +11,10 @@ import UploadButtonContainer from '../containers/upload_button_container'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import Toggle from 'react-toggle'; import Collapsable from '../../../components/collapsable'; +import UnlistedToggleContainer from '../containers/unlisted_toggle_container'; +import SpoilerToggleContainer from '../containers/spoiler_toggle_container'; +import PrivateToggleContainer from '../containers/private_toggle_container'; +import SensitiveToggleContainer from '../containers/sensitive_toggle_container'; const messages = defineMessages({ placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' }, @@ -25,30 +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, - spoiler_text: React.PropTypes.string, - unlisted: React.PropTypes.bool, private: React.PropTypes.bool, + unlisted: React.PropTypes.bool, + spoiler_text: React.PropTypes.string, 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, }, mixins: [PureRenderMixin], @@ -80,34 +78,17 @@ const ComposeForm = React.createClass({ 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; + const selectionStart = (this.props.preselectDate !== prevProps.preselectDate) ? (this.props.text.search(/\s/) + 1) : selectionEnd; this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd); this.autosuggestTextarea.textarea.focus(); @@ -122,14 +103,9 @@ const ComposeForm = React.createClass({ const { intl, needsPrivacyWarning, mentionedDomains } = this.props; const disabled = this.props.is_submitting || this.props.is_uploading; - let replyArea = ''; let publishText = ''; let privacyWarning = ''; - let reply_to_other = !!this.props.in_reply_to && (this.props.in_reply_to.getIn(['account', 'id']) !== this.props.me); - - if (this.props.in_reply_to) { - replyArea = <ReplyIndicator status={this.props.in_reply_to} onCancel={this.props.onCancelReply} />; - } + let reply_to_other = false; if (needsPrivacyWarning) { privacyWarning = ( @@ -158,7 +134,8 @@ const ComposeForm = React.createClass({ </Collapsable> {privacyWarning} - {replyArea} + + <ReplyIndicatorContainer /> <AutosuggestTextarea ref={this.setAutosuggestTextarea} @@ -180,29 +157,10 @@ const ComposeForm = React.createClass({ <UploadButtonContainer style={{ paddingTop: '4px' }} /> </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> - - <Collapsable isVisible={!(this.props.private || reply_to_other)} fullHeight={39.5}> - <label className='compose-form__label'> - <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> - </Collapsable> - - <Collapsable isVisible={this.props.media_count > 0} fullHeight={39.5}> - <label className='compose-form__label'> - <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> - </Collapsable> + <SpoilerToggleContainer /> + <PrivateToggleContainer /> + <UnlistedToggleContainer /> + <SensitiveToggleContainer /> </div> ); } diff --git a/app/assets/javascripts/components/features/compose/components/drawer.jsx b/app/assets/javascripts/components/features/compose/components/drawer.jsx index 83f3fa27d..ab67c86ea 100644 --- a/app/assets/javascripts/components/features/compose/components/drawer.jsx +++ b/app/assets/javascripts/components/features/compose/components/drawer.jsx @@ -3,7 +3,8 @@ 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' }, + 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' } }); @@ -15,6 +16,7 @@ const Drawer = ({ children, withHeader, intl }) => { 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> diff --git a/app/assets/javascripts/components/features/compose/components/private_toggle.jsx b/app/assets/javascripts/components/features/compose/components/private_toggle.jsx new file mode 100644 index 000000000..902ee70ca --- /dev/null +++ b/app/assets/javascripts/components/features/compose/components/private_toggle.jsx @@ -0,0 +1,27 @@ +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import { FormattedMessage } from 'react-intl'; +import Toggle from 'react-toggle'; + +const PrivateToggle = React.createClass({ + + propTypes: { + isPrivate: React.PropTypes.bool, + onChange: React.PropTypes.func.isRequired + }, + + mixins: [PureRenderMixin], + + render () { + const { isPrivate, onChange } = this.props; + + return ( + <label className='compose-form__label with-border'> + <Toggle checked={isPrivate} onChange={onChange} /> + <span className='compose-form__label__text'><FormattedMessage id='compose_form.private' defaultMessage='Mark as private' /></span> + </label> + ); + } + +}); + +export default PrivateToggle; 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/sensitive_toggle.jsx b/app/assets/javascripts/components/features/compose/components/sensitive_toggle.jsx new file mode 100644 index 000000000..97cc9487e --- /dev/null +++ b/app/assets/javascripts/components/features/compose/components/sensitive_toggle.jsx @@ -0,0 +1,31 @@ +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import { FormattedMessage } from 'react-intl'; +import Toggle from 'react-toggle'; +import Collapsable from '../../../components/collapsable'; + +const SensitiveToggle = React.createClass({ + + propTypes: { + hasMedia: React.PropTypes.bool, + isSensitive: React.PropTypes.bool, + onChange: React.PropTypes.func.isRequired + }, + + mixins: [PureRenderMixin], + + render () { + const { hasMedia, isSensitive, onChange } = this.props; + + return ( + <Collapsable isVisible={hasMedia} fullHeight={39.5}> + <label className='compose-form__label'> + <Toggle checked={isSensitive} onChange={onChange} /> + <span className='compose-form__label__text'><FormattedMessage id='compose_form.sensitive' defaultMessage='Mark media as sensitive' /></span> + </label> + </Collapsable> + ); + } + +}); + +export default SensitiveToggle; diff --git a/app/assets/javascripts/components/features/compose/components/spoiler_toggle.jsx b/app/assets/javascripts/components/features/compose/components/spoiler_toggle.jsx new file mode 100644 index 000000000..1c59e4393 --- /dev/null +++ b/app/assets/javascripts/components/features/compose/components/spoiler_toggle.jsx @@ -0,0 +1,27 @@ +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import { FormattedMessage } from 'react-intl'; +import Toggle from 'react-toggle'; + +const SpoilerToggle = React.createClass({ + + propTypes: { + isSpoiler: React.PropTypes.bool, + onChange: React.PropTypes.func.isRequired + }, + + mixins: [PureRenderMixin], + + render () { + const { isSpoiler, onChange } = this.props; + + return ( + <label className='compose-form__label with-border' style={{ marginTop: '10px' }}> + <Toggle checked={isSpoiler} onChange={onChange} /> + <span className='compose-form__label__text'><FormattedMessage id='compose_form.spoiler' defaultMessage='Hide text behind warning' /></span> + </label> + ); + } + +}); + +export default SpoilerToggle; diff --git a/app/assets/javascripts/components/features/compose/components/unlisted_toggle.jsx b/app/assets/javascripts/components/features/compose/components/unlisted_toggle.jsx new file mode 100644 index 000000000..0745051eb --- /dev/null +++ b/app/assets/javascripts/components/features/compose/components/unlisted_toggle.jsx @@ -0,0 +1,32 @@ +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import { FormattedMessage } from 'react-intl'; +import Toggle from 'react-toggle'; +import Collapsable from '../../../components/collapsable'; + +const UnlistedToggle = React.createClass({ + + propTypes: { + isPrivate: React.PropTypes.bool, + isUnlisted: React.PropTypes.bool, + isReplyToOther: React.PropTypes.bool, + onChangeListability: React.PropTypes.func.isRequired + }, + + mixins: [PureRenderMixin], + + render () { + const { isPrivate, isUnlisted, isReplyToOther, onChangeListability } = this.props; + + return ( + <Collapsable isVisible={!(isPrivate || isReplyToOther)} fullHeight={39.5}> + <label className='compose-form__label'> + <Toggle checked={isUnlisted} onChange={onChangeListability} /> + <span className='compose-form__label__text'><FormattedMessage id='compose_form.unlisted' defaultMessage='Do not display on public timelines' /></span> + </label> + </Collapsable> + ); + } + +}); + +export default UnlistedToggle; 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 2671ea618..53129af6e 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,95 +1,70 @@ import { connect } from 'react-redux'; import ComposeForm from '../components/compose_form'; +import { createSelector } from 'reselect'; import { changeCompose, submitCompose, - cancelReplyCompose, clearComposeSuggestions, fetchComposeSuggestions, selectComposeSuggestion, - changeComposeSensitivity, - changeComposeSpoilerness, changeComposeSpoilerText, - changeComposeVisibility, - changeComposeListability } 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) { - const mentionedUsernamesWithDomains = state.getIn(['compose', 'text']).match(/(?:^|[^\/\w])@([a-z0-9_]+@[a-z0-9\.\-]+)/ig); +const getMentionedDomains = createSelector(getMentionedUsernames, mentionedUsernamesWithDomains => { + return mentionedUsernamesWithDomains !== null ? [...new Set(mentionedUsernamesWithDomains.map(item => item.split('@')[2]))] : []; +}); - 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']), - needsPrivacyWarning: state.getIn(['compose', 'private']) && mentionedUsernamesWithDomains !== null, - mentionedDomains: 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)); - }, - - onSubmit () { - dispatch(submitCompose()); - }, - - onCancelReply () { - dispatch(cancelReplyCompose()); - }, + 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']), + unlisted: state.getIn(['compose', 'unlisted'], ), + private: state.getIn(['compose', 'private']), + fileDropDate: state.getIn(['compose', 'fileDropDate']), + 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', 'private']) && mentionedUsernames !== null, + mentionedDomains: mentionedUsernamesWithDomains + }; +}; - onClearSuggestions () { - dispatch(clearComposeSuggestions()); - }, +const mapDispatchToProps = (dispatch) => ({ - onFetchSuggestions (token) { - dispatch(fetchComposeSuggestions(token)); - }, + onChange (text) { + dispatch(changeCompose(text)); + }, - onSuggestionSelected (position, token, accountId) { - dispatch(selectComposeSuggestion(position, token, accountId)); - }, + onSubmit () { + dispatch(submitCompose()); + }, - onChangeSensitivity (checked) { - dispatch(changeComposeSensitivity(checked)); - }, + onClearSuggestions () { + dispatch(clearComposeSuggestions()); + }, - onChangeSpoilerness (checked) { - dispatch(changeComposeSpoilerness(checked)); - }, + onFetchSuggestions (token) { + dispatch(fetchComposeSuggestions(token)); + }, - onChangeSpoilerText (checked) { - dispatch(changeComposeSpoilerText(checked)); - }, + onSuggestionSelected (position, token, accountId) { + dispatch(selectComposeSuggestion(position, token, accountId)); + }, - onChangeVisibility (checked) { - dispatch(changeComposeVisibility(checked)); - }, + onChangeSpoilerText (checked) { + dispatch(changeComposeSpoilerText(checked)); + }, - 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/private_toggle_container.jsx b/app/assets/javascripts/components/features/compose/containers/private_toggle_container.jsx new file mode 100644 index 000000000..ee3596902 --- /dev/null +++ b/app/assets/javascripts/components/features/compose/containers/private_toggle_container.jsx @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import PrivateToggle from '../components/private_toggle'; +import { changeComposeVisibility } from '../../../actions/compose'; + +const mapStateToProps = state => ({ + isPrivate: state.getIn(['compose', 'private']) +}); + +const mapDispatchToProps = dispatch => ({ + + onChange (e) { + dispatch(changeComposeVisibility(e.target.checked)); + } + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(PrivateToggle); 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/sensitive_toggle_container.jsx b/app/assets/javascripts/components/features/compose/containers/sensitive_toggle_container.jsx new file mode 100644 index 000000000..97b3361ba --- /dev/null +++ b/app/assets/javascripts/components/features/compose/containers/sensitive_toggle_container.jsx @@ -0,0 +1,18 @@ +import { connect } from 'react-redux'; +import SensitiveToggle from '../components/sensitive_toggle'; +import { changeComposeSensitivity } from '../../../actions/compose'; + +const mapStateToProps = state => ({ + hasMedia: state.getIn(['compose', 'media_attachments']).size > 0, + isSensitive: state.getIn(['compose', 'sensitive']) +}); + +const mapDispatchToProps = dispatch => ({ + + onChange (e) { + dispatch(changeComposeSensitivity(e.target.checked)); + } + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(SensitiveToggle); diff --git a/app/assets/javascripts/components/features/compose/containers/spoiler_toggle_container.jsx b/app/assets/javascripts/components/features/compose/containers/spoiler_toggle_container.jsx new file mode 100644 index 000000000..0bd4df759 --- /dev/null +++ b/app/assets/javascripts/components/features/compose/containers/spoiler_toggle_container.jsx @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import SpoilerToggle from '../components/spoiler_toggle'; +import { changeComposeSpoilerness } from '../../../actions/compose'; + +const mapStateToProps = state => ({ + isSpoiler: state.getIn(['compose', 'spoiler']) +}); + +const mapDispatchToProps = dispatch => ({ + + onChange (e) { + dispatch(changeComposeSpoilerness(e.target.checked)); + } + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(SpoilerToggle); diff --git a/app/assets/javascripts/components/features/compose/containers/unlisted_toggle_container.jsx b/app/assets/javascripts/components/features/compose/containers/unlisted_toggle_container.jsx new file mode 100644 index 000000000..ceac903d9 --- /dev/null +++ b/app/assets/javascripts/components/features/compose/containers/unlisted_toggle_container.jsx @@ -0,0 +1,31 @@ +import { connect } from 'react-redux'; +import UnlistedToggle from '../components/unlisted_toggle'; +import { makeGetStatus } from '../../../selectors'; +import { changeComposeListability } from '../../../actions/compose'; + +const makeMapStateToProps = () => { + const getStatus = makeGetStatus(); + + const mapStateToProps = state => { + const status = getStatus(state, state.getIn(['compose', 'in_reply_to'])); + const me = state.getIn(['compose', 'me']); + + return { + isPrivate: state.getIn(['compose', 'private']), + isUnlisted: state.getIn(['compose', 'unlisted']), + isReplyToOther: status ? status.getIn(['account', 'id']) !== me : false + }; + }; + + return mapStateToProps; +}; + +const mapDispatchToProps = dispatch => ({ + + onChangeListability (e) { + dispatch(changeComposeListability(e.target.checked)); + } + +}); + +export default connect(makeMapStateToProps, mapDispatchToProps)(UnlistedToggle); diff --git a/app/assets/javascripts/components/features/getting_started/index.jsx b/app/assets/javascripts/components/features/getting_started/index.jsx index af86919c1..f8433b8f4 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' /> diff --git a/app/assets/javascripts/components/features/hashtag_timeline/index.jsx b/app/assets/javascripts/components/features/hashtag_timeline/index.jsx index 6cb9e5482..7fb413336 100644 --- a/app/assets/javascripts/components/features/hashtag_timeline/index.jsx +++ b/app/assets/javascripts/components/features/hashtag_timeline/index.jsx @@ -12,6 +12,7 @@ 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']) }); @@ -20,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], @@ -72,10 +74,10 @@ 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} 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/index.jsx b/app/assets/javascripts/components/features/home_timeline/index.jsx index 23e198701..a2b775764 100644 --- a/app/assets/javascripts/components/features/home_timeline/index.jsx +++ b/app/assets/javascripts/components/features/home_timeline/index.jsx @@ -1,3 +1,4 @@ +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'; @@ -9,25 +10,30 @@ 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' 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> }} />} /> + <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/notification.jsx b/app/assets/javascripts/components/features/notifications/components/notification.jsx index fa8466140..0de4df52e 100644 --- a/app/assets/javascripts/components/features/notifications/components/notification.jsx +++ b/app/assets/javascripts/components/features/notifications/components/notification.jsx @@ -5,7 +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'; +import escapeTextContentForBrowser from 'escape-html'; const linkStyle = { fontWeight: '500' diff --git a/app/assets/javascripts/components/features/notifications/index.jsx b/app/assets/javascripts/components/features/notifications/index.jsx index 9532b8af8..0da3544f6 100644 --- a/app/assets/javascripts/components/features/notifications/index.jsx +++ b/app/assets/javascripts/components/features/notifications/index.jsx @@ -2,7 +2,7 @@ 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, FormattedMessage } from 'react-intl'; @@ -23,7 +23,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 +34,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 +53,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)); } }, @@ -74,18 +80,25 @@ const Notifications = React.createClass({ }, render () { - const { intl, notifications, trackScroll, isLoading } = this.props; + const { intl, notifications, trackScroll, isLoading, isUnread } = this.props; let loadMore = ''; let scrollableArea = ''; + let unread = ''; if (!isLoading && notifications.size > 0) { loadMore = <LoadMore onClick={this.handleLoadMore} />; } + 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} @@ -102,7 +115,7 @@ const Notifications = React.createClass({ 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'> @@ -112,7 +125,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..ce4eacc92 100644 --- a/app/assets/javascripts/components/features/public_timeline/index.jsx +++ b/app/assets/javascripts/components/features/public_timeline/index.jsx @@ -7,15 +7,16 @@ import { updateTimeline, deleteFromTimelines } 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']) }); @@ -24,7 +25,8 @@ 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], @@ -58,12 +60,12 @@ const PublicTimeline = React.createClass({ }, 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/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/containers/modal_container.jsx b/app/assets/javascripts/components/features/ui/containers/modal_container.jsx index 4c47fb8c5..d8301b20f 100644 --- a/app/assets/javascripts/components/features/ui/containers/modal_container.jsx +++ b/app/assets/javascripts/components/features/ui/containers/modal_container.jsx @@ -131,19 +131,14 @@ const Modal = React.createClass({ return null; } - const url = media.get(index).get('url'); - const hasLeft = index > 0; - const hasRight = index + 1 < media.size; + const url = media.get(index).get('url'); let leftNav, rightNav; leftNav = rightNav = ''; - if (hasLeft) { - leftNav = <div style={leftNavStyle} className='modal-container--nav' onClick={this.handlePrevClick}><i className='fa fa-fw fa-chevron-left' /></div>; - } - - if (hasRight) { + 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>; } 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/locales/en.jsx b/app/assets/javascripts/components/locales/en.jsx index 95962fd73..f1d6a6dbc 100644 --- a/app/assets/javascripts/components/locales/en.jsx +++ b/app/assets/javascripts/components/locales/en.jsx @@ -28,13 +28,13 @@ const en = { "getting_started.about_developer": "The developer of this project can be followed as Gargron@mastodon.social", "getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on github at {github}.", "column.home": "Home", - "column.mentions": "Mentions", - "column.public": "Public", + "column.community": "Local", + "column.public": "Whole Known Network", "column.notifications": "Notifications", "tabs_bar.compose": "Compose", "tabs_bar.home": "Home", "tabs_bar.mentions": "Mentions", - "tabs_bar.public": "Public", + "tabs_bar.public": "Whole Known Network", "tabs_bar.notifications": "Notifications", "compose_form.placeholder": "What is on your mind?", "compose_form.publish": "Toot", @@ -42,10 +42,11 @@ const en = { "compose_form.spoiler": "Hide text behind warning", "compose_form.private": "Mark as private", "compose_form.privacy_disclaimer": "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?", - "compose_form.unlisted": "Do not display in public timeline", + "compose_form.unlisted": "Do not display on public timelines", "navigation_bar.edit_profile": "Edit profile", "navigation_bar.preferences": "Preferences", - "navigation_bar.public_timeline": "Public timeline", + "navigation_bar.community_timeline": "Local timeline", + "navigation_bar.public_timeline": "Whole Known Network", "navigation_bar.logout": "Logout", "reply_indicator.cancel": "Cancel", "search.placeholder": "Search", diff --git a/app/assets/javascripts/components/reducers/compose.jsx b/app/assets/javascripts/components/reducers/compose.jsx index 8d281048e..dead5fd77 100644 --- a/app/assets/javascripts/components/reducers/compose.jsx +++ b/app/assets/javascripts/components/reducers/compose.jsx @@ -35,6 +35,8 @@ const initialState = Immutable.Map({ private: false, text: '', fileDropDate: null, + focusDate: null, + preselectDate: null, in_reply_to: null, is_submitting: false, is_uploading: false, @@ -99,6 +101,7 @@ const insertSuggestion = (state, position, token, completion) => { map.update('text', oldText => `${oldText.slice(0, position)}${completion} ${oldText.slice(position + token.length)}`); map.set('suggestion_token', null); map.update('suggestions', Immutable.List(), list => list.clear()); + map.set('focusDate', new Date()); }); }; @@ -113,7 +116,10 @@ export default function compose(state = initialState, action) { case COMPOSE_SENSITIVITY_CHANGE: return state.set('sensitive', action.checked); case COMPOSE_SPOILERNESS_CHANGE: - return (action.checked ? state : state.set('spoiler_text', '')).set('spoiler', action.checked); + return state.withMutations(map => { + map.set('spoiler_text', ''); + map.set('spoiler', action.checked); + }); case COMPOSE_SPOILER_TEXT_CHANGE: return state.set('spoiler_text', action.text); case COMPOSE_VISIBILITY_CHANGE: @@ -128,6 +134,8 @@ export default function compose(state = initialState, action) { map.set('text', statusToTextMentions(state, action.status)); map.set('unlisted', action.status.get('visibility') === 'unlisted' || state.get('default_privacy') === 'unlisted'); map.set('private', action.status.get('visibility') === 'private' || state.get('default_privacy') === 'private'); + map.set('focusDate', new Date()); + map.set('preselectDate', new Date()); }); case COMPOSE_REPLY_CANCEL: return state.withMutations(map => { @@ -156,7 +164,7 @@ export default function compose(state = initialState, action) { case COMPOSE_UPLOAD_PROGRESS: return state.set('progress', Math.round((action.loaded / action.total) * 100)); case COMPOSE_MENTION: - return state.update('text', text => `${text}@${action.account.get('acct')} `); + return state.update('text', text => `${text}@${action.account.get('acct')} `).set('focusDate', new Date()); case COMPOSE_SUGGESTIONS_CLEAR: return state.update('suggestions', Immutable.List(), list => list.clear()).set('suggestion_token', null); case COMPOSE_SUGGESTIONS_READY: diff --git a/app/assets/javascripts/components/reducers/modal.jsx b/app/assets/javascripts/components/reducers/modal.jsx index 07da65771..37ffbc62b 100644 --- a/app/assets/javascripts/components/reducers/modal.jsx +++ b/app/assets/javascripts/components/reducers/modal.jsx @@ -23,9 +23,9 @@ export default function modal(state = initialState, action) { case MODAL_CLOSE: return state.set('open', false); case MODAL_INDEX_DECREASE: - return state.update('index', index => Math.max(index - 1, 0)); + return state.update('index', index => (index - 1) % state.get('media').size); case MODAL_INDEX_INCREASE: - return state.update('index', index => Math.min(index + 1, state.get('media').size - 1)); + return state.update('index', index => (index + 1) % state.get('media').size); default: return state; } diff --git a/app/assets/javascripts/components/reducers/notifications.jsx b/app/assets/javascripts/components/reducers/notifications.jsx index 4a7af8856..1406a388a 100644 --- a/app/assets/javascripts/components/reducers/notifications.jsx +++ b/app/assets/javascripts/components/reducers/notifications.jsx @@ -6,7 +6,8 @@ import { NOTIFICATIONS_EXPAND_REQUEST, NOTIFICATIONS_REFRESH_FAIL, NOTIFICATIONS_EXPAND_FAIL, - NOTIFICATIONS_CLEAR + NOTIFICATIONS_CLEAR, + NOTIFICATIONS_SCROLL_TOP } from '../actions/notifications'; import { ACCOUNT_BLOCK_SUCCESS } from '../actions/accounts'; import Immutable from 'immutable'; @@ -14,6 +15,8 @@ import Immutable from 'immutable'; const initialState = Immutable.Map({ items: Immutable.List(), next: null, + top: true, + unread: 0, loaded: false, isLoading: true }); @@ -26,6 +29,10 @@ const notificationToMap = notification => Immutable.Map({ }); const normalizeNotification = (state, notification) => { + if (!state.get('top')) { + state = state.update('unread', unread => unread + 1); + } + return state.update('items', list => list.unshift(notificationToMap(notification))); }; @@ -37,9 +44,12 @@ const normalizeNotifications = (state, notifications, next) => { items = items.set(i, notificationToMap(n)); }); + if (state.get('next') === null) { + state = state.set('next', next); + } + return state .update('items', list => loaded ? list.unshift(...items) : list.push(...items)) - .set('next', next) .set('loaded', true) .set('isLoading', false); }; @@ -61,6 +71,14 @@ const filterNotifications = (state, relationship) => { return state.update('items', list => list.filterNot(item => item.get('account') === relationship.id)); }; +const updateTop = (state, top) => { + if (top) { + state = state.set('unread', 0); + } + + return state.set('top', top); +}; + export default function notifications(state = initialState, action) { switch(action.type) { case NOTIFICATIONS_REFRESH_REQUEST: @@ -68,6 +86,8 @@ export default function notifications(state = initialState, action) { case NOTIFICATIONS_REFRESH_FAIL: case NOTIFICATIONS_EXPAND_FAIL: return state.set('isLoading', true); + case NOTIFICATIONS_SCROLL_TOP: + return updateTop(state, action.top); case NOTIFICATIONS_UPDATE: return normalizeNotification(state, action.notification); case NOTIFICATIONS_REFRESH_SUCCESS: diff --git a/app/assets/javascripts/components/reducers/timelines.jsx b/app/assets/javascripts/components/reducers/timelines.jsx index 6f2d26dcb..6472ac6a0 100644 --- a/app/assets/javascripts/components/reducers/timelines.jsx +++ b/app/assets/javascripts/components/reducers/timelines.jsx @@ -31,31 +31,44 @@ import Immutable from 'immutable'; const initialState = Immutable.Map({ home: Immutable.Map({ + path: () => '/api/v1/timelines/home', + next: null, isLoading: false, loaded: false, top: true, + unread: 0, items: Immutable.List() }), - mentions: Immutable.Map({ + public: Immutable.Map({ + path: () => '/api/v1/timelines/public', + next: null, isLoading: false, loaded: false, top: true, + unread: 0, items: Immutable.List() }), - public: Immutable.Map({ + community: Immutable.Map({ + path: () => '/api/v1/timelines/public', + next: null, + params: { local: true }, isLoading: false, loaded: false, top: true, + unread: 0, items: Immutable.List() }), tag: Immutable.Map({ + path: (id) => `/api/v1/timelines/tag/${id}`, + next: null, isLoading: false, id: null, loaded: false, top: true, + unread: 0, items: Immutable.List() }), @@ -81,7 +94,7 @@ const normalizeStatus = (state, status) => { return state; }; -const normalizeTimeline = (state, timeline, statuses, replace = false) => { +const normalizeTimeline = (state, timeline, statuses, next) => { let ids = Immutable.List(); const loaded = state.getIn([timeline, 'loaded']); @@ -93,10 +106,14 @@ const normalizeTimeline = (state, timeline, statuses, replace = false) => { state = state.setIn([timeline, 'loaded'], true); state = state.setIn([timeline, 'isLoading'], false); + if (state.getIn([timeline, 'next']) === null) { + state = state.setIn([timeline, 'next'], next); + } + return state.updateIn([timeline, 'items'], Immutable.List(), list => (loaded ? list.unshift(...ids) : ids)); }; -const appendNormalizedTimeline = (state, timeline, statuses) => { +const appendNormalizedTimeline = (state, timeline, statuses, next) => { let moreIds = Immutable.List(); statuses.forEach((status, i) => { @@ -105,6 +122,7 @@ const appendNormalizedTimeline = (state, timeline, statuses) => { }); state = state.setIn([timeline, 'isLoading'], false); + state = state.setIn([timeline, 'next'], next); return state.updateIn([timeline, 'items'], Immutable.List(), list => list.push(...moreIds)); }; @@ -141,6 +159,10 @@ const updateTimeline = (state, timeline, status, references) => { state = normalizeStatus(state, status); + if (!top) { + state = state.updateIn([timeline, 'unread'], unread => unread + 1); + } + state = state.updateIn([timeline, 'items'], Immutable.List(), list => { if (top && list.size > 40) { list = list.take(20); @@ -169,7 +191,7 @@ const deleteStatus = (state, id, accountId, references, reblogOf) => { } // Remove references from timelines - ['home', 'mentions', 'public', 'tag'].forEach(function (timeline) { + ['home', 'public', 'community', 'tag'].forEach(function (timeline) { state = state.updateIn([timeline, 'items'], list => list.filterNot(item => item === id)); }); @@ -221,11 +243,13 @@ const normalizeContext = (state, id, ancestors, descendants) => { }; const resetTimeline = (state, timeline, id) => { - if (timeline === 'tag' && state.getIn([timeline, 'id']) !== id) { + if (timeline === 'tag' && typeof id !== 'undefined' && state.getIn([timeline, 'id']) !== id) { state = state.update(timeline, map => map .set('id', id) .set('isLoading', true) .set('loaded', false) + .set('next', null) + .set('top', true) .update('items', list => list.clear())); } else { state = state.setIn([timeline, 'isLoading'], true); @@ -234,6 +258,14 @@ const resetTimeline = (state, timeline, id) => { return state; }; +const updateTop = (state, timeline, top) => { + if (top) { + state = state.setIn([timeline, 'unread'], 0); + } + + return state.setIn([timeline, 'top'], top); +}; + export default function timelines(state = initialState, action) { switch(action.type) { case TIMELINE_REFRESH_REQUEST: @@ -243,9 +275,9 @@ export default function timelines(state = initialState, action) { case TIMELINE_EXPAND_FAIL: return state.setIn([action.timeline, 'isLoading'], false); case TIMELINE_REFRESH_SUCCESS: - return normalizeTimeline(state, action.timeline, Immutable.fromJS(action.statuses)); + return normalizeTimeline(state, action.timeline, Immutable.fromJS(action.statuses), action.next); case TIMELINE_EXPAND_SUCCESS: - return appendNormalizedTimeline(state, action.timeline, Immutable.fromJS(action.statuses)); + return appendNormalizedTimeline(state, action.timeline, Immutable.fromJS(action.statuses), action.next); case TIMELINE_UPDATE: return updateTimeline(state, action.timeline, Immutable.fromJS(action.status), action.references); case TIMELINE_DELETE: @@ -265,7 +297,7 @@ export default function timelines(state = initialState, action) { case ACCOUNT_BLOCK_SUCCESS: return filterTimelines(state, action.relationship, action.statuses); case TIMELINE_SCROLL_TOP: - return state.setIn([action.timeline, 'top'], action.top); + return updateTop(state, action.timeline, action.top); default: return state; } diff --git a/app/assets/javascripts/components/selectors/index.jsx b/app/assets/javascripts/components/selectors/index.jsx index 20debe604..0e88654a1 100644 --- a/app/assets/javascripts/components/selectors/index.jsx +++ b/app/assets/javascripts/components/selectors/index.jsx @@ -1,4 +1,4 @@ -import { createSelector } from 'reselect' +import { createSelector } from 'reselect'; import Immutable from 'immutable'; const getStatuses = state => state.get('statuses'); @@ -17,37 +17,32 @@ export const makeGetAccount = () => { }); }; -const getStatusBase = (state, id) => state.getIn(['statuses', id], null); - export const makeGetStatus = () => { - return createSelector([getStatusBase, getStatuses, getAccounts], (base, statuses, accounts) => { - if (base === null) { - return null; + return createSelector( + [ + (state, id) => state.getIn(['statuses', id]), + (state, id) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]), + (state, id) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]), + (state, id) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]), + ], + + (statusBase, statusReblog, accountBase, accountReblog) => { + if (!statusBase) { + return null; + } + + if (statusReblog) { + statusReblog = statusReblog.set('account', accountReblog); + } else { + statusReblog = null; + } + + return statusBase.withMutations(map => { + map.set('reblog', statusReblog); + map.set('account', accountBase); + }); } - - return assembleStatus(base.get('id'), statuses, accounts); - }); -}; - -const assembleStatus = (id, statuses, accounts) => { - let status = statuses.get(id, null); - let reblog = null; - - if (status === null) { - return null; - } - - if (status.get('reblog', null) !== null) { - reblog = statuses.get(status.get('reblog'), null); - - if (reblog !== null) { - reblog = reblog.set('account', accounts.get(reblog.get('account'))); - } else { - return null; - } - } - - return status.set('reblog', reblog).set('account', accounts.get(status.get('account'))); + ); }; const getAlertsBase = state => state.get('alerts'); diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss index bc56891f3..94c351520 100644 --- a/app/assets/stylesheets/components.scss +++ b/app/assets/stylesheets/components.scss @@ -786,6 +786,7 @@ a.status__content__spoiler-link { flex: 0 0 auto; cursor: pointer; color: $color4; + z-index: 3; &:hover { text-decoration: underline; @@ -1079,6 +1080,17 @@ button.active i.fa-retweet { background: lighten($color1, 4%); flex: 0 0 auto; cursor: pointer; + position: relative; + z-index: 2; + + &.active { + box-shadow: 0 1px 0 rgba($color4, 0.3); + } + + &.active .fa { + color: $color4; + text-shadow: 0 0 10px rgba($color4, 0.4); + } } .search { @@ -1201,3 +1213,16 @@ button.active i.fa-retweet { } } } + +.status-list__unread-indicator, .notifications__unread-indicator { + position: absolute; + top: 35px; + left: 0; + right: 0; + margin: 0 auto; + width: 60%; + pointer-events: none; + height: 28px; + z-index: 1; + background: radial-gradient(ellipse, rgba($color4, 0.23) 0%, rgba($color4, 0) 60%); +} |