diff options
Diffstat (limited to 'app/javascript/mastodon/features')
20 files changed, 279 insertions, 209 deletions
diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js index 320e669a2..7ab492225 100644 --- a/app/javascript/mastodon/features/account/components/header.js +++ b/app/javascript/mastodon/features/account/components/header.js @@ -14,7 +14,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; const messages = defineMessages({ unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, follow: { id: 'account.follow', defaultMessage: 'Follow' }, - requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }, + requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' }, }); const makeMapStateToProps = () => { @@ -105,7 +105,7 @@ export default class Header extends ImmutablePureComponent { if (account.getIn(['relationship', 'requested'])) { actionBtn = ( <div className='account--action-button'> - <IconButton size={26} disabled icon='hourglass' title={intl.formatMessage(messages.requested)} /> + <IconButton size={26} active icon='hourglass' title={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} /> </div> ); } else if (!account.getIn(['relationship', 'blocking'])) { diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.js b/app/javascript/mastodon/features/account_timeline/containers/header_container.js index baa81bbc2..dcee78b3e 100644 --- a/app/javascript/mastodon/features/account_timeline/containers/header_container.js +++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.js @@ -38,7 +38,7 @@ const makeMapStateToProps = () => { const mapDispatchToProps = (dispatch, { intl }) => ({ onFollow (account) { - if (account.getIn(['relationship', 'following'])) { + if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) { if (this.unfollowModal) { dispatch(openModal('CONFIRM', { message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, diff --git a/app/javascript/mastodon/features/community_timeline/index.js b/app/javascript/mastodon/features/community_timeline/index.js index 0e2300f8c..596a89412 100644 --- a/app/javascript/mastodon/features/community_timeline/index.js +++ b/app/javascript/mastodon/features/community_timeline/index.js @@ -7,15 +7,11 @@ import ColumnHeader from '../../components/column_header'; import { refreshCommunityTimeline, expandCommunityTimeline, - updateTimeline, - deleteFromTimelines, - connectTimeline, - disconnectTimeline, } from '../../actions/timelines'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ColumnSettingsContainer from './containers/column_settings_container'; -import createStream from '../../stream'; +import { connectCommunityStream } from '../../actions/streaming'; const messages = defineMessages({ title: { id: 'column.community', defaultMessage: 'Local timeline' }, @@ -23,8 +19,6 @@ const messages = defineMessages({ const mapStateToProps = state => ({ hasUnread: state.getIn(['timelines', 'community', 'unread']) > 0, - streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']), - accessToken: state.getIn(['meta', 'access_token']), }); @connect(mapStateToProps) @@ -35,8 +29,6 @@ export default class CommunityTimeline extends React.PureComponent { dispatch: PropTypes.func.isRequired, columnId: PropTypes.string, intl: PropTypes.object.isRequired, - streamingAPIBaseURL: PropTypes.string.isRequired, - accessToken: PropTypes.string.isRequired, hasUnread: PropTypes.bool, multiColumn: PropTypes.bool, }; @@ -61,46 +53,16 @@ export default class CommunityTimeline extends React.PureComponent { } componentDidMount () { - const { dispatch, streamingAPIBaseURL, accessToken } = this.props; + const { dispatch } = this.props; dispatch(refreshCommunityTimeline()); - - if (typeof this._subscription !== 'undefined') { - return; - } - - this._subscription = createStream(streamingAPIBaseURL, accessToken, 'public:local', { - - connected () { - dispatch(connectTimeline('community')); - }, - - reconnected () { - dispatch(connectTimeline('community')); - }, - - disconnected () { - dispatch(disconnectTimeline('community')); - }, - - received (data) { - switch(data.event) { - case 'update': - dispatch(updateTimeline('community', JSON.parse(data.payload))); - break; - case 'delete': - dispatch(deleteFromTimelines(data.payload)); - break; - } - }, - - }); + this.disconnect = dispatch(connectCommunityStream()); } componentWillUnmount () { - if (typeof this._subscription !== 'undefined') { - this._subscription.close(); - this._subscription = null; + if (this.disconnect) { + this.disconnect(); + this.disconnect = null; } } diff --git a/app/javascript/mastodon/features/favourited_statuses/index.js b/app/javascript/mastodon/features/favourited_statuses/index.js index d9ad9bc1f..1e1f5873c 100644 --- a/app/javascript/mastodon/features/favourited_statuses/index.js +++ b/app/javascript/mastodon/features/favourited_statuses/index.js @@ -16,6 +16,7 @@ const messages = defineMessages({ const mapStateToProps = state => ({ statusIds: state.getIn(['status_lists', 'favourites', 'items']), + hasMore: !!state.getIn(['status_lists', 'favourites', 'next']), }); @connect(mapStateToProps) @@ -28,6 +29,7 @@ export default class Favourites extends ImmutablePureComponent { intl: PropTypes.object.isRequired, columnId: PropTypes.string, multiColumn: PropTypes.bool, + hasMore: PropTypes.bool, }; componentWillMount () { @@ -62,7 +64,7 @@ export default class Favourites extends ImmutablePureComponent { } render () { - const { intl, statusIds, columnId, multiColumn } = this.props; + const { intl, statusIds, columnId, multiColumn, hasMore } = this.props; const pinned = !!columnId; return ( @@ -75,12 +77,14 @@ export default class Favourites extends ImmutablePureComponent { onClick={this.handleHeaderClick} pinned={pinned} multiColumn={multiColumn} + showBackButton /> <StatusList trackScroll={!pinned} statusIds={statusIds} scrollKey={`favourited_statuses-${columnId}`} + hasMore={hasMore} onScrollToBottom={this.handleScrollToBottom} /> </Column> diff --git a/app/javascript/mastodon/features/hashtag_timeline/index.js b/app/javascript/mastodon/features/hashtag_timeline/index.js index b17e8e1a5..5fe21ce90 100644 --- a/app/javascript/mastodon/features/hashtag_timeline/index.js +++ b/app/javascript/mastodon/features/hashtag_timeline/index.js @@ -7,17 +7,13 @@ import ColumnHeader from '../../components/column_header'; import { refreshHashtagTimeline, expandHashtagTimeline, - updateTimeline, - deleteFromTimelines, } from '../../actions/timelines'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { FormattedMessage } from 'react-intl'; -import createStream from '../../stream'; +import { connectHashtagStream } from '../../actions/streaming'; -const mapStateToProps = state => ({ - hasUnread: state.getIn(['timelines', 'tag', 'unread']) > 0, - streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']), - accessToken: state.getIn(['meta', 'access_token']), +const mapStateToProps = (state, props) => ({ + hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}`, 'unread']) > 0, }); @connect(mapStateToProps) @@ -27,8 +23,6 @@ export default class HashtagTimeline extends React.PureComponent { params: PropTypes.object.isRequired, columnId: PropTypes.string, dispatch: PropTypes.func.isRequired, - streamingAPIBaseURL: PropTypes.string.isRequired, - accessToken: PropTypes.string.isRequired, hasUnread: PropTypes.bool, multiColumn: PropTypes.bool, }; @@ -53,28 +47,13 @@ export default class HashtagTimeline extends React.PureComponent { } _subscribe (dispatch, id) { - const { streamingAPIBaseURL, accessToken } = this.props; - - this.subscription = createStream(streamingAPIBaseURL, accessToken, `hashtag&tag=${id}`, { - - received (data) { - switch(data.event) { - case 'update': - dispatch(updateTimeline(`hashtag:${id}`, JSON.parse(data.payload))); - break; - case 'delete': - dispatch(deleteFromTimelines(data.payload)); - break; - } - }, - - }); + this.disconnect = dispatch(connectHashtagStream(id)); } _unsubscribe () { - if (typeof this.subscription !== 'undefined') { - this.subscription.close(); - this.subscription = null; + if (this.disconnect) { + this.disconnect(); + this.disconnect = null; } } diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js index 7d521e4b6..b52c3c934 100644 --- a/app/javascript/mastodon/features/notifications/components/notification.js +++ b/app/javascript/mastodon/features/notifications/components/notification.js @@ -2,6 +2,7 @@ // SEE INSTEAD : glitch/components/notification import React from 'react'; +import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import StatusContainer from '../../../containers/status_container'; import AccountContainer from '../../../containers/account_container'; @@ -13,6 +14,7 @@ export default class Notification extends ImmutablePureComponent { static propTypes = { notification: ImmutablePropTypes.map.isRequired, + hidden: PropTypes.bool, }; renderFollow (account, link) { @@ -26,13 +28,13 @@ export default class Notification extends ImmutablePureComponent { <FormattedMessage id='notification.follow' defaultMessage='{name} followed you' values={{ name: link }} /> </div> - <AccountContainer id={account.get('id')} withNote={false} /> + <AccountContainer id={account.get('id')} withNote={false} hidden={this.props.hidden} /> </div> ); } renderMention (notification) { - return <StatusContainer id={notification.get('status')} withDismiss />; + return <StatusContainer id={notification.get('status')} withDismiss hidden={this.props.hidden} />; } renderFavourite (notification, link) { @@ -45,7 +47,7 @@ export default class Notification extends ImmutablePureComponent { <FormattedMessage id='notification.favourite' defaultMessage='{name} favourited your status' values={{ name: link }} /> </div> - <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss /> + <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={!!this.props.hidden} /> </div> ); } @@ -60,7 +62,7 @@ export default class Notification extends ImmutablePureComponent { <FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} /> </div> - <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss /> + <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={this.props.hidden} /> </div> ); } diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js index 0d86d41ce..54e58dfd0 100644 --- a/app/javascript/mastodon/features/notifications/index.js +++ b/app/javascript/mastodon/features/notifications/index.js @@ -16,8 +16,8 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ColumnSettingsContainer from './containers/column_settings_container'; import { createSelector } from 'reselect'; import { List as ImmutableList } from 'immutable'; -import LoadMore from '../../components/load_more'; import { debounce } from 'lodash'; +import ScrollableList from '../../components/scrollable_list'; const messages = defineMessages({ title: { id: 'column.notifications', defaultMessage: 'Notifications' }, @@ -68,40 +68,18 @@ export default class Notifications extends React.PureComponent { trackScroll: true, }; - dispatchExpandNotifications = debounce(() => { + handleScrollToBottom = debounce(() => { + this.props.dispatch(scrollTopNotifications(false)); this.props.dispatch(expandNotifications()); }, 300, { leading: true }); - dispatchScrollToTop = debounce((top) => { - this.props.dispatch(scrollTopNotifications(top)); + handleScrollToTop = debounce(() => { + this.props.dispatch(scrollTopNotifications(true)); }, 100); - handleScroll = (e) => { - const { scrollTop, scrollHeight, clientHeight } = e.target; - const offset = scrollHeight - scrollTop - clientHeight; - this._oldScrollPosition = scrollHeight - scrollTop; - - if (250 > offset && this.props.hasMore && !this.props.isLoading) { - this.dispatchExpandNotifications(); - } - - if (scrollTop < 100) { - this.dispatchScrollToTop(true); - } else { - this.dispatchScrollToTop(false); - } - } - - componentDidUpdate (prevProps) { - if (this.node.scrollTop > 0 && (prevProps.notifications.size < this.props.notifications.size && prevProps.notifications.first() !== this.props.notifications.first() && !!this._oldScrollPosition)) { - this.node.scrollTop = this.node.scrollHeight - this._oldScrollPosition; - } - } - - handleLoadMore = (e) => { - e.preventDefault(); - this.dispatchExpandNotifications(); - } + handleScroll = debounce(() => { + this.props.dispatch(scrollTopNotifications(false)); + }, 100); handlePin = () => { const { columnId, dispatch } = this.props; @@ -122,10 +100,6 @@ export default class Notifications extends React.PureComponent { this.column.scrollTop(); } - setRef = (c) => { - this.node = c; - } - setColumnRef = c => { this.column = c; } @@ -133,52 +107,35 @@ export default class Notifications extends React.PureComponent { render () { const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore } = this.props; const pinned = !!columnId; + const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />; - let loadMore = ''; - let scrollableArea = ''; - let unread = ''; - let scrollContainer = ''; - - if (!isLoading && hasMore) { - loadMore = <LoadMore onClick={this.handleLoadMore} />; - } - - if (isUnread) { - unread = <div className='notifications__unread-indicator' />; - } + let scrollableContent = null; - if (isLoading && this.scrollableArea) { - scrollableArea = this.scrollableArea; + if (isLoading && this.scrollableContent) { + scrollableContent = this.scrollableContent; } else if (notifications.size > 0 || hasMore) { - scrollableArea = ( - <div className='scrollable' onScroll={this.handleScroll} ref={this.setRef}> - {unread} - - <div> - {notifications.map(item => <NotificationContainer key={item.get('id')} notification={item} accountId={item.get('account')} />)} - {loadMore} - </div> - </div> - ); + scrollableContent = notifications.map((item) => <NotificationContainer key={item.get('id')} notification={item} accountId={item.get('account')} />); } else { - scrollableArea = ( - <div className='empty-column-indicator' ref={this.setRef}> - <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." /> - </div> - ); + scrollableContent = null; } - if (pinned) { - scrollContainer = scrollableArea; - } else { - scrollContainer = ( - <ScrollContainer scrollKey={`notifications-${columnId}`} shouldUpdateScroll={shouldUpdateScroll}> - {scrollableArea} - </ScrollContainer> - ); - } - - this.scrollableArea = scrollableArea; + this.scrollableContent = scrollableContent; + + const scrollContainer = ( + <ScrollableList + scrollKey={`notifications-${columnId}`} + trackScroll={!pinned} + isLoading={isLoading} + hasMore={hasMore} + emptyMessage={emptyMessage} + onScrollToBottom={this.handleScrollToBottom} + onScrollToTop={this.handleScrollToTop} + onScroll={this.handleScroll} + shouldUpdateScroll={shouldUpdateScroll} + > + {scrollableContent} + </ScrollableList> + ); return ( <Column diff --git a/app/javascript/mastodon/features/public_timeline/index.js b/app/javascript/mastodon/features/public_timeline/index.js index c6cad02d6..193489c63 100644 --- a/app/javascript/mastodon/features/public_timeline/index.js +++ b/app/javascript/mastodon/features/public_timeline/index.js @@ -7,15 +7,11 @@ import ColumnHeader from '../../components/column_header'; import { refreshPublicTimeline, expandPublicTimeline, - updateTimeline, - deleteFromTimelines, - connectTimeline, - disconnectTimeline, } from '../../actions/timelines'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ColumnSettingsContainer from './containers/column_settings_container'; -import createStream from '../../stream'; +import { connectPublicStream } from '../../actions/streaming'; const messages = defineMessages({ title: { id: 'column.public', defaultMessage: 'Federated timeline' }, @@ -23,8 +19,6 @@ const messages = defineMessages({ const mapStateToProps = state => ({ hasUnread: state.getIn(['timelines', 'public', 'unread']) > 0, - streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']), - accessToken: state.getIn(['meta', 'access_token']), }); @connect(mapStateToProps) @@ -36,8 +30,6 @@ export default class PublicTimeline extends React.PureComponent { intl: PropTypes.object.isRequired, columnId: PropTypes.string, multiColumn: PropTypes.bool, - streamingAPIBaseURL: PropTypes.string.isRequired, - accessToken: PropTypes.string.isRequired, hasUnread: PropTypes.bool, }; @@ -61,46 +53,16 @@ export default class PublicTimeline extends React.PureComponent { } componentDidMount () { - const { dispatch, streamingAPIBaseURL, accessToken } = this.props; + const { dispatch } = this.props; dispatch(refreshPublicTimeline()); - - if (typeof this._subscription !== 'undefined') { - return; - } - - this._subscription = createStream(streamingAPIBaseURL, accessToken, 'public', { - - connected () { - dispatch(connectTimeline('public')); - }, - - reconnected () { - dispatch(connectTimeline('public')); - }, - - disconnected () { - dispatch(disconnectTimeline('public')); - }, - - received (data) { - switch(data.event) { - case 'update': - dispatch(updateTimeline('public', JSON.parse(data.payload))); - break; - case 'delete': - dispatch(deleteFromTimelines(data.payload)); - break; - } - }, - - }); + this.disconnect = dispatch(connectPublicStream()); } componentWillUnmount () { - if (typeof this._subscription !== 'undefined') { - this._subscription.close(); - this._subscription = null; + if (this.disconnect) { + this.disconnect(); + this.disconnect = null; } } diff --git a/app/javascript/mastodon/features/standalone/compose/index.js b/app/javascript/mastodon/features/standalone/compose/index.js new file mode 100644 index 000000000..96d07fefb --- /dev/null +++ b/app/javascript/mastodon/features/standalone/compose/index.js @@ -0,0 +1,18 @@ +import React from 'react'; +import ComposeFormContainer from '../../compose/containers/compose_form_container'; +import NotificationsContainer from '../../ui/containers/notifications_container'; +import LoadingBarContainer from '../../ui/containers/loading_bar_container'; + +export default class Compose extends React.PureComponent { + + render () { + return ( + <div> + <ComposeFormContainer /> + <NotificationsContainer /> + <LoadingBarContainer className='loading-bar' /> + </div> + ); + } + +} diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js index a2885adda..4be013be7 100644 --- a/app/javascript/mastodon/features/status/components/action_bar.js +++ b/app/javascript/mastodon/features/status/components/action_bar.js @@ -14,6 +14,9 @@ const messages = defineMessages({ favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, report: { id: 'status.report', defaultMessage: 'Report @{name}' }, share: { id: 'status.share', defaultMessage: 'Share' }, + pin: { id: 'status.pin', defaultMessage: 'Pin on profile' }, + unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' }, + embed: { id: 'status.embed', defaultMessage: 'Embed' }, }); @injectIntl @@ -31,6 +34,8 @@ export default class ActionBar extends React.PureComponent { onDelete: PropTypes.func.isRequired, onMention: PropTypes.func.isRequired, onReport: PropTypes.func, + onPin: PropTypes.func, + onEmbed: PropTypes.func, me: PropTypes.number.isRequired, intl: PropTypes.object.isRequired, }; @@ -59,6 +64,10 @@ export default class ActionBar extends React.PureComponent { this.props.onReport(this.props.status); } + handlePinClick = () => { + this.props.onPin(this.props.status); + } + handleShare = () => { navigator.share({ text: this.props.status.get('search_index'), @@ -66,12 +75,26 @@ export default class ActionBar extends React.PureComponent { }); } + handleEmbed = () => { + this.props.onEmbed(this.props.status); + } + render () { const { status, me, intl } = this.props; + const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); + let menu = []; + if (publicStatus) { + menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed }); + } + if (me === status.getIn(['account', 'id'])) { + if (publicStatus) { + menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick }); + } + menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); } else { menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); diff --git a/app/javascript/mastodon/features/status/components/card.js b/app/javascript/mastodon/features/status/components/card.js index bfb40468b..6b13e15cc 100644 --- a/app/javascript/mastodon/features/status/components/card.js +++ b/app/javascript/mastodon/features/status/components/card.js @@ -1,6 +1,7 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import punycode from 'punycode'; +import classnames from 'classnames'; const IDNA_PREFIX = 'xn--'; @@ -32,7 +33,7 @@ export default class Card extends React.PureComponent { if (card.get('image')) { image = ( <div className='status-card__image'> - <img src={card.get('image')} alt={card.get('title')} className='status-card__image-image' /> + <img src={card.get('image')} alt={card.get('title')} className='status-card__image-image' width={card.get('width')} height={card.get('height')} /> </div> ); } @@ -41,8 +42,12 @@ export default class Card extends React.PureComponent { provider = decodeIDNA(getHostname(card.get('url'))); } + const className = classnames('status-card', { + 'horizontal': card.get('width') > card.get('height'), + }); + return ( - <a href={card.get('url')} className='status-card' target='_blank' rel='noopener'> + <a href={card.get('url')} className={className} target='_blank' rel='noopener'> {image} <div className='status-card__content'> diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js index d774dfdfe..03010cf0a 100644 --- a/app/javascript/mastodon/features/status/index.js +++ b/app/javascript/mastodon/features/status/index.js @@ -12,6 +12,8 @@ import { unfavourite, reblog, unreblog, + pin, + unpin, } from '../../actions/interactions'; import { replyCompose, @@ -89,6 +91,14 @@ export default class Status extends ImmutablePureComponent { } } + handlePin = (status) => { + if (status.get('pinned')) { + this.props.dispatch(unpin(status)); + } else { + this.props.dispatch(pin(status)); + } + } + handleReplyClick = (status) => { this.props.dispatch(replyCompose(status, this.context.router.history)); } @@ -139,6 +149,10 @@ export default class Status extends ImmutablePureComponent { this.props.dispatch(initReport(status.get('account'), status)); } + handleEmbed = (status) => { + this.props.dispatch(openModal('EMBED', { url: status.get('url') })); + } + renderChildren (list) { return list.map(id => <StatusContainer key={id} id={id} />); } @@ -190,6 +204,8 @@ export default class Status extends ImmutablePureComponent { onDelete={this.handleDeleteClick} onMention={this.handleMentionClick} onReport={this.handleReport} + onPin={this.handlePin} + onEmbed={this.handleEmbed} /> {descendants} diff --git a/app/javascript/mastodon/features/ui/components/column.js b/app/javascript/mastodon/features/ui/components/column.js index 9031c16fc..15538ea38 100644 --- a/app/javascript/mastodon/features/ui/components/column.js +++ b/app/javascript/mastodon/features/ui/components/column.js @@ -25,6 +25,17 @@ export default class Column extends React.PureComponent { this._interruptScrollAnimation = scrollTop(scrollable); } + scrollTop () { + const scrollable = this.node.querySelector('.scrollable'); + + if (!scrollable) { + return; + } + + this._interruptScrollAnimation = scrollTop(scrollable); + } + + handleScroll = debounce(() => { if (typeof this._interruptScrollAnimation !== 'undefined') { this._interruptScrollAnimation(); diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js index 47d5a2e20..7d84bece7 100644 --- a/app/javascript/mastodon/features/ui/components/columns_area.js +++ b/app/javascript/mastodon/features/ui/components/columns_area.js @@ -12,6 +12,7 @@ import ColumnLoading from './column_loading'; import BundleColumnError from './bundle_column_error'; import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, FavouritedStatuses } from '../../ui/util/async-components'; +import detectPassiveEvents from 'detect-passive-events'; import { scrollRight } from '../../../scroll'; const componentMap = { @@ -24,7 +25,7 @@ const componentMap = { 'FAVOURITES': FavouritedStatuses, }; -@injectIntl +@component => injectIntl(component, { withRef: true }) export default class ColumnsArea extends ImmutablePureComponent { static contextTypes = { @@ -47,16 +48,36 @@ export default class ColumnsArea extends ImmutablePureComponent { } componentDidMount() { + if (!this.props.singleColumn) { + this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false); + } this.lastIndex = getIndex(this.context.router.history.location.pathname); this.setState({ shouldAnimate: true }); } + componentWillUpdate(nextProps) { + if (this.props.singleColumn !== nextProps.singleColumn && nextProps.singleColumn) { + this.node.removeEventListener('wheel', this.handleWheel); + } + } + componentDidUpdate(prevProps) { + if (this.props.singleColumn !== prevProps.singleColumn && !this.props.singleColumn) { + this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false); + } this.lastIndex = getIndex(this.context.router.history.location.pathname); this.setState({ shouldAnimate: true }); + } - if (this.props.children !== prevProps.children && !this.props.singleColumn) { - scrollRight(this.node); + componentWillUnmount () { + if (!this.props.singleColumn) { + this.node.removeEventListener('wheel', this.handleWheel); + } + } + + handleChildrenContentChange() { + if (!this.props.singleColumn) { + scrollRight(this.node, this.node.scrollWidth - window.innerWidth); } } @@ -80,6 +101,14 @@ export default class ColumnsArea extends ImmutablePureComponent { } } + handleWheel = () => { + if (typeof this._interruptScrollAnimation !== 'function') { + return; + } + + this._interruptScrollAnimation(); + } + setRef = (node) => { this.node = node; } diff --git a/app/javascript/mastodon/features/ui/components/embed_modal.js b/app/javascript/mastodon/features/ui/components/embed_modal.js new file mode 100644 index 000000000..992aed8a3 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/embed_modal.js @@ -0,0 +1,84 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { FormattedMessage, injectIntl } from 'react-intl'; +import axios from 'axios'; + +@injectIntl +export default class EmbedModal extends ImmutablePureComponent { + + static propTypes = { + url: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + } + + state = { + loading: false, + oembed: null, + }; + + componentDidMount () { + const { url } = this.props; + + this.setState({ loading: true }); + + axios.post('/api/web/embed', { url }).then(res => { + this.setState({ loading: false, oembed: res.data }); + + const iframeDocument = this.iframe.contentWindow.document; + + iframeDocument.open(); + iframeDocument.write(res.data.html); + iframeDocument.close(); + + iframeDocument.body.style.margin = 0; + this.iframe.height = iframeDocument.body.scrollHeight + 'px'; + }); + } + + setIframeRef = c => { + this.iframe = c; + } + + handleTextareaClick = (e) => { + e.target.select(); + } + + render () { + const { oembed } = this.state; + + return ( + <div className='modal-root__modal embed-modal'> + <h4><FormattedMessage id='status.embed' defaultMessage='Embed' /></h4> + + <div className='embed-modal__container'> + <p className='hint'> + <FormattedMessage id='embed.instructions' defaultMessage='Embed this status on your website by copying the code below.' /> + </p> + + <input + type='text' + className='embed-modal__html' + readOnly + value={oembed && oembed.html || ''} + onClick={this.handleTextareaClick} + /> + + <p className='hint'> + <FormattedMessage id='embed.preview' defaultMessage='Here is what it will look like:' /> + </p> + + <iframe + className='embed-modal__iframe' + scrolling='no' + frameBorder='0' + ref={this.setIframeRef} + title='preview' + /> + </div> + </div> + ); + } + +} diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js index d316ff433..cd605d7b2 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.js +++ b/app/javascript/mastodon/features/ui/components/modal_root.js @@ -14,6 +14,7 @@ import { ConfirmationModal, ReportModal, SettingsModal, + EmbedModal, } from '../../../features/ui/util/async-components'; const MODAL_COMPONENTS = { @@ -25,6 +26,7 @@ const MODAL_COMPONENTS = { 'REPORT': ReportModal, 'SETTINGS': SettingsModal, 'ACTIONS': () => Promise.resolve({ default: ActionsModal }), + 'EMBED': EmbedModal, }; export default class ModalRoot extends React.PureComponent { diff --git a/app/javascript/mastodon/features/ui/components/onboarding_modal.js b/app/javascript/mastodon/features/ui/components/onboarding_modal.js index 0c872f40d..2facf9c44 100644 --- a/app/javascript/mastodon/features/ui/components/onboarding_modal.js +++ b/app/javascript/mastodon/features/ui/components/onboarding_modal.js @@ -30,7 +30,7 @@ const PageOne = ({ acct, domain }) => ( <div> <h1><FormattedMessage id='onboarding.page_one.welcome' defaultMessage='Welcome to {domain}!' values={{ domain }} /></h1> <p><FormattedMessage id='onboarding.page_one.federation' defaultMessage='{domain} is an "instance" of Mastodon. Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.' values={{ domain }} /></p> - <p><FormattedMessage id='onboarding.page_one.handle' defaultMessage='You are on {domain}, so your full handle is {handle}' values={{ domain, handle: <strong>{acct}@{domain}</strong> }} /></p> + <p><FormattedMessage id='onboarding.page_one.handle' defaultMessage='You are on {domain}, so your full handle is {handle}' values={{ domain, handle: <strong>@{acct}@{domain}</strong> }} /></p> </div> </div> ); diff --git a/app/javascript/mastodon/features/ui/containers/columns_area_container.js b/app/javascript/mastodon/features/ui/containers/columns_area_container.js index 6420f0784..95f95618b 100644 --- a/app/javascript/mastodon/features/ui/containers/columns_area_container.js +++ b/app/javascript/mastodon/features/ui/containers/columns_area_container.js @@ -5,4 +5,4 @@ const mapStateToProps = state => ({ columns: state.getIn(['settings', 'columns']), }); -export default connect(mapStateToProps)(ColumnsArea); +export default connect(mapStateToProps, null, null, { withRef: true })(ColumnsArea); diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index 6d53f474d..a14547ed8 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -1,12 +1,11 @@ import React from 'react'; -import classNames from 'classnames'; -import Redirect from 'react-router-dom/Redirect'; import NotificationsContainer from './containers/notifications_container'; import PropTypes from 'prop-types'; import LoadingBarContainer from './containers/loading_bar_container'; import TabsBar from './components/tabs_bar'; import ModalContainer from './containers/modal_container'; import { connect } from 'react-redux'; +import { Redirect, withRouter } from 'react-router-dom'; import { isMobile } from '../../is_mobile'; import { debounce } from 'lodash'; import { uploadCompose } from '../../actions/compose'; @@ -16,6 +15,7 @@ import { clearStatusesHeight } from '../../actions/statuses'; import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers'; import UploadArea from './components/upload_area'; import ColumnsAreaContainer from './containers/columns_area_container'; +import classNames from 'classnames'; import { Compose, Status, @@ -51,6 +51,7 @@ const mapStateToProps = state => ({ }); @connect(mapStateToProps) +@withRouter export default class UI extends React.PureComponent { static contextTypes = { @@ -65,6 +66,7 @@ export default class UI extends React.PureComponent { systemFontUi: PropTypes.bool, navbarUnder: PropTypes.bool, isComposing: PropTypes.bool, + location: PropTypes.object, }; state = { @@ -141,7 +143,7 @@ export default class UI extends React.PureComponent { if (data.type === 'navigate') { this.context.router.history.push(data.path); } else { - console.warn('Unknown message type:', data.type); // eslint-disable-line no-console + console.warn('Unknown message type:', data.type); } } @@ -175,6 +177,12 @@ export default class UI extends React.PureComponent { return true; } + componentDidUpdate (prevProps) { + if (![this.props.location.pathname, '/'].includes(prevProps.location.pathname)) { + this.columnsAreaNode.handleChildrenContentChange(); + } + } + componentWillUnmount () { window.removeEventListener('resize', this.handleResize); document.removeEventListener('dragenter', this.handleDragEnter); @@ -188,6 +196,10 @@ export default class UI extends React.PureComponent { this.node = c; } + setColumnsAreaRef = (c) => { + this.columnsAreaNode = c.getWrappedInstance().getWrappedInstance(); + } + render () { const { width, draggingOver } = this.state; const { children, layout, isWide, navbarUnder } = this.props; @@ -212,7 +224,7 @@ export default class UI extends React.PureComponent { return ( <div className={className} ref={this.setRef}> {navbarUnder ? null : (<TabsBar />)} - <ColumnsAreaContainer singleColumn={isMobile(width, layout)}> + <ColumnsAreaContainer ref={this.setColumnsAreaRef} singleColumn={isMobile(width, layout)}> <WrappedSwitch> <Redirect from='/' to='/getting-started' exact /> <WrappedRoute path='/getting-started' component={GettingStarted} content={children} /> diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index 9267519dd..108ffc142 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -116,3 +116,7 @@ export function MediaGallery () { export function VideoPlayer () { return import(/* webpackChunkName: "status/video_player" */'../../../components/video_player'); } + +export function EmbedModal () { + return import(/* webpackChunkName: "modals/embed_modal" */'../components/embed_modal'); +} |