diff options
Diffstat (limited to 'app/javascript')
11 files changed, 112 insertions, 47 deletions
diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js index cbae62a0f..d4a824e2c 100644 --- a/app/javascript/mastodon/actions/accounts.js +++ b/app/javascript/mastodon/actions/accounts.js @@ -145,12 +145,14 @@ export function fetchAccountFail(id, error) { export function followAccount(id, reblogs = true) { return (dispatch, getState) => { const alreadyFollowing = getState().getIn(['relationships', id, 'following']); - dispatch(followAccountRequest(id)); + const locked = getState().getIn(['accounts', id, 'locked'], false); + + dispatch(followAccountRequest(id, locked)); api(getState).post(`/api/v1/accounts/${id}/follow`, { reblogs }).then(response => { dispatch(followAccountSuccess(response.data, alreadyFollowing)); }).catch(error => { - dispatch(followAccountFail(error)); + dispatch(followAccountFail(error, locked)); }); }; }; @@ -167,10 +169,12 @@ export function unfollowAccount(id) { }; }; -export function followAccountRequest(id) { +export function followAccountRequest(id, locked) { return { type: ACCOUNT_FOLLOW_REQUEST, id, + locked, + skipLoading: true, }; }; @@ -179,13 +183,16 @@ export function followAccountSuccess(relationship, alreadyFollowing) { type: ACCOUNT_FOLLOW_SUCCESS, relationship, alreadyFollowing, + skipLoading: true, }; }; -export function followAccountFail(error) { +export function followAccountFail(error, locked) { return { type: ACCOUNT_FOLLOW_FAIL, error, + locked, + skipLoading: true, }; }; @@ -193,6 +200,7 @@ export function unfollowAccountRequest(id) { return { type: ACCOUNT_UNFOLLOW_REQUEST, id, + skipLoading: true, }; }; @@ -201,6 +209,7 @@ export function unfollowAccountSuccess(relationship, statuses) { type: ACCOUNT_UNFOLLOW_SUCCESS, relationship, statuses, + skipLoading: true, }; }; @@ -208,6 +217,7 @@ export function unfollowAccountFail(error) { return { type: ACCOUNT_UNFOLLOW_FAIL, error, + skipLoading: true, }; }; diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index 2b7962a6e..8cb06c157 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -97,13 +97,12 @@ export const expandAccountTimeline = (accountId, { maxId, withReplies } export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true }); export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true }); export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done); - export const expandHashtagTimeline = (hashtag, { maxId, tags } = {}, done = noOp) => { return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, { max_id: maxId, - any: parseTags(tags, 'any'), - all: parseTags(tags, 'all'), - none: parseTags(tags, 'none'), + any: parseTags(tags, 'any'), + all: parseTags(tags, 'all'), + none: parseTags(tags, 'none'), }, done); }; @@ -111,6 +110,7 @@ export function expandTimelineRequest(timeline) { return { type: TIMELINE_EXPAND_REQUEST, timeline, + skipLoading: true, }; }; @@ -121,6 +121,7 @@ export function expandTimelineSuccess(timeline, statuses, next, partial) { statuses, next, partial, + skipLoading: true, }; }; @@ -129,6 +130,7 @@ export function expandTimelineFail(timeline, error) { type: TIMELINE_EXPAND_FAIL, timeline, error, + skipLoading: true, }; }; diff --git a/app/javascript/mastodon/components/scrollable_list.js b/app/javascript/mastodon/components/scrollable_list.js index e51c83c2b..91a895bce 100644 --- a/app/javascript/mastodon/components/scrollable_list.js +++ b/app/javascript/mastodon/components/scrollable_list.js @@ -8,6 +8,7 @@ import { throttle } from 'lodash'; import { List as ImmutableList } from 'immutable'; import classNames from 'classnames'; import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen'; +import LoadingIndicator from './loading_indicator'; const MOUSE_IDLE_DELAY = 300; @@ -25,6 +26,7 @@ export default class ScrollableList extends PureComponent { trackScroll: PropTypes.bool, shouldUpdateScroll: PropTypes.func, isLoading: PropTypes.bool, + showLoading: PropTypes.bool, hasMore: PropTypes.bool, prepend: PropTypes.node, alwaysPrepend: PropTypes.bool, @@ -39,8 +41,6 @@ export default class ScrollableList extends PureComponent { state = { fullscreen: null, - mouseMovedRecently: false, - scrollToTopOnMouseIdle: false, }; intersectionObserverWrapper = new IntersectionObserverWrapper(); @@ -65,11 +65,14 @@ export default class ScrollableList extends PureComponent { }); mouseIdleTimer = null; + mouseMovedRecently = false; + scrollToTopOnMouseIdle = false; clearMouseIdleTimer = () => { if (this.mouseIdleTimer === null) { return; } + clearTimeout(this.mouseIdleTimer); this.mouseIdleTimer = null; }; @@ -77,37 +80,36 @@ export default class ScrollableList extends PureComponent { handleMouseMove = throttle(() => { // As long as the mouse keeps moving, clear and restart the idle timer. this.clearMouseIdleTimer(); - this.mouseIdleTimer = - setTimeout(this.handleMouseIdle, MOUSE_IDLE_DELAY); - - this.setState(({ - mouseMovedRecently, - scrollToTopOnMouseIdle, - }) => ({ - mouseMovedRecently: true, - // Only set scrollToTopOnMouseIdle if we just started moving and were - // scrolled to the top. Otherwise, just retain the previous state. - scrollToTopOnMouseIdle: - mouseMovedRecently - ? scrollToTopOnMouseIdle - : (this.node.scrollTop === 0), - })); + this.mouseIdleTimer = setTimeout(this.handleMouseIdle, MOUSE_IDLE_DELAY); + + if (!this.mouseMovedRecently && this.node.scrollTop === 0) { + // Only set if we just started moving and are scrolled to the top. + this.scrollToTopOnMouseIdle = true; + } + + // Save setting this flag for last, so we can do the comparison above. + this.mouseMovedRecently = true; }, MOUSE_IDLE_DELAY / 2); + handleWheel = throttle(() => { + this.scrollToTopOnMouseIdle = false; + }, 150, { + trailing: true, + }); + handleMouseIdle = () => { - if (this.state.scrollToTopOnMouseIdle) { + if (this.scrollToTopOnMouseIdle) { this.node.scrollTop = 0; - this.props.onScrollToTop(); } - this.setState({ - mouseMovedRecently: false, - scrollToTopOnMouseIdle: false, - }); + + this.mouseMovedRecently = false; + this.scrollToTopOnMouseIdle = false; } componentDidMount () { this.attachScrollListener(); this.attachIntersectionObserver(); + attachFullscreenListener(this.onFullScreenChange); // Handle initial scroll posiiton @@ -118,7 +120,8 @@ export default class ScrollableList extends PureComponent { const someItemInserted = React.Children.count(prevProps.children) > 0 && React.Children.count(prevProps.children) < React.Children.count(this.props.children) && this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props); - if ((someItemInserted && this.node.scrollTop > 0) || this.state.mouseMovedRecently) { + + if ((someItemInserted && this.node.scrollTop > 0) || this.mouseMovedRecently) { return this.node.scrollHeight - this.node.scrollTop; } else { return null; @@ -161,20 +164,24 @@ export default class ScrollableList extends PureComponent { attachScrollListener () { this.node.addEventListener('scroll', this.handleScroll); + this.node.addEventListener('wheel', this.handleWheel); } detachScrollListener () { this.node.removeEventListener('scroll', this.handleScroll); + this.node.removeEventListener('wheel', this.handleWheel); } getFirstChildKey (props) { const { children } = props; - let firstChild = children; + let firstChild = children; + if (children instanceof ImmutableList) { firstChild = children.get(0); } else if (Array.isArray(children)) { firstChild = children[0]; } + return firstChild && firstChild.key; } @@ -182,20 +189,32 @@ export default class ScrollableList extends PureComponent { this.node = c; } - handleLoadMore = (e) => { + handleLoadMore = e => { e.preventDefault(); this.props.onLoadMore(); } render () { - const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, alwaysPrepend, alwaysShowScrollbar, emptyMessage, onLoadMore } = this.props; + const { children, scrollKey, trackScroll, shouldUpdateScroll, showLoading, isLoading, hasMore, prepend, alwaysPrepend, alwaysShowScrollbar, emptyMessage, onLoadMore } = this.props; const { fullscreen } = this.state; const childrenCount = React.Children.count(children); const loadMore = (hasMore && childrenCount > 0 && onLoadMore) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null; let scrollableArea = null; - if (isLoading || childrenCount > 0 || !emptyMessage) { + if (showLoading) { + scrollableArea = ( + <div className='scrollable scrollable--flex' ref={this.setRef}> + <div role='feed' className='item-list'> + {prepend} + </div> + + <div className='scrollable__append'> + <LoadingIndicator /> + </div> + </div> + ); + } else if (isLoading || childrenCount > 0 || !emptyMessage) { scrollableArea = ( <div className={classNames('scrollable', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove}> <div role='feed' className='item-list'> diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index 9fa8cc008..fd0780025 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -67,6 +67,7 @@ class Status extends ImmutablePureComponent { unread: PropTypes.bool, onMoveUp: PropTypes.func, onMoveDown: PropTypes.func, + showThread: PropTypes.bool, }; // Avoid checking props that are functions (and whose equality will always @@ -168,7 +169,7 @@ class Status extends ImmutablePureComponent { let media = null; let statusAvatar, prepend, rebloggedByText; - const { intl, hidden, featured, otherAccounts, unread } = this.props; + const { intl, hidden, featured, otherAccounts, unread, showThread } = this.props; let { status, account, ...other } = this.props; @@ -309,6 +310,12 @@ class Status extends ImmutablePureComponent { {media} + {showThread && status.get('in_reply_to_id') && status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) && ( + <button className='status__content__read-more-button' onClick={this.handleClick}> + <FormattedMessage id='status.show_thread' defaultMessage='Show thread' /> + </button> + )} + <StatusActionBar status={status} account={account} {...other} /> </div> </div> diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js index e7e5b0a6c..68a1fda24 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -148,7 +148,6 @@ class StatusActionBar extends ImmutablePureComponent { let menu = []; let reblogIcon = 'retweet'; - let replyIcon; let replyTitle; menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen }); @@ -191,10 +190,8 @@ class StatusActionBar extends ImmutablePureComponent { } if (status.get('in_reply_to_id', null) === null) { - replyIcon = 'reply'; replyTitle = intl.formatMessage(messages.reply); } else { - replyIcon = 'reply-all'; replyTitle = intl.formatMessage(messages.replyAll); } @@ -204,7 +201,7 @@ class StatusActionBar extends ImmutablePureComponent { return ( <div className='status__action-bar'> - <div className='status__action-bar__counter'><IconButton className='status__action-bar-button' disabled={anonymousAccess} title={replyTitle} icon={replyIcon} onClick={this.handleReplyClick} /><span className='status__action-bar__counter__label' >{obfuscatedCount(status.get('replies_count'))}</span></div> + <div className='status__action-bar__counter'><IconButton className='status__action-bar-button' disabled={anonymousAccess} title={replyTitle} icon='reply' onClick={this.handleReplyClick} /><span className='status__action-bar__counter__label' >{obfuscatedCount(status.get('replies_count'))}</span></div> <IconButton className='status__action-bar-button' disabled={anonymousAccess || !publicStatus} active={status.get('reblogged')} pressed={status.get('reblogged')} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /> <IconButton className='status__action-bar-button star-icon' disabled={anonymousAccess} animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /> {shareButton} diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js index 37f21fb44..01cc05661 100644 --- a/app/javascript/mastodon/components/status_list.js +++ b/app/javascript/mastodon/components/status_list.js @@ -25,7 +25,7 @@ export default class StatusList extends ImmutablePureComponent { prepend: PropTypes.node, emptyMessage: PropTypes.node, alwaysPrepend: PropTypes.bool, - timelineId: PropTypes.string.isRequired, + timelineId: PropTypes.string, }; static defaultProps = { @@ -104,6 +104,7 @@ export default class StatusList extends ImmutablePureComponent { onMoveUp={this.handleMoveUp} onMoveDown={this.handleMoveDown} contextType={timelineId} + showThread /> )) ) : null; @@ -117,12 +118,13 @@ export default class StatusList extends ImmutablePureComponent { onMoveUp={this.handleMoveUp} onMoveDown={this.handleMoveDown} contextType={timelineId} + showThread /> )).concat(scrollableContent); } return ( - <ScrollableList {...other} onLoadMore={onLoadMore && this.handleLoadOlder} shouldUpdateScroll={shouldUpdateScroll} ref={this.setRef}> + <ScrollableList {...other} showLoading={isLoading && statusIds.size === 0} onLoadMore={onLoadMore && this.handleLoadOlder} shouldUpdateScroll={shouldUpdateScroll} ref={this.setRef}> {scrollableContent} </ScrollableList> ); diff --git a/app/javascript/mastodon/features/account_timeline/index.js b/app/javascript/mastodon/features/account_timeline/index.js index 6055af51d..afc484c60 100644 --- a/app/javascript/mastodon/features/account_timeline/index.js +++ b/app/javascript/mastodon/features/account_timeline/index.js @@ -11,6 +11,7 @@ import HeaderContainer from './containers/header_container'; import ColumnBackButton from '../../components/column_back_button'; import { List as ImmutableList } from 'immutable'; import ImmutablePureComponent from 'react-immutable-pure-component'; +import { FormattedMessage } from 'react-intl'; const mapStateToProps = (state, { params: { accountId }, withReplies = false }) => { const path = withReplies ? `${accountId}:with_replies` : accountId; @@ -78,6 +79,7 @@ class AccountTimeline extends ImmutablePureComponent { <StatusList prepend={<HeaderContainer accountId={this.props.params.accountId} />} + alwaysPrepend scrollKey='account_timeline' statusIds={statusIds} featuredStatusIds={featuredStatusIds} @@ -85,6 +87,7 @@ class AccountTimeline extends ImmutablePureComponent { hasMore={hasMore} onLoadMore={this.handleLoadMore} shouldUpdateScroll={shouldUpdateScroll} + emptyMessage={<FormattedMessage id='empty_column.account_timeline' defaultMessage='No toots here!' />} /> </Column> ); diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js index fa6fd56e5..565009be2 100644 --- a/app/javascript/mastodon/features/status/components/action_bar.js +++ b/app/javascript/mastodon/features/status/components/action_bar.js @@ -159,7 +159,7 @@ class ActionBar extends React.PureComponent { return ( <div className='detailed-status__action-bar'> - <div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_id', null) === null ? 'reply' : 'reply-all'} onClick={this.handleReplyClick} /></div> + <div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon='reply' onClick={this.handleReplyClick} /></div> <div className='detailed-status__button'><IconButton disabled={reblog_disabled} active={status.get('reblogged')} title={reblog_disabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /></div> <div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /></div> {shareButton} diff --git a/app/javascript/mastodon/reducers/relationships.js b/app/javascript/mastodon/reducers/relationships.js index f46049297..8322780de 100644 --- a/app/javascript/mastodon/reducers/relationships.js +++ b/app/javascript/mastodon/reducers/relationships.js @@ -1,6 +1,10 @@ import { ACCOUNT_FOLLOW_SUCCESS, + ACCOUNT_FOLLOW_REQUEST, + ACCOUNT_FOLLOW_FAIL, ACCOUNT_UNFOLLOW_SUCCESS, + ACCOUNT_UNFOLLOW_REQUEST, + ACCOUNT_UNFOLLOW_FAIL, ACCOUNT_BLOCK_SUCCESS, ACCOUNT_UNBLOCK_SUCCESS, ACCOUNT_MUTE_SUCCESS, @@ -37,6 +41,14 @@ const initialState = ImmutableMap(); export default function relationships(state = initialState, action) { switch(action.type) { + case ACCOUNT_FOLLOW_REQUEST: + return state.setIn([action.id, action.locked ? 'requested' : 'following'], true); + case ACCOUNT_FOLLOW_FAIL: + return state.setIn([action.id, action.locked ? 'requested' : 'following'], false); + case ACCOUNT_UNFOLLOW_REQUEST: + return state.setIn([action.id, 'following'], false); + case ACCOUNT_UNFOLLOW_FAIL: + return state.setIn([action.id, 'following'], true); case ACCOUNT_FOLLOW_SUCCESS: case ACCOUNT_UNFOLLOW_SUCCESS: case ACCOUNT_BLOCK_SUCCESS: diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index e669bf2e2..0ee0b002f 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -1847,7 +1847,7 @@ a.account__display-name { } .column { - width: 330px; + width: 350px; position: relative; box-sizing: border-box; display: flex; @@ -2092,6 +2092,16 @@ a.account__display-name { @supports(display: grid) { // hack to fix Chrome <57 contain: strict; } + + &--flex { + display: flex; + flex-direction: column; + } + + &__append { + flex: 1 1 auto; + position: relative; + } } .scrollable.fullscreen { diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss index 8c4c934ea..46ef85774 100644 --- a/app/javascript/styles/mastodon/forms.scss +++ b/app/javascript/styles/mastodon/forms.scss @@ -330,9 +330,12 @@ code { } input[type=text], + input[type=number], input[type=email], - input[type=password] { - border-bottom-color: $valid-value-color; + input[type=password], + textarea, + select { + border-color: lighten($error-red, 12%); } .error { |