diff options
Diffstat (limited to 'app/javascript')
35 files changed, 684 insertions, 156 deletions
diff --git a/app/javascript/flavours/glitch/components/media_gallery.js b/app/javascript/flavours/glitch/components/media_gallery.js index 194800d52..6ef101f11 100644 --- a/app/javascript/flavours/glitch/components/media_gallery.js +++ b/app/javascript/flavours/glitch/components/media_gallery.js @@ -257,7 +257,6 @@ export default class MediaGallery extends React.PureComponent { static propTypes = { sensitive: PropTypes.bool, - revealed: PropTypes.bool, standalone: PropTypes.bool, letterbox: PropTypes.bool, fullwidth: PropTypes.bool, @@ -268,6 +267,8 @@ export default class MediaGallery extends React.PureComponent { intl: PropTypes.object.isRequired, defaultWidth: PropTypes.number, cacheWidth: PropTypes.func, + visible: PropTypes.bool, + onToggleVisibility: PropTypes.func, }; static defaultProps = { @@ -275,13 +276,15 @@ export default class MediaGallery extends React.PureComponent { }; state = { - visible: this.props.revealed === undefined ? (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all') : this.props.revealed, + visible: this.props.visible !== undefined ? this.props.visible : (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all'), width: this.props.defaultWidth, }; componentWillReceiveProps (nextProps) { - if (!is(nextProps.media, this.props.media) || nextProps.revealed === true) { - this.setState({ visible: nextProps.revealed === undefined ? (displayMedia !== 'hide_all' && !nextProps.sensitive || displayMedia === 'show_all') : nextProps.revealed }); + if (!is(nextProps.media, this.props.media) && nextProps.visible === undefined) { + this.setState({ visible: displayMedia !== 'hide_all' && !nextProps.sensitive || displayMedia === 'show_all' }); + } else if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) { + this.setState({ visible: nextProps.visible }); } } @@ -294,7 +297,11 @@ export default class MediaGallery extends React.PureComponent { } handleOpen = () => { - this.setState({ visible: !this.state.visible }); + if (this.props.onToggleVisibility) { + this.props.onToggleVisibility(); + } else { + this.setState({ visible: !this.state.visible }); + } } handleClick = (index) => { diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js index 5f10e0c52..7014cab17 100644 --- a/app/javascript/flavours/glitch/components/status.js +++ b/app/javascript/flavours/glitch/components/status.js @@ -16,6 +16,7 @@ import NotificationOverlayContainer from 'flavours/glitch/features/notifications import classNames from 'classnames'; import { autoUnfoldCW } from 'flavours/glitch/util/content_warning'; import PollContainer from 'flavours/glitch/containers/poll_container'; +import { displayMedia } from 'flavours/glitch/util/initial_state'; // We use the component (and not the container) since we do not want // to use the progress bar to show download progress @@ -38,6 +39,22 @@ export const textForScreenReader = (intl, status, rebloggedByText = false, expan return values.join(', '); }; +export const defaultMediaVisibility = (status, settings) => { + if (!status) { + return undefined; + } + + if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { + status = status.get('reblog'); + } + + if (settings.getIn(['media', 'reveal_behind_cw']) && !!status.get('spoiler_text')) { + return true; + } + + return (displayMedia !== 'hide_all' && !status.get('sensitive') || displayMedia === 'show_all'); +} + @injectIntl export default class Status extends ImmutablePureComponent { @@ -82,6 +99,9 @@ export default class Status extends ImmutablePureComponent { isCollapsed: false, autoCollapsed: false, isExpanded: undefined, + showMedia: undefined, + statusId: undefined, + revealBehindCW: undefined, } // Avoid checking props that are functions (and whose equality will always @@ -103,6 +123,7 @@ export default class Status extends ImmutablePureComponent { updateOnStates = [ 'isExpanded', 'isCollapsed', + 'showMedia', ] // If our settings have changed to disable collapsed statuses, then we @@ -160,6 +181,20 @@ export default class Status extends ImmutablePureComponent { } } + if (nextProps.status && nextProps.status.get('id') !== prevState.statusId) { + update.showMedia = defaultMediaVisibility(nextProps.status, nextProps.settings); + update.statusId = nextProps.status.get('id'); + updated = true; + } + + if (nextProps.settings.getIn(['media', 'reveal_behind_cw']) !== prevState.revealBehindCW) { + update.revealBehindCW = nextProps.settings.getIn(['media', 'reveal_behind_cw']); + if (update.revealBehindCW) { + update.showMedia = defaultMediaVisibility(nextProps.status, nextProps.settings); + } + updated = true; + } + return updated ? update : null; } @@ -305,6 +340,10 @@ export default class Status extends ImmutablePureComponent { } } + handleToggleMediaVisibility = () => { + this.setState({ showMedia: !this.state.showMedia }); + } + handleAccountClick = (e) => { if (this.context.router && e.button === 0) { const id = e.currentTarget.getAttribute('data-id'); @@ -374,6 +413,9 @@ export default class Status extends ImmutablePureComponent { this.setCollapsed(!this.state.isCollapsed); } + handleHotkeyToggleSensitive = () => { + this.handleToggleMediaVisibility(); + } handleRef = c => { this.node = c; @@ -490,7 +532,8 @@ export default class Status extends ImmutablePureComponent { onOpenVideo={this.handleOpenVideo} width={this.props.cachedMediaWidth} cacheWidth={this.props.cacheMediaWidth} - revealed={settings.getIn(['media', 'reveal_behind_cw']) && !!status.get('spoiler_text') ? true : undefined} + visible={this.state.showMedia} + onToggleVisibility={this.handleToggleMediaVisibility} />)} </Bundle> ); @@ -508,7 +551,8 @@ export default class Status extends ImmutablePureComponent { onOpenMedia={this.props.onOpenMedia} cacheWidth={this.props.cacheMediaWidth} defaultWidth={this.props.cachedMediaWidth} - revealed={settings.getIn(['media', 'reveal_behind_cw']) && !!status.get('spoiler_text') ? true : undefined} + visible={this.state.showMedia} + onToggleVisibility={this.handleToggleMediaVisibility} /> )} </Bundle> @@ -566,6 +610,7 @@ export default class Status extends ImmutablePureComponent { toggleSpoiler: this.handleExpandedToggle, bookmark: this.handleHotkeyBookmark, toggleCollapse: this.handleHotkeyCollapse, + toggleSensitive: this.handleHotkeyToggleSensitive, }; const computedClass = classNames('status', `status-${status.get('visibility')}`, { diff --git a/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.js b/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.js index 2935a6021..f7b475f8d 100644 --- a/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.js +++ b/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.js @@ -71,6 +71,10 @@ export default class KeyboardShortcuts extends ImmutablePureComponent { <td><kbd>x</kbd></td> <td><FormattedMessage id='keyboard_shortcuts.toggle_hidden' defaultMessage='to show/hide text behind CW' /></td> </tr> + <tr> + <td><kbd>h</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.toggle_sensitivity' defaultMessage='to show/hide media' /></td> + </tr> {collapseEnabled && ( <tr> <td><kbd>shift</kbd>+<kbd>x</kbd></td> diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.js b/app/javascript/flavours/glitch/features/status/components/detailed_status.js index 03d98fde8..ddedac4d4 100644 --- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js +++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js @@ -33,6 +33,8 @@ export default class DetailedStatus extends ImmutablePureComponent { onHeightChange: PropTypes.func, domain: PropTypes.string.isRequired, compact: PropTypes.bool, + showMedia: PropTypes.bool, + onToggleMediaVisibility: PropTypes.func, }; state = { @@ -144,7 +146,8 @@ export default class DetailedStatus extends ImmutablePureComponent { preventPlayback={!expanded} onOpenVideo={this.handleOpenVideo} autoplay - revealed={settings.getIn(['media', 'reveal_behind_cw']) && !!status.get('spoiler_text') ? true : undefined} + visible={this.props.showMedia} + onToggleVisibility={this.props.onToggleMediaVisibility} /> ); mediaIcon = 'video-camera'; @@ -158,7 +161,8 @@ export default class DetailedStatus extends ImmutablePureComponent { fullwidth={settings.getIn(['media', 'fullwidth'])} hidden={!expanded} onOpenMedia={this.props.onOpenMedia} - revealed={settings.getIn(['media', 'reveal_behind_cw']) && !!status.get('spoiler_text') ? true : undefined} + visible={this.props.showMedia} + onToggleVisibility={this.props.onToggleMediaVisibility} /> ); mediaIcon = 'picture-o'; diff --git a/app/javascript/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js index 57d70db1a..145a33fff 100644 --- a/app/javascript/flavours/glitch/features/status/index.js +++ b/app/javascript/flavours/glitch/features/status/index.js @@ -41,7 +41,7 @@ import { HotKeys } from 'react-hotkeys'; import { boostModal, favouriteModal, deleteModal } from 'flavours/glitch/util/initial_state'; import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from 'flavours/glitch/util/fullscreen'; import { autoUnfoldCW } from 'flavours/glitch/util/content_warning'; -import { textForScreenReader } from 'flavours/glitch/components/status'; +import { textForScreenReader, defaultMediaVisibility } from 'flavours/glitch/components/status'; const messages = defineMessages({ deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, @@ -134,6 +134,9 @@ export default class Status extends ImmutablePureComponent { isExpanded: undefined, threadExpanded: undefined, statusId: undefined, + loadedStatusId: undefined, + showMedia: undefined, + revealBehindCW: undefined, }; componentDidMount () { @@ -152,17 +155,31 @@ export default class Status extends ImmutablePureComponent { } static getDerivedStateFromProps(props, state) { - if (state.statusId === props.params.statusId || !props.params.statusId) { - return null; + let update = {}; + let updated = false; + + if (props.params.statusId && state.statusId !== props.params.statusId) { + props.dispatch(fetchStatus(props.params.statusId)); + update.threadExpanded = undefined; + update.statusId = props.params.statusId; + updated = true; } - props.dispatch(fetchStatus(props.params.statusId)); + const revealBehindCW = props.settings.getIn(['media', 'reveal_behind_cw']); + if (revealBehindCW !== state.revealBehindCW) { + update.revealBehindCW = revealBehindCW; + if (revealBehindCW) update.showMedia = defaultMediaVisibility(props.status, props.settings); + updated = true; + } - return { - threadExpanded: undefined, - isExpanded: autoUnfoldCW(props.settings, props.status), - statusId: props.params.statusId, - }; + if (props.status && state.loadedStatusId !== props.status.get('id')) { + update.showMedia = defaultMediaVisibility(props.status, props.settings); + update.loadedStatusId = props.status.get('id'); + update.isExpanded = autoUnfoldCW(props.settings, props.status); + updated = true; + } + + return updated ? update : null; } handleExpandedToggle = () => { @@ -171,6 +188,10 @@ export default class Status extends ImmutablePureComponent { } }; + handleToggleMediaVisibility = () => { + this.setState({ showMedia: !this.state.showMedia }); + } + handleModalFavourite = (status) => { this.props.dispatch(favourite(status)); } @@ -304,6 +325,10 @@ export default class Status extends ImmutablePureComponent { this.props.dispatch(openModal('EMBED', { url: status.get('url') })); } + handleHotkeyToggleSensitive = () => { + this.handleToggleMediaVisibility(); + } + handleHotkeyMoveUp = () => { this.handleMoveUp(this.props.status.get('id')); } @@ -477,6 +502,7 @@ export default class Status extends ImmutablePureComponent { mention: this.handleHotkeyMention, openProfile: this.handleHotkeyOpenProfile, toggleSpoiler: this.handleExpandedToggle, + toggleSensitive: this.handleHotkeyToggleSensitive, }; return ( @@ -505,6 +531,8 @@ export default class Status extends ImmutablePureComponent { expanded={isExpanded} onToggleHidden={this.handleExpandedToggle} domain={domain} + showMedia={this.state.showMedia} + onToggleMediaVisibility={this.handleToggleMediaVisibility} /> <ActionBar diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js index 13c71337a..f8fff934d 100644 --- a/app/javascript/flavours/glitch/features/ui/index.js +++ b/app/javascript/flavours/glitch/features/ui/index.js @@ -101,6 +101,7 @@ const keyMap = { toggleSpoiler: 'x', bookmark: 'd', toggleCollapse: 'shift+x', + toggleSensitive: 'h', }; @connect(mapStateToProps) diff --git a/app/javascript/flavours/glitch/features/video/index.js b/app/javascript/flavours/glitch/features/video/index.js index 2e0d59d47..b73ea0b07 100644 --- a/app/javascript/flavours/glitch/features/video/index.js +++ b/app/javascript/flavours/glitch/features/video/index.js @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import { fromJS } from 'immutable'; +import { fromJS, is } from 'immutable'; import { throttle } from 'lodash'; import classNames from 'classnames'; import { isFullscreen, requestFullscreen, exitFullscreen } from 'flavours/glitch/util/fullscreen'; @@ -94,7 +94,6 @@ export default class Video extends React.PureComponent { width: PropTypes.number, height: PropTypes.number, sensitive: PropTypes.bool, - revealed: PropTypes.bool, startTime: PropTypes.number, onOpenVideo: PropTypes.func, onCloseVideo: PropTypes.func, @@ -102,9 +101,11 @@ export default class Video extends React.PureComponent { fullwidth: PropTypes.bool, detailed: PropTypes.bool, inline: PropTypes.bool, - preventPlayback: PropTypes.bool, - intl: PropTypes.object.isRequired, cacheWidth: PropTypes.func, + intl: PropTypes.object.isRequired, + visible: PropTypes.bool, + onToggleVisibility: PropTypes.func, + preventPlayback: PropTypes.bool, blurhash: PropTypes.string, link: PropTypes.node, }; @@ -119,12 +120,12 @@ export default class Video extends React.PureComponent { fullscreen: false, hovered: false, muted: false, - revealed: this.props.revealed === undefined ? (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all') : this.props.revealed, + revealed: this.props.visible !== undefined ? this.props.visible : (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all'), }; componentWillReceiveProps (nextProps) { - if (nextProps.revealed === true) { - this.setState({ revealed: true }); + if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) { + this.setState({ revealed: nextProps.visible }); } } @@ -305,9 +306,6 @@ export default class Video extends React.PureComponent { if (this.video && this.state.revealed && this.props.preventPlayback && !prevProps.preventPlayback) { this.video.pause(); } - } - - componentDidUpdate (prevProps) { if (prevProps.blurhash !== this.props.blurhash && this.props.blurhash) { this._decode(); } @@ -349,7 +347,11 @@ export default class Video extends React.PureComponent { this.video.pause(); } - this.setState({ revealed: !this.state.revealed }); + if (this.props.onToggleVisibility) { + this.props.onToggleVisibility(); + } else { + this.setState({ revealed: !this.state.revealed }); + } } handleLoadedData = () => { diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 94062f2be..300fb48a9 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -63,6 +63,14 @@ const messages = defineMessages({ uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' }, }); +const COMPOSE_PANEL_BREAKPOINT = 600 + (285 * 1) + (10 * 1); + +export const ensureComposeIsVisible = (getState, routerHistory) => { + if (!getState().getIn(['compose', 'mounted']) && window.innerWidth < COMPOSE_PANEL_BREAKPOINT) { + routerHistory.push('/statuses/new'); + } +}; + export function changeCompose(text) { return { type: COMPOSE_CHANGE, @@ -77,9 +85,7 @@ export function replyCompose(status, routerHistory) { status: status, }); - if (!getState().getIn(['compose', 'mounted'])) { - routerHistory.push('/statuses/new'); - } + ensureComposeIsVisible(getState, routerHistory); }; }; @@ -102,9 +108,7 @@ export function mentionCompose(account, routerHistory) { account: account, }); - if (!getState().getIn(['compose', 'mounted'])) { - routerHistory.push('/statuses/new'); - } + ensureComposeIsVisible(getState, routerHistory); }; }; @@ -115,9 +119,7 @@ export function directCompose(account, routerHistory) { account: account, }); - if (!getState().getIn(['compose', 'mounted'])) { - routerHistory.push('/statuses/new'); - } + ensureComposeIsVisible(getState, routerHistory); }; }; diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js index 3916b9ac1..06a19afc3 100644 --- a/app/javascript/mastodon/actions/statuses.js +++ b/app/javascript/mastodon/actions/statuses.js @@ -4,6 +4,7 @@ import { evictStatus } from '../storage/modifier'; import { deleteFromTimelines } from './timelines'; import { importFetchedStatus, importFetchedStatuses, importAccount, importStatus } from './importer'; +import { ensureComposeIsVisible } from './compose'; export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST'; export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS'; @@ -139,7 +140,7 @@ export function redraft(status, raw_text) { }; }; -export function deleteStatus(id, router, withRedraft = false) { +export function deleteStatus(id, routerHistory, withRedraft = false) { return (dispatch, getState) => { let status = getState().getIn(['statuses', id]); @@ -156,10 +157,7 @@ export function deleteStatus(id, router, withRedraft = false) { if (withRedraft) { dispatch(redraft(status, response.data.text)); - - if (!getState().getIn(['compose', 'mounted'])) { - router.push('/statuses/new'); - } + ensureComposeIsVisible(getState, routerHistory); } }).catch(error => { dispatch(deleteStatusFail(id, error)); diff --git a/app/javascript/mastodon/components/autosuggest_input.js b/app/javascript/mastodon/components/autosuggest_input.js index 4b4aa8f0e..c7d965b53 100644 --- a/app/javascript/mastodon/components/autosuggest_input.js +++ b/app/javascript/mastodon/components/autosuggest_input.js @@ -49,7 +49,7 @@ export default class AutosuggestInput extends ImmutablePureComponent { autoFocus: PropTypes.bool, className: PropTypes.string, id: PropTypes.string, - searchTokens: ImmutablePropTypes.list, + searchTokens: PropTypes.arrayOf(PropTypes.string), maxLength: PropTypes.number, }; diff --git a/app/javascript/mastodon/components/icon_with_badge.js b/app/javascript/mastodon/components/icon_with_badge.js new file mode 100644 index 000000000..7851eb4be --- /dev/null +++ b/app/javascript/mastodon/components/icon_with_badge.js @@ -0,0 +1,20 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Icon from 'mastodon/components/icon'; + +const formatNumber = num => num > 40 ? '40+' : num; + +const IconWithBadge = ({ id, count, className }) => ( + <i className='icon-with-badge'> + <Icon id={id} fixedWidth className={className} /> + {count > 0 && <i className='icon-with-badge__badge'>{formatNumber(count)}</i>} + </i> +); + +IconWithBadge.propTypes = { + id: PropTypes.string.isRequired, + count: PropTypes.number.isRequired, + className: PropTypes.string, +}; + +export default IconWithBadge; diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js index abd17647e..56618462b 100644 --- a/app/javascript/mastodon/components/media_gallery.js +++ b/app/javascript/mastodon/components/media_gallery.js @@ -244,6 +244,8 @@ class MediaGallery extends React.PureComponent { intl: PropTypes.object.isRequired, defaultWidth: PropTypes.number, cacheWidth: PropTypes.func, + visible: PropTypes.bool, + onToggleVisibility: PropTypes.func, }; static defaultProps = { @@ -251,18 +253,24 @@ class MediaGallery extends React.PureComponent { }; state = { - visible: displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all', + visible: this.props.visible !== undefined ? this.props.visible : (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all'), width: this.props.defaultWidth, }; componentWillReceiveProps (nextProps) { - if (!is(nextProps.media, this.props.media)) { - this.setState({ visible: !nextProps.sensitive }); + if (!is(nextProps.media, this.props.media) && nextProps.visible === undefined) { + this.setState({ visible: displayMedia !== 'hide_all' && !nextProps.sensitive || displayMedia === 'show_all' }); + } else if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) { + this.setState({ visible: nextProps.visible }); } } handleOpen = () => { - this.setState({ visible: !this.state.visible }); + if (this.props.onToggleVisibility) { + this.props.onToggleVisibility(); + } else { + this.setState({ visible: !this.state.visible }); + } } handleClick = (index) => { diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index 6f66a4260..b67354b01 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -17,6 +17,8 @@ import { HotKeys } from 'react-hotkeys'; import classNames from 'classnames'; import Icon from 'mastodon/components/icon'; import PollContainer from 'mastodon/containers/poll_container'; +import { displayMedia } from '../initial_state'; +import { is } from 'immutable'; // We use the component (and not the container) since we do not want // to use the progress bar to show download progress @@ -39,6 +41,18 @@ export const textForScreenReader = (intl, status, rebloggedByText = false) => { return values.join(', '); }; +export const defaultMediaVisibility = (status) => { + if (!status) { + return undefined; + } + + if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { + status = status.get('reblog'); + } + + return (displayMedia !== 'hide_all' && !status.get('sensitive') || displayMedia === 'show_all'); +}; + export default @injectIntl class Status extends ImmutablePureComponent { @@ -85,6 +99,10 @@ class Status extends ImmutablePureComponent { 'hidden', ]; + state = { + showMedia: defaultMediaVisibility(this.props.status), + }; + // Track height changes we know about to compensate scrolling componentDidMount () { this.didShowCard = !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card'); @@ -98,11 +116,19 @@ class Status extends ImmutablePureComponent { } } + componentWillReceiveProps (nextProps) { + if (!is(nextProps.status, this.props.status) && nextProps.status) { + this.setState({ showMedia: defaultMediaVisibility(nextProps.status) }); + } + } + // Compensate height changes componentDidUpdate (prevProps, prevState, snapshot) { const doShowCard = !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card'); + if (doShowCard && !this.didShowCard) { this.didShowCard = true; + if (snapshot !== null && this.props.updateScrollBottom) { if (this.node && this.node.offsetTop < snapshot.top) { this.props.updateScrollBottom(snapshot.height - snapshot.top); @@ -122,6 +148,10 @@ class Status extends ImmutablePureComponent { } } + handleToggleMediaVisibility = () => { + this.setState({ showMedia: !this.state.showMedia }); + } + handleClick = () => { if (this.props.onClick) { this.props.onClick(); @@ -136,6 +166,17 @@ class Status extends ImmutablePureComponent { this.context.router.history.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`); } + handleExpandClick = (e) => { + if (e.button === 0) { + if (!this.context.router) { + return; + } + + const { status } = this.props; + this.context.router.history.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`); + } + } + handleAccountClick = (e) => { if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) { const id = e.currentTarget.getAttribute('data-id'); @@ -198,6 +239,10 @@ class Status extends ImmutablePureComponent { this.props.onToggleHidden(this._properStatus()); } + handleHotkeyToggleSensitive = () => { + this.handleToggleMediaVisibility(); + } + _properStatus () { const { status } = this.props; @@ -298,6 +343,8 @@ class Status extends ImmutablePureComponent { sensitive={status.get('sensitive')} onOpenVideo={this.handleOpenVideo} cacheWidth={this.props.cacheMediaWidth} + visible={this.state.showMedia} + onToggleVisibility={this.handleToggleMediaVisibility} /> )} </Bundle> @@ -313,6 +360,8 @@ class Status extends ImmutablePureComponent { onOpenMedia={this.props.onOpenMedia} cacheWidth={this.props.cacheMediaWidth} defaultWidth={this.props.cachedMediaWidth} + visible={this.state.showMedia} + onToggleVisibility={this.handleToggleMediaVisibility} /> )} </Bundle> @@ -348,6 +397,7 @@ class Status extends ImmutablePureComponent { moveUp: this.handleHotkeyMoveUp, moveDown: this.handleHotkeyMoveDown, toggleHidden: this.handleHotkeyToggleHidden, + toggleSensitive: this.handleHotkeyToggleSensitive, }; return ( @@ -356,7 +406,7 @@ class Status extends ImmutablePureComponent { {prepend} <div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), muted: this.props.muted, read: unread === false })} data-id={status.get('id')}> - <div className='status__expand' onClick={this.handleClick} role='presentation' /> + <div className='status__expand' onClick={this.handleExpandClick} role='presentation' /> <div className='status__info'> <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a> diff --git a/app/javascript/mastodon/features/compose/components/action_bar.js b/app/javascript/mastodon/features/compose/components/action_bar.js index 95d6eeb06..077226d70 100644 --- a/app/javascript/mastodon/features/compose/components/action_bar.js +++ b/app/javascript/mastodon/features/compose/components/action_bar.js @@ -46,7 +46,7 @@ class ActionBar extends React.PureComponent { return ( <div className='compose__action-bar'> <div className='compose__action-bar-dropdown'> - <DropdownMenuContainer items={menu} icon='ellipsis-v' size={24} direction='right' /> + <DropdownMenuContainer items={menu} icon='chevron-down' size={16} direction='right' /> </div> </div> ); diff --git a/app/javascript/mastodon/features/compose/components/navigation_bar.js b/app/javascript/mastodon/features/compose/components/navigation_bar.js index 9910eb4f9..d8d49cb95 100644 --- a/app/javascript/mastodon/features/compose/components/navigation_bar.js +++ b/app/javascript/mastodon/features/compose/components/navigation_bar.js @@ -20,7 +20,7 @@ export default class NavigationBar extends ImmutablePureComponent { <div className='navigation-bar'> <Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}> <span style={{ display: 'none' }}>{this.props.account.get('acct')}</span> - <Avatar account={this.props.account} size={40} /> + <Avatar account={this.props.account} size={48} /> </Permalink> <div className='navigation-bar__profile'> diff --git a/app/javascript/mastodon/features/compose/components/search.js b/app/javascript/mastodon/features/compose/components/search.js index 774658b1b..6833c43ef 100644 --- a/app/javascript/mastodon/features/compose/components/search.js +++ b/app/javascript/mastodon/features/compose/components/search.js @@ -47,6 +47,10 @@ class SearchPopout extends React.PureComponent { export default @injectIntl class Search extends React.PureComponent { + static contextTypes = { + router: PropTypes.object.isRequired, + }; + static propTypes = { value: PropTypes.string.isRequired, submitted: PropTypes.bool, @@ -54,6 +58,7 @@ class Search extends React.PureComponent { onSubmit: PropTypes.func.isRequired, onClear: PropTypes.func.isRequired, onShow: PropTypes.func.isRequired, + openInRoute: PropTypes.bool, intl: PropTypes.object.isRequired, }; @@ -76,7 +81,12 @@ class Search extends React.PureComponent { handleKeyUp = (e) => { if (e.key === 'Enter') { e.preventDefault(); + this.props.onSubmit(); + + if (this.props.openInRoute) { + this.context.router.history.push('/search'); + } } else if (e.key === 'Escape') { document.querySelector('.ui').parentElement.focus(); } diff --git a/app/javascript/mastodon/features/follow_requests/index.js b/app/javascript/mastodon/features/follow_requests/index.js index 3871e0e5d..44624cb40 100644 --- a/app/javascript/mastodon/features/follow_requests/index.js +++ b/app/javascript/mastodon/features/follow_requests/index.js @@ -56,7 +56,7 @@ class FollowRequests extends ImmutablePureComponent { const emptyMessage = <FormattedMessage id='empty_column.follow_requests' defaultMessage="You don't have any follow requests yet. When you receive one, it will show up here." />; return ( - <Column icon='users' heading={intl.formatMessage(messages.heading)}> + <Column icon='user-plus' heading={intl.formatMessage(messages.heading)}> <ColumnBackButtonSlim /> <ScrollableList scrollKey='follow_requests' diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js index a671578a0..129ce77f6 100644 --- a/app/javascript/mastodon/features/getting_started/index.js +++ b/app/javascript/mastodon/features/getting_started/index.js @@ -9,12 +9,10 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { me, invitesEnabled, version, profile_directory, repository, source_url } from '../../initial_state'; import { fetchFollowRequests } from 'mastodon/actions/accounts'; -import { changeSetting } from 'mastodon/actions/settings'; import { List as ImmutableList } from 'immutable'; import { Link } from 'react-router-dom'; import NavigationBar from '../compose/components/navigation_bar'; import Icon from 'mastodon/components/icon'; -import Toggle from 'react-toggle'; const messages = defineMessages({ home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' }, @@ -41,12 +39,10 @@ const messages = defineMessages({ const mapStateToProps = state => ({ myAccount: state.getIn(['accounts', me]), unreadFollowRequests: state.getIn(['user_lists', 'follow_requests', 'items'], ImmutableList()).size, - forceSingleColumn: state.getIn(['settings', 'forceSingleColumn'], false), }); const mapDispatchToProps = dispatch => ({ fetchFollowRequests: () => dispatch(fetchFollowRequests()), - changeForceSingleColumn: checked => dispatch(changeSetting(['forceSingleColumn'], checked)), }); const badgeDisplay = (number, limit) => { @@ -59,10 +55,16 @@ const badgeDisplay = (number, limit) => { } }; +const NAVIGATION_PANEL_BREAKPOINT = 600 + (285 * 2) + (10 * 2); + export default @connect(mapStateToProps, mapDispatchToProps) @injectIntl class GettingStarted extends ImmutablePureComponent { + static contextTypes = { + router: PropTypes.object.isRequired, + }; + static propTypes = { intl: PropTypes.object.isRequired, myAccount: ImmutablePropTypes.map.isRequired, @@ -71,24 +73,23 @@ class GettingStarted extends ImmutablePureComponent { fetchFollowRequests: PropTypes.func.isRequired, unreadFollowRequests: PropTypes.number, unreadNotifications: PropTypes.number, - forceSingleColumn: PropTypes.bool, - changeForceSingleColumn: PropTypes.func.isRequired, }; componentDidMount () { - const { myAccount, fetchFollowRequests } = this.props; + const { myAccount, fetchFollowRequests, multiColumn } = this.props; + + if (!multiColumn && window.innerWidth >= NAVIGATION_PANEL_BREAKPOINT) { + this.context.router.history.replace('/timelines/home'); + return; + } if (myAccount.get('locked')) { fetchFollowRequests(); } } - handleForceSingleColumnChange = ({ target }) => { - this.props.changeForceSingleColumn(target.checked); - } - render () { - const { intl, myAccount, multiColumn, unreadFollowRequests, forceSingleColumn } = this.props; + const { intl, myAccount, multiColumn, unreadFollowRequests } = this.props; const navItems = []; let i = 1; @@ -133,7 +134,7 @@ class GettingStarted extends ImmutablePureComponent { height += 48*3; if (myAccount.get('locked')) { - navItems.push(<ColumnLink key={i++} icon='users' text={intl.formatMessage(messages.follow_requests)} badge={badgeDisplay(unreadFollowRequests, 40)} to='/follow_requests' />); + navItems.push(<ColumnLink key={i++} icon='user-plus' text={intl.formatMessage(messages.follow_requests)} badge={badgeDisplay(unreadFollowRequests, 40)} to='/follow_requests' />); height += 48; } @@ -187,11 +188,6 @@ class GettingStarted extends ImmutablePureComponent { </p> </div> </div> - - <label className='navigational-toggle'> - <FormattedMessage id='getting_started.use_simple_layout' defaultMessage='Use simple layout' /> - <Toggle checked={forceSingleColumn} onChange={this.handleForceSingleColumnChange} /> - </label> </Column> ); } diff --git a/app/javascript/mastodon/features/keyboard_shortcuts/index.js b/app/javascript/mastodon/features/keyboard_shortcuts/index.js index ab1ac511e..01b45652c 100644 --- a/app/javascript/mastodon/features/keyboard_shortcuts/index.js +++ b/app/javascript/mastodon/features/keyboard_shortcuts/index.js @@ -61,6 +61,10 @@ class KeyboardShortcuts extends ImmutablePureComponent { <td><FormattedMessage id='keyboard_shortcuts.toggle_hidden' defaultMessage='to show/hide text behind CW' /></td> </tr> <tr> + <td><kbd>h</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.toggle_sensitivity' defaultMessage='to show/hide media' /></td> + </tr> + <tr> <td><kbd>up</kbd>, <kbd>k</kbd></td> <td><FormattedMessage id='keyboard_shortcuts.up' defaultMessage='to move up in the list' /></td> </tr> diff --git a/app/javascript/mastodon/features/search/index.js b/app/javascript/mastodon/features/search/index.js new file mode 100644 index 000000000..76bf70d4b --- /dev/null +++ b/app/javascript/mastodon/features/search/index.js @@ -0,0 +1,17 @@ +import React from 'react'; +import SearchContainer from 'mastodon/features/compose/containers/search_container'; +import SearchResultsContainer from 'mastodon/features/compose/containers/search_results_container'; + +const Search = () => ( + <div className='column search-page'> + <SearchContainer /> + + <div className='drawer__pager'> + <div className='drawer__inner darker'> + <SearchResultsContainer /> + </div> + </div> + </div> +); + +export default Search; diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js index 059ecd979..22821af0c 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.js +++ b/app/javascript/mastodon/features/status/components/detailed_status.js @@ -30,6 +30,8 @@ export default class DetailedStatus extends ImmutablePureComponent { onHeightChange: PropTypes.func, domain: PropTypes.string.isRequired, compact: PropTypes.bool, + showMedia: PropTypes.bool, + onToggleMediaVisibility: PropTypes.func, }; state = { @@ -122,6 +124,8 @@ export default class DetailedStatus extends ImmutablePureComponent { inline onOpenVideo={this.handleOpenVideo} sensitive={status.get('sensitive')} + visible={this.props.showMedia} + onToggleVisibility={this.props.onToggleMediaVisibility} /> ); } else { @@ -132,6 +136,8 @@ export default class DetailedStatus extends ImmutablePureComponent { media={status.get('media_attachments')} height={300} onOpenMedia={this.props.onOpenMedia} + visible={this.props.showMedia} + onToggleVisibility={this.props.onToggleMediaVisibility} /> ); } diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js index 6279bb468..d8c4c50dc 100644 --- a/app/javascript/mastodon/features/status/index.js +++ b/app/javascript/mastodon/features/status/index.js @@ -43,7 +43,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import { HotKeys } from 'react-hotkeys'; import { boostModal, deleteModal } from '../../initial_state'; import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../ui/util/fullscreen'; -import { textForScreenReader } from '../../components/status'; +import { textForScreenReader, defaultMediaVisibility } from '../../components/status'; import Icon from 'mastodon/components/icon'; const messages = defineMessages({ @@ -131,6 +131,7 @@ class Status extends ImmutablePureComponent { state = { fullscreen: false, + showMedia: defaultMediaVisibility(this.props.status), }; componentWillMount () { @@ -146,6 +147,14 @@ class Status extends ImmutablePureComponent { this._scrolledIntoView = false; this.props.dispatch(fetchStatus(nextProps.params.statusId)); } + + if (!Immutable.is(nextProps.status, this.props.status) && nextProps.status) { + this.setState({ showMedia: defaultMediaVisibility(nextProps.status) }); + } + } + + handleToggleMediaVisibility = () => { + this.setState({ showMedia: !this.state.showMedia }); } handleFavouriteClick = (status) => { @@ -312,6 +321,10 @@ class Status extends ImmutablePureComponent { this.handleToggleHidden(this.props.status); } + handleHotkeyToggleSensitive = () => { + this.handleToggleMediaVisibility(); + } + handleMoveUp = id => { const { status, ancestorsIds, descendantsIds } = this.props; @@ -432,6 +445,7 @@ class Status extends ImmutablePureComponent { mention: this.handleHotkeyMention, openProfile: this.handleHotkeyOpenProfile, toggleHidden: this.handleHotkeyToggleHidden, + toggleSensitive: this.handleHotkeyToggleSensitive, }; return ( @@ -455,6 +469,8 @@ class Status extends ImmutablePureComponent { onOpenMedia={this.handleOpenMedia} onToggleHidden={this.handleToggleHidden} domain={domain} + showMedia={this.state.showMedia} + onToggleMediaVisibility={this.handleToggleMediaVisibility} /> <ActionBar diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js index ae07b8907..756db3c61 100644 --- a/app/javascript/mastodon/features/ui/components/columns_area.js +++ b/app/javascript/mastodon/features/ui/components/columns_area.js @@ -14,6 +14,8 @@ import DrawerLoading from './drawer_loading'; import BundleColumnError from './bundle_column_error'; import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, DirectTimeline, FavouritedStatuses, ListTimeline } from '../../ui/util/async-components'; import Icon from 'mastodon/components/icon'; +import ComposePanel from './compose_panel'; +import NavigationPanel from './navigation_panel'; import detectPassiveEvents from 'detect-passive-events'; import { scrollRight } from '../../../scroll'; @@ -173,14 +175,22 @@ class ColumnsArea extends ImmutablePureComponent { return ( <div className='columns-area__panels'> - <div className='columns-area__panels__pane' /> + <div className='columns-area__panels__pane columns-area__panels__pane--compositional'> + <div className='columns-area__panels__pane__inner'> + <ComposePanel /> + </div> + </div> <div className='columns-area__panels__main'> <TabsBar key='tabs' /> {content} </div> - <div className='columns-area__panels__pane' /> + <div className='columns-area__panels__pane columns-area__panels__pane--start columns-area__panels__pane--navigational'> + <div className='columns-area__panels__pane__inner'> + <NavigationPanel /> + </div> + </div> {floatingActionButton} </div> diff --git a/app/javascript/mastodon/features/ui/components/compose_panel.js b/app/javascript/mastodon/features/ui/components/compose_panel.js new file mode 100644 index 000000000..7c1158e5d --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/compose_panel.js @@ -0,0 +1,41 @@ +import React from 'react'; +import SearchContainer from 'mastodon/features/compose/containers/search_container'; +import ComposeFormContainer from 'mastodon/features/compose/containers/compose_form_container'; +import NavigationContainer from 'mastodon/features/compose/containers/navigation_container'; +import { invitesEnabled, version, repository, source_url } from 'mastodon/initial_state'; +import { Link } from 'react-router-dom'; +import { FormattedMessage } from 'react-intl'; + +const ComposePanel = () => ( + <div className='compose-panel'> + <SearchContainer openInRoute /> + <NavigationContainer /> + <ComposeFormContainer /> + + <div className='flex-spacer' /> + + <div className='getting-started__footer'> + <ul> + {invitesEnabled && <li><a href='/invites' target='_blank'><FormattedMessage id='getting_started.invite' defaultMessage='Invite people' /></a> · </li>} + <li><Link to='/keyboard-shortcuts'><FormattedMessage id='navigation_bar.keyboard_shortcuts' defaultMessage='Hotkeys' /></Link> · </li> + <li><a href='/auth/edit'><FormattedMessage id='getting_started.security' defaultMessage='Security' /></a> · </li> + <li><a href='/about/more' target='_blank'><FormattedMessage id='navigation_bar.info' defaultMessage='About this server' /></a> · </li> + <li><a href='https://joinmastodon.org/apps' target='_blank'><FormattedMessage id='navigation_bar.apps' defaultMessage='Mobile apps' /></a> · </li> + <li><a href='/terms' target='_blank'><FormattedMessage id='getting_started.terms' defaultMessage='Terms of service' /></a> · </li> + <li><a href='/settings/applications' target='_blank'><FormattedMessage id='getting_started.developers' defaultMessage='Developers' /></a> · </li> + <li><a href='https://docs.joinmastodon.org' target='_blank'><FormattedMessage id='getting_started.documentation' defaultMessage='Documentation' /></a> · </li> + <li><a href='/auth/sign_out' data-method='delete'><FormattedMessage id='navigation_bar.logout' defaultMessage='Logout' /></a></li> + </ul> + + <p> + <FormattedMessage + id='getting_started.open_source_notice' + defaultMessage='Mastodon is open source software. You can contribute or report issues on GitHub at {github}.' + values={{ github: <span><a href={source_url} rel='noopener' target='_blank'>{repository}</a> (v{version})</span> }} + /> + </p> + </div> + </div> +); + +export default ComposePanel; diff --git a/app/javascript/mastodon/features/ui/components/follow_requests_nav_link.js b/app/javascript/mastodon/features/ui/components/follow_requests_nav_link.js new file mode 100644 index 000000000..90c953893 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/follow_requests_nav_link.js @@ -0,0 +1,44 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { fetchFollowRequests } from 'mastodon/actions/accounts'; +import { connect } from 'react-redux'; +import { NavLink, withRouter } from 'react-router-dom'; +import IconWithBadge from 'mastodon/components/icon_with_badge'; +import { me } from 'mastodon/initial_state'; +import { List as ImmutableList } from 'immutable'; +import { FormattedMessage } from 'react-intl'; + +const mapStateToProps = state => ({ + locked: state.getIn(['accounts', me, 'locked']), + count: state.getIn(['user_lists', 'follow_requests', 'items'], ImmutableList()).size, +}); + +export default @withRouter +@connect(mapStateToProps) +class FollowRequestsNavLink extends React.Component { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + locked: PropTypes.bool, + count: PropTypes.number.isRequired, + }; + + componentDidMount () { + const { dispatch, locked } = this.props; + + if (locked) { + dispatch(fetchFollowRequests()); + } + } + + render () { + const { locked, count } = this.props; + + if (!locked || count === 0) { + return null; + } + + return <NavLink className='column-link column-link--transparent' to='/follow_requests'><IconWithBadge className='column-link__icon' id='user-plus' count={count} /><FormattedMessage id='navigation_bar.follow_requests' defaultMessage='Follow requests' /></NavLink>; + } + +} diff --git a/app/javascript/mastodon/features/ui/components/list_panel.js b/app/javascript/mastodon/features/ui/components/list_panel.js new file mode 100644 index 000000000..1f7ec683a --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/list_panel.js @@ -0,0 +1,55 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { fetchLists } from 'mastodon/actions/lists'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { NavLink, withRouter } from 'react-router-dom'; +import Icon from 'mastodon/components/icon'; + +const getOrderedLists = createSelector([state => state.get('lists')], lists => { + if (!lists) { + return lists; + } + + return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title'))).take(4); +}); + +const mapStateToProps = state => ({ + lists: getOrderedLists(state), +}); + +export default @withRouter +@connect(mapStateToProps) +class ListPanel extends ImmutablePureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + lists: ImmutablePropTypes.list, + }; + + componentDidMount () { + const { dispatch } = this.props; + dispatch(fetchLists()); + } + + render () { + const { lists } = this.props; + + if (!lists || lists.isEmpty()) { + return null; + } + + return ( + <div> + <hr /> + + {lists.map(list => ( + <NavLink key={list.get('id')} className='column-link column-link--transparent' strict to={`/timelines/list/${list.get('id')}`}><Icon className='column-link__icon' id='list-ul' fixedWidth />{list.get('title')}</NavLink> + ))} + </div> + ); + } + +} diff --git a/app/javascript/mastodon/features/ui/components/navigation_panel.js b/app/javascript/mastodon/features/ui/components/navigation_panel.js new file mode 100644 index 000000000..1fd28c63a --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/navigation_panel.js @@ -0,0 +1,29 @@ +import React from 'react'; +import { NavLink, withRouter } from 'react-router-dom'; +import { FormattedMessage } from 'react-intl'; +import Icon from 'mastodon/components/icon'; +import NotificationsCounterIcon from './notifications_counter_icon'; +import FollowRequestsNavLink from './follow_requests_nav_link'; +import ListPanel from './list_panel'; + +const NavigationPanel = () => ( + <div className='navigation-panel'> + <NavLink className='column-link column-link--transparent' to='/timelines/home' data-preview-title-id='column.home' data-preview-icon='home' ><Icon className='column-link__icon' id='home' fixedWidth /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink> + <NavLink className='column-link column-link--transparent' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><NotificationsCounterIcon className='column-link__icon' /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></NavLink> + <FollowRequestsNavLink /> + <NavLink className='column-link column-link--transparent' to='/timelines/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon className='column-link__icon' id='users' fixedWidth /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink> + <NavLink className='column-link column-link--transparent' exact to='/timelines/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon className='column-link__icon' id='globe' fixedWidth /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink> + <NavLink className='column-link column-link--transparent' to='/timelines/direct'><Icon className='column-link__icon' id='envelope' fixedWidth /><FormattedMessage id='navigation_bar.direct' defaultMessage='Direct messages' /></NavLink> + <NavLink className='column-link column-link--transparent' to='/favourites'><Icon className='column-link__icon' id='star' fixedWidth /><FormattedMessage id='navigation_bar.favourites' defaultMessage='Favourites' /></NavLink> + <NavLink className='column-link column-link--transparent' to='/lists'><Icon className='column-link__icon' id='list-ul' fixedWidth /><FormattedMessage id='navigation_bar.lists' defaultMessage='Lists' /></NavLink> + + <ListPanel /> + + <hr /> + + <a className='column-link column-link--transparent' href='/settings/preferences' target='_blank'><Icon className='column-link__icon' id='cog' fixedWidth /><FormattedMessage id='navigation_bar.preferences' defaultMessage='Preferences' /></a> + <a className='column-link column-link--transparent' href='/relationships' target='_blank'><Icon className='column-link__icon' id='address-book-o' fixedWidth /><FormattedMessage id='navigation_bar.follows_and_followers' defaultMessage='Follows and followers' /></a> + </div> +); + +export default withRouter(NavigationPanel); diff --git a/app/javascript/mastodon/features/ui/components/notifications_counter_icon.js b/app/javascript/mastodon/features/ui/components/notifications_counter_icon.js index deb907866..da553cd9f 100644 --- a/app/javascript/mastodon/features/ui/components/notifications_counter_icon.js +++ b/app/javascript/mastodon/features/ui/components/notifications_counter_icon.js @@ -1,23 +1,9 @@ -import React from 'react'; -import PropTypes from 'prop-types'; import { connect } from 'react-redux'; -import Icon from 'mastodon/components/icon'; +import IconWithBadge from 'mastodon/components/icon_with_badge'; const mapStateToProps = state => ({ count: state.getIn(['notifications', 'unread']), + id: 'bell', }); -const formatNumber = num => num > 99 ? '99+' : num; - -const NotificationsCounterIcon = ({ count }) => ( - <i className='icon-with-badge'> - <Icon id='bell' fixedWidth /> - {count > 0 && <i className='icon-with-badge__badge'>{formatNumber(count)}</i>} - </i> -); - -NotificationsCounterIcon.propTypes = { - count: PropTypes.number.isRequired, -}; - -export default connect(mapStateToProps)(NotificationsCounterIcon); +export default connect(mapStateToProps)(IconWithBadge); diff --git a/app/javascript/mastodon/features/ui/components/tabs_bar.js b/app/javascript/mastodon/features/ui/components/tabs_bar.js index 979b782bb..29583d3d7 100644 --- a/app/javascript/mastodon/features/ui/components/tabs_bar.js +++ b/app/javascript/mastodon/features/ui/components/tabs_bar.js @@ -8,14 +8,12 @@ import Icon from 'mastodon/components/icon'; import NotificationsCounterIcon from './notifications_counter_icon'; export const links = [ - <NavLink className='tabs-bar__link primary' to='/timelines/home' data-preview-title-id='column.home' data-preview-icon='home' ><Icon id='home' fixedWidth /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>, - <NavLink className='tabs-bar__link primary' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><NotificationsCounterIcon /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></NavLink>, - - <NavLink className='tabs-bar__link secondary' to='/timelines/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon id='users' fixedWidth /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>, - <NavLink className='tabs-bar__link secondary' exact to='/timelines/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon id='globe' fixedWidth /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>, - <NavLink className='tabs-bar__link primary' to='/search' data-preview-title-id='tabs_bar.search' data-preview-icon='bell' ><Icon id='search' fixedWidth /><FormattedMessage id='tabs_bar.search' defaultMessage='Search' /></NavLink>, - - <NavLink className='tabs-bar__link primary' style={{ flexGrow: '0', flexBasis: '30px' }} to='/getting-started' data-preview-title-id='getting_started.heading' data-preview-icon='bars' ><Icon id='bars' fixedWidth /></NavLink>, + <NavLink className='tabs-bar__link' to='/timelines/home' data-preview-title-id='column.home' data-preview-icon='home' ><Icon id='home' fixedWidth /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>, + <NavLink className='tabs-bar__link' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><NotificationsCounterIcon /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></NavLink>, + <NavLink className='tabs-bar__link' to='/timelines/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon id='users' fixedWidth /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>, + <NavLink className='tabs-bar__link' exact to='/timelines/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon id='globe' fixedWidth /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>, + <NavLink className='tabs-bar__link optional' to='/search' data-preview-title-id='tabs_bar.search' data-preview-icon='bell' ><Icon id='search' fixedWidth /><FormattedMessage id='tabs_bar.search' defaultMessage='Search' /></NavLink>, + <NavLink className='tabs-bar__link' style={{ flexGrow: '0', flexBasis: '30px' }} to='/getting-started' data-preview-title-id='getting_started.heading' data-preview-icon='bars' ><Icon id='bars' fixedWidth /></NavLink>, ]; export function getIndex (path) { diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index 6d5279157..791133afd 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -44,8 +44,9 @@ import { Mutes, PinnedStatuses, Lists, + Search, } from './util/async-components'; -import { me } from '../../initial_state'; +import { me, forceSingleColumn } from '../../initial_state'; import { previewState as previewMediaState } from './components/media_modal'; import { previewState as previewVideoState } from './components/video_modal'; @@ -62,7 +63,6 @@ const mapStateToProps = state => ({ hasComposingText: state.getIn(['compose', 'text']).trim().length !== 0, hasMediaAttachments: state.getIn(['compose', 'media_attachments']).size > 0, dropdownMenuIsOpen: state.getIn(['dropdown_menu', 'openId']) !== null, - forceSingleColumn: state.getIn(['settings', 'forceSingleColumn'], false), }); const keyMap = { @@ -93,6 +93,7 @@ const keyMap = { goToMuted: 'g m', goToRequests: 'g r', toggleHidden: 'x', + toggleSensitive: 'h', }; class SwitchingColumnsArea extends React.PureComponent { @@ -101,7 +102,6 @@ class SwitchingColumnsArea extends React.PureComponent { children: PropTypes.node, location: PropTypes.object, onLayoutChange: PropTypes.func.isRequired, - forceSingleColumn: PropTypes.bool, }; state = { @@ -140,7 +140,7 @@ class SwitchingColumnsArea extends React.PureComponent { } render () { - const { children, forceSingleColumn } = this.props; + const { children } = this.props; const { mobile } = this.state; const singleColumn = forceSingleColumn || mobile; const redirect = singleColumn ? <Redirect from='/' to='/timelines/home' exact /> : <Redirect from='/' to='/getting-started' exact />; @@ -162,7 +162,7 @@ class SwitchingColumnsArea extends React.PureComponent { <WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} /> <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} /> - <WrappedRoute path='/search' component={Compose} content={children} componentParams={{ isSearchPage: true }} /> + <WrappedRoute path='/search' component={Search} content={children} /> <WrappedRoute path='/statuses/new' component={Compose} content={children} /> <WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} /> @@ -207,7 +207,6 @@ class UI extends React.PureComponent { location: PropTypes.object, intl: PropTypes.object.isRequired, dropdownMenuIsOpen: PropTypes.bool, - forceSingleColumn: PropTypes.bool, }; state = { @@ -456,7 +455,7 @@ class UI extends React.PureComponent { render () { const { draggingOver } = this.state; - const { children, isComposing, location, dropdownMenuIsOpen, forceSingleColumn } = this.props; + const { children, isComposing, location, dropdownMenuIsOpen } = this.props; const handlers = { help: this.handleHotkeyToggleHelp, @@ -482,7 +481,7 @@ class UI extends React.PureComponent { return ( <HotKeys keyMap={keyMap} handlers={handlers} ref={this.setHotkeysRef} attach={window} focused> <div className={classNames('ui', { 'is-composing': isComposing })} ref={this.setRef} style={{ pointerEvents: dropdownMenuIsOpen ? 'none' : null }}> - <SwitchingColumnsArea location={location} onLayoutChange={this.handleLayoutChange} forceSingleColumn={forceSingleColumn}> + <SwitchingColumnsArea location={location} onLayoutChange={this.handleLayoutChange}> {children} </SwitchingColumnsArea> diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index 235fd2a07..6e8ed163a 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -129,3 +129,7 @@ export function ListEditor () { export function ListAdder () { return import(/*webpackChunkName: "features/list_adder" */'../../list_adder'); } + +export function Search () { + return import(/*webpackChunkName: "features/search" */'../../search'); +} diff --git a/app/javascript/mastodon/features/video/index.js b/app/javascript/mastodon/features/video/index.js index 00a63a3d9..b0c408527 100644 --- a/app/javascript/mastodon/features/video/index.js +++ b/app/javascript/mastodon/features/video/index.js @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import { fromJS } from 'immutable'; +import { fromJS, is } from 'immutable'; import { throttle } from 'lodash'; import classNames from 'classnames'; import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen'; @@ -102,6 +102,8 @@ class Video extends React.PureComponent { detailed: PropTypes.bool, inline: PropTypes.bool, cacheWidth: PropTypes.func, + visible: PropTypes.bool, + onToggleVisibility: PropTypes.func, intl: PropTypes.object.isRequired, blurhash: PropTypes.string, link: PropTypes.node, @@ -117,7 +119,7 @@ class Video extends React.PureComponent { fullscreen: false, hovered: false, muted: false, - revealed: displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all', + revealed: this.props.visible !== undefined ? this.props.visible : (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all'), }; // hard coded in components.scss @@ -280,7 +282,16 @@ class Video extends React.PureComponent { document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true); } - componentDidUpdate (prevProps) { + componentWillReceiveProps (nextProps) { + if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) { + this.setState({ revealed: nextProps.visible }); + } + } + + componentDidUpdate (prevProps, prevState) { + if (prevState.revealed && !this.state.revealed && this.video) { + this.video.pause(); + } if (prevProps.blurhash !== this.props.blurhash && this.props.blurhash) { this._decode(); } @@ -316,11 +327,11 @@ class Video extends React.PureComponent { } toggleReveal = () => { - if (this.state.revealed) { - this.video.pause(); + if (this.props.onToggleVisibility) { + this.props.onToggleVisibility(); + } else { + this.setState({ revealed: !this.state.revealed }); } - - this.setState({ revealed: !this.state.revealed }); } handleLoadedData = () => { diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js index d74f5ceb1..4e0ecef94 100644 --- a/app/javascript/mastodon/initial_state.js +++ b/app/javascript/mastodon/initial_state.js @@ -20,5 +20,6 @@ export const version = getMeta('version'); export const mascot = getMeta('mascot'); export const profile_directory = getMeta('profile_directory'); export const isStaff = getMeta('is_staff'); +export const forceSingleColumn = !getMeta('advanced_layout'); export default initialState; diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js index 419c313af..a0eea137f 100644 --- a/app/javascript/mastodon/reducers/settings.js +++ b/app/javascript/mastodon/reducers/settings.js @@ -14,8 +14,6 @@ const initialState = ImmutableMap({ skinTone: 1, - forceSingleColumn: false, - home: ImmutableMap({ shows: ImmutableMap({ reblog: true, diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index fe3c55755..6b8b89a03 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -710,7 +710,7 @@ white-space: pre-wrap; &:last-child { - margin-bottom: 0; + margin-bottom: 2px; } } @@ -1801,7 +1801,12 @@ a.account__display-name { display: flex; justify-content: flex-end; + &--start { + justify-content: flex-start; + } + &__inner { + width: 285px; pointer-events: auto; height: 100%; } @@ -1925,6 +1930,7 @@ a.account__display-name { display: block; flex: 1 1 auto; padding: 15px 10px; + padding-bottom: 13px; color: $primary-text-color; text-decoration: none; text-align: center; @@ -1949,6 +1955,7 @@ a.account__display-name { &:active { @media screen and (min-width: 631px) { background: lighten($ui-base-color, 14%); + border-bottom-color: lighten($ui-base-color, 14%); } } @@ -1978,11 +1985,21 @@ a.account__display-name { padding: 0; } - .search__input, .autosuggest-textarea__textarea { font-size: 16px; } + .search__input { + line-height: 18px; + font-size: 16px; + padding: 15px; + padding-right: 30px; + } + + .search__icon .fa { + top: 15px; + } + @media screen and (min-width: 360px) { padding: 10px 0; } @@ -2038,6 +2055,58 @@ a.account__display-name { margin-top: 10px; } } + + .account { + padding: 15px 10px; + } + + .notification { + &__message { + margin-left: 48px + 15px * 2; + padding-top: 15px; + } + + &__favourite-icon-wrapper { + left: -32px; + } + + .status { + padding-top: 8px; + } + + .account { + padding-top: 8px; + } + + .account__avatar-wrapper { + margin-left: 17px; + margin-right: 15px; + } + } + } +} + +.floating-action-button { + position: fixed; + display: flex; + justify-content: center; + align-items: center; + width: 3.9375rem; + height: 3.9375rem; + bottom: 1.3125rem; + right: 1.3125rem; + background: darken($ui-highlight-color, 3%); + color: $white; + border-radius: 50%; + font-size: 21px; + line-height: 21px; + text-decoration: none; + box-shadow: 2px 3px 9px rgba($base-shadow-color, 0.4); + + &:hover, + &:focus, + &:active { + background: lighten($ui-highlight-color, 7%); } } @@ -2059,12 +2128,41 @@ a.account__display-name { } } +@media screen and (max-width: 600px + (285px * 1) + (10px * 1)) { + .columns-area__panels__pane--compositional { + display: none; + } +} + +@media screen and (min-width: 600px + (285px * 1) + (10px * 1)) { + .floating-action-button, + .tabs-bar__link.optional { + display: none; + } + + .search-page .search { + display: none; + } +} + +@media screen and (max-width: 600px + (285px * 2) + (10px * 2)) { + .columns-area__panels__pane--navigational { + display: none; + } +} + +@media screen and (min-width: 600px + (285px * 2) + (10px * 2)) { + .tabs-bar { + display: none; + } +} + .icon-with-badge { position: relative; &__badge { position: absolute; - right: -13px; + left: 9px; top: -13px; background: $ui-highlight-color; border: 2px solid lighten($ui-base-color, 8%); @@ -2077,6 +2175,57 @@ a.account__display-name { } } +.column-link--transparent .icon-with-badge__badge { + border-color: darken($ui-base-color, 8%); +} + +.compose-panel { + width: 285px; + margin-top: 10px; + display: flex; + flex-direction: column; + height: 100%; + + .search__input { + line-height: 18px; + font-size: 16px; + padding: 15px; + padding-right: 30px; + } + + .search__icon .fa { + top: 15px; + } + + .navigation-bar { + padding-top: 20px; + padding-bottom: 20px; + } + + .flex-spacer { + background: transparent; + } + + .autosuggest-textarea__textarea { + max-height: 200px; + } + + .compose-form__upload-thumbnail { + height: 80px; + } +} + +.navigation-panel { + margin-top: 10px; + + hr { + border: 0; + background: transparent; + border-top: 1px solid lighten($ui-base-color, 4%); + margin: 10px 0; + } +} + .drawer__pager { box-sizing: border-box; padding: 0; @@ -2127,15 +2276,6 @@ a.account__display-name { } } -.navigational-toggle { - padding: 10px; - display: flex; - align-items: center; - justify-content: space-between; - font-size: 14px; - color: $dark-text-color; -} - .pseudo-drawer { background: lighten($ui-base-color, 13%); font-size: 13px; @@ -2365,9 +2505,31 @@ a.account__display-name { padding: 15px; text-decoration: none; - &:hover { + &:hover, + &:focus, + &:active { background: lighten($ui-base-color, 11%); } + + &:focus { + outline: 0; + } + + &--transparent { + background: transparent; + color: $ui-secondary-color; + + &:hover, + &:focus, + &:active { + background: transparent; + color: $primary-text-color; + } + + &.active { + color: $ui-highlight-color; + } + } } .column-link__icon { @@ -5436,34 +5598,6 @@ noscript { } } -.floating-action-button { - position: fixed; - display: flex; - justify-content: center; - align-items: center; - width: 3.9375rem; - height: 3.9375rem; - bottom: 1.3125rem; - right: 1.3125rem; - background: darken($ui-highlight-color, 3%); - color: $white; - border-radius: 50%; - font-size: 21px; - line-height: 21px; - text-decoration: none; - box-shadow: 2px 3px 9px rgba($base-shadow-color, 0.4); - - &:hover, - &:focus, - &:active { - background: lighten($ui-highlight-color, 7%); - } - - @media screen and (min-width: 630px) { - display: none; - } -} - .account__header__content { color: $darker-text-color; font-size: 14px; |