diff options
Diffstat (limited to 'app/javascript/flavours/glitch/features')
9 files changed, 169 insertions, 21 deletions
diff --git a/app/javascript/flavours/glitch/features/account_timeline/index.js b/app/javascript/flavours/glitch/features/account_timeline/index.js index f25c82a00..a8e8aa7a8 100644 --- a/app/javascript/flavours/glitch/features/account_timeline/index.js +++ b/app/javascript/flavours/glitch/features/account_timeline/index.js @@ -15,11 +15,14 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import { FormattedMessage } from 'react-intl'; import { fetchAccountIdentityProofs } from '../../actions/identity_proofs'; import MissingIndicator from 'flavours/glitch/components/missing_indicator'; +import TimelineHint from 'flavours/glitch/components/timeline_hint'; const mapStateToProps = (state, { params: { accountId }, withReplies = false }) => { const path = withReplies ? `${accountId}:with_replies` : accountId; return { + remote: !!state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username']), + remoteUrl: state.getIn(['accounts', accountId, 'url']), isAccount: !!state.getIn(['accounts', accountId]), statusIds: state.getIn(['timelines', `account:${path}`, 'items'], ImmutableList()), featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned`, 'items'], ImmutableList()), @@ -28,6 +31,14 @@ const mapStateToProps = (state, { params: { accountId }, withReplies = false }) }; }; +const RemoteHint = ({ url }) => ( + <TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.statuses' defaultMessage='Older toots' />} /> +); + +RemoteHint.propTypes = { + url: PropTypes.string.isRequired, +}; + export default @connect(mapStateToProps) class AccountTimeline extends ImmutablePureComponent { @@ -40,6 +51,8 @@ class AccountTimeline extends ImmutablePureComponent { hasMore: PropTypes.bool, withReplies: PropTypes.bool, isAccount: PropTypes.bool, + remote: PropTypes.bool, + remoteUrl: PropTypes.string, multiColumn: PropTypes.bool, }; @@ -78,7 +91,7 @@ class AccountTimeline extends ImmutablePureComponent { } render () { - const { statusIds, featuredStatusIds, isLoading, hasMore, isAccount, multiColumn } = this.props; + const { statusIds, featuredStatusIds, isLoading, hasMore, isAccount, multiColumn, remote, remoteUrl } = this.props; if (!isAccount) { return ( @@ -97,6 +110,16 @@ class AccountTimeline extends ImmutablePureComponent { ); } + let emptyMessage; + + if (remote && statusIds.isEmpty()) { + emptyMessage = <RemoteHint url={remoteUrl} />; + } else { + emptyMessage = <FormattedMessage id='empty_column.account_timeline' defaultMessage='No toots here!' />; + } + + const remoteMessage = remote ? <RemoteHint url={remoteUrl} /> : null; + return ( <Column ref={this.setRef} name='account'> <ProfileColumnHeader onClick={this.handleHeaderClick} multiColumn={multiColumn} /> @@ -104,13 +127,14 @@ class AccountTimeline extends ImmutablePureComponent { <StatusList prepend={<HeaderContainer accountId={this.props.params.accountId} />} alwaysPrepend + append={remoteMessage} scrollKey='account_timeline' statusIds={statusIds} featuredStatusIds={featuredStatusIds} isLoading={isLoading} hasMore={hasMore} onLoadMore={this.handleLoadMore} - emptyMessage={<FormattedMessage id='empty_column.account_timeline' defaultMessage='No toots here!' />} + emptyMessage={emptyMessage} bindToDocument={!multiColumn} timelineId='account' /> diff --git a/app/javascript/flavours/glitch/features/audio/index.js b/app/javascript/flavours/glitch/features/audio/index.js index 49e91227f..ba3534492 100644 --- a/app/javascript/flavours/glitch/features/audio/index.js +++ b/app/javascript/flavours/glitch/features/audio/index.js @@ -125,6 +125,7 @@ class Audio extends React.PureComponent { this.wavesurfer.createPeakCache(); this.wavesurfer.load(this.props.src); this.wavesurfer.toggleInteraction(); + this.wavesurfer.setVolume(this.state.volume); this.loaded = true; } diff --git a/app/javascript/flavours/glitch/features/emoji_picker/index.js b/app/javascript/flavours/glitch/features/emoji_picker/index.js index 3717fcd82..14e5cb94a 100644 --- a/app/javascript/flavours/glitch/features/emoji_picker/index.js +++ b/app/javascript/flavours/glitch/features/emoji_picker/index.js @@ -279,12 +279,13 @@ class EmojiPickerMenu extends React.PureComponent { }; } - handleClick = emoji => { + handleClick = (emoji, event) => { if (!emoji.native) { emoji.native = emoji.colons; } - - this.props.onClose(); + if (!event.ctrlKey) { + this.props.onClose(); + } this.props.onPick(emoji); } diff --git a/app/javascript/flavours/glitch/features/followers/index.js b/app/javascript/flavours/glitch/features/followers/index.js index bf41f3b98..8ae46be94 100644 --- a/app/javascript/flavours/glitch/features/followers/index.js +++ b/app/javascript/flavours/glitch/features/followers/index.js @@ -17,14 +17,25 @@ import HeaderContainer from 'flavours/glitch/features/account_timeline/container import ImmutablePureComponent from 'react-immutable-pure-component'; import MissingIndicator from 'flavours/glitch/components/missing_indicator'; import ScrollableList from 'flavours/glitch/components/scrollable_list'; +import TimelineHint from 'flavours/glitch/components/timeline_hint'; const mapStateToProps = (state, props) => ({ + remote: !!state.getIn(['accounts', props.params.accountId, 'acct']) !== state.getIn(['accounts', props.params.accountId, 'username']), + remoteUrl: state.getIn(['accounts', props.params.accountId, 'url']), isAccount: !!state.getIn(['accounts', props.params.accountId]), accountIds: state.getIn(['user_lists', 'followers', props.params.accountId, 'items']), hasMore: !!state.getIn(['user_lists', 'followers', props.params.accountId, 'next']), isLoading: state.getIn(['user_lists', 'followers', props.params.accountId, 'isLoading'], true), }); +const RemoteHint = ({ url }) => ( + <TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.followers' defaultMessage='Followers' />} /> +); + +RemoteHint.propTypes = { + url: PropTypes.string.isRequired, +}; + export default @connect(mapStateToProps) class Followers extends ImmutablePureComponent { @@ -35,6 +46,8 @@ class Followers extends ImmutablePureComponent { hasMore: PropTypes.bool, isLoading: PropTypes.bool, isAccount: PropTypes.bool, + remote: PropTypes.bool, + remoteUrl: PropTypes.string, multiColumn: PropTypes.bool, }; @@ -65,7 +78,7 @@ class Followers extends ImmutablePureComponent { } render () { - const { accountIds, hasMore, isAccount, multiColumn, isLoading } = this.props; + const { accountIds, hasMore, isAccount, multiColumn, isLoading, remote, remoteUrl } = this.props; if (!isAccount) { return ( @@ -83,7 +96,15 @@ class Followers extends ImmutablePureComponent { ); } - const emptyMessage = <FormattedMessage id='account.followers.empty' defaultMessage='No one follows this user yet.' />; + let emptyMessage; + + if (remote && accountIds.isEmpty()) { + emptyMessage = <RemoteHint url={remoteUrl} />; + } else { + emptyMessage = <FormattedMessage id='account.followers.empty' defaultMessage='No one follows this user yet.' />; + } + + const remoteMessage = remote ? <RemoteHint url={remoteUrl} /> : null; return ( <Column ref={this.setRef}> @@ -96,6 +117,7 @@ class Followers extends ImmutablePureComponent { onLoadMore={this.handleLoadMore} prepend={<HeaderContainer accountId={this.props.params.accountId} hideTabs />} alwaysPrepend + append={remoteMessage} emptyMessage={emptyMessage} bindToDocument={!multiColumn} > diff --git a/app/javascript/flavours/glitch/features/following/index.js b/app/javascript/flavours/glitch/features/following/index.js index f090900cc..e06eaa8a6 100644 --- a/app/javascript/flavours/glitch/features/following/index.js +++ b/app/javascript/flavours/glitch/features/following/index.js @@ -17,14 +17,25 @@ import HeaderContainer from 'flavours/glitch/features/account_timeline/container import ImmutablePureComponent from 'react-immutable-pure-component'; import MissingIndicator from 'flavours/glitch/components/missing_indicator'; import ScrollableList from 'flavours/glitch/components/scrollable_list'; +import TimelineHint from 'flavours/glitch/components/timeline_hint'; const mapStateToProps = (state, props) => ({ + remote: !!state.getIn(['accounts', props.params.accountId, 'acct']) !== state.getIn(['accounts', props.params.accountId, 'username']), + remoteUrl: state.getIn(['accounts', props.params.accountId, 'url']), isAccount: !!state.getIn(['accounts', props.params.accountId]), accountIds: state.getIn(['user_lists', 'following', props.params.accountId, 'items']), hasMore: !!state.getIn(['user_lists', 'following', props.params.accountId, 'next']), isLoading: state.getIn(['user_lists', 'following', props.params.accountId, 'isLoading'], true), }); +const RemoteHint = ({ url }) => ( + <TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.follows' defaultMessage='Follows' />} /> +); + +RemoteHint.propTypes = { + url: PropTypes.string.isRequired, +}; + export default @connect(mapStateToProps) class Following extends ImmutablePureComponent { @@ -35,6 +46,8 @@ class Following extends ImmutablePureComponent { hasMore: PropTypes.bool, isLoading: PropTypes.bool, isAccount: PropTypes.bool, + remote: PropTypes.bool, + remoteUrl: PropTypes.string, multiColumn: PropTypes.bool, }; @@ -65,7 +78,7 @@ class Following extends ImmutablePureComponent { } render () { - const { accountIds, hasMore, isAccount, multiColumn, isLoading } = this.props; + const { accountIds, hasMore, isAccount, multiColumn, isLoading, remote, remoteUrl } = this.props; if (!isAccount) { return ( @@ -83,7 +96,15 @@ class Following extends ImmutablePureComponent { ); } - const emptyMessage = <FormattedMessage id='account.follows.empty' defaultMessage="This user doesn't follow anyone yet." />; + let emptyMessage; + + if (remote && accountIds.isEmpty()) { + emptyMessage = <RemoteHint url={remoteUrl} />; + } else { + emptyMessage = <FormattedMessage id='account.follows.empty' defaultMessage="This user doesn't follow anyone yet." />; + } + + const remoteMessage = remote ? <RemoteHint url={remoteUrl} /> : null; return ( <Column ref={this.setRef}> @@ -96,6 +117,7 @@ class Following extends ImmutablePureComponent { onLoadMore={this.handleLoadMore} prepend={<HeaderContainer accountId={this.props.params.accountId} hideTabs />} alwaysPrepend + append={remoteMessage} emptyMessage={emptyMessage} bindToDocument={!multiColumn} > diff --git a/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.js b/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.js index 0bb71e872..abc3f468f 100644 --- a/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.js +++ b/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.js @@ -106,6 +106,10 @@ class KeyboardShortcuts extends ImmutablePureComponent { <td><FormattedMessage id='keyboard_shortcuts.toot' defaultMessage='to start a brand new toot' /></td> </tr> <tr> + <td><kbd>alt</kbd>+<kbd>x</kbd></td> + <td><FormattedMessage id='keyboard_shortcuts.spoilers' defaultMessage='to show/hide CW field' /></td> + </tr> + <tr> <td><kbd>backspace</kbd></td> <td><FormattedMessage id='keyboard_shortcuts.back' defaultMessage='to navigate back' /></td> </tr> diff --git a/app/javascript/flavours/glitch/features/status/components/card.js b/app/javascript/flavours/glitch/features/status/components/card.js index e3ee7dada..03867e03a 100644 --- a/app/javascript/flavours/glitch/features/status/components/card.js +++ b/app/javascript/flavours/glitch/features/status/components/card.js @@ -2,10 +2,14 @@ import React from 'react'; import PropTypes from 'prop-types'; import Immutable from 'immutable'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import { FormattedMessage } from 'react-intl'; import punycode from 'punycode'; import classnames from 'classnames'; import { decode as decodeIDNA } from 'flavours/glitch/util/idna'; import Icon from 'flavours/glitch/components/icon'; +import classNames from 'classnames'; +import { useBlurhash } from 'flavours/glitch/util/initial_state'; +import { decode } from 'blurhash'; const getHostname = url => { const parser = document.createElement('a'); @@ -55,6 +59,7 @@ export default class Card extends React.PureComponent { compact: PropTypes.bool, defaultWidth: PropTypes.number, cacheWidth: PropTypes.func, + sensitive: PropTypes.bool, }; static defaultProps = { @@ -64,12 +69,44 @@ export default class Card extends React.PureComponent { state = { width: this.props.defaultWidth || 280, + previewLoaded: false, embedded: false, + revealed: !this.props.sensitive, }; componentWillReceiveProps (nextProps) { if (!Immutable.is(this.props.card, nextProps.card)) { - this.setState({ embedded: false }); + this.setState({ embedded: false, previewLoaded: false }); + } + if (this.props.sensitive !== nextProps.sensitive) { + this.setState({ revealed: !nextProps.sensitive }); + } + } + + componentDidMount () { + if (this.props.card && this.props.card.get('blurhash')) { + this._decode(); + } + } + + componentDidUpdate (prevProps) { + const { card } = this.props; + if (card.get('blurhash') && (!prevProps.card || prevProps.card.get('blurhash') !== card.get('blurhash'))) { + this._decode(); + } + } + + _decode () { + if (!useBlurhash) return; + + const hash = this.props.card.get('blurhash'); + const pixels = decode(hash, 32, 32); + + if (pixels) { + const ctx = this.canvas.getContext('2d'); + const imageData = new ImageData(pixels, 32, 32); + + ctx.putImageData(imageData, 0, 0); } } @@ -111,6 +148,18 @@ export default class Card extends React.PureComponent { } } + setCanvasRef = c => { + this.canvas = c; + } + + handleImageLoad = () => { + this.setState({ previewLoaded: true }); + } + + handleReveal = () => { + this.setState({ revealed: true }); + } + renderVideo () { const { card } = this.props; const content = { __html: addAutoPlay(card.get('html')) }; @@ -130,7 +179,7 @@ export default class Card extends React.PureComponent { render () { const { card, maxDescription, compact, defaultWidth } = this.props; - const { width, embedded } = this.state; + const { width, embedded, revealed } = this.state; if (card === null) { return null; @@ -145,7 +194,7 @@ export default class Card extends React.PureComponent { const height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio); const description = ( - <div className='status-card__content'> + <div className={classNames('status-card__content', { 'status-card__content--blurred': !revealed })}> {title} {!(horizontal || compact) && <p className='status-card__description'>{trim(card.get('description') || '', maxDescription)}</p>} <span className='status-card__host'>{provider}</span> @@ -153,7 +202,18 @@ export default class Card extends React.PureComponent { ); let embed = ''; - let thumbnail = <div style={{ backgroundImage: `url(${card.get('image')})`, width: horizontal ? width : null, height: horizontal ? height : null }} className='status-card__image-image' />; + let canvas = <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('status-card__image-preview', { 'status-card__image-preview--hidden' : revealed && this.state.previewLoaded })} />; + let thumbnail = <img src={card.get('image')} alt='' style={{ width: horizontal ? width : null, height: horizontal ? height : null, visibility: revealed ? null : 'hidden' }} onLoad={this.handleImageLoad} className='status-card__image-image' />; + let spoilerButton = ( + <button type='button' onClick={this.handleReveal} className='spoiler-button__overlay'> + <span className='spoiler-button__overlay__label'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span> + </button> + ); + spoilerButton = ( + <div className={classNames('spoiler-button', { 'spoiler-button--minified': revealed })}> + {spoilerButton} + </div> + ); if (interactive) { if (embedded) { @@ -167,14 +227,18 @@ export default class Card extends React.PureComponent { embed = ( <div className='status-card__image'> + {canvas} {thumbnail} - <div className='status-card__actions'> - <div> - <button onClick={this.handleEmbedClick}><Icon id={iconVariant} /></button> - {horizontal && <a href={card.get('url')} target='_blank' rel='noopener noreferrer'><Icon id='external-link' /></a>} + {revealed && ( + <div className='status-card__actions'> + <div> + <button onClick={this.handleEmbedClick}><Icon id={iconVariant} /></button> + {horizontal && <a href={card.get('url')} target='_blank' rel='noopener noreferrer'><Icon id='external-link' /></a>} + </div> </div> - </div> + )} + {!revealed && spoilerButton} </div> ); } @@ -188,13 +252,16 @@ export default class Card extends React.PureComponent { } else if (card.get('image')) { embed = ( <div className='status-card__image'> + {canvas} {thumbnail} + {!revealed && spoilerButton} </div> ); } else { embed = ( <div className='status-card__image'> <Icon id='file-text' /> + {!revealed && spoilerButton} </div> ); } 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 17f22a8a2..4fbd65517 100644 --- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js +++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js @@ -184,7 +184,7 @@ export default class DetailedStatus extends ImmutablePureComponent { mediaIcon = 'picture-o'; } } else if (status.get('card')) { - media = <Card onOpenMedia={this.props.onOpenMedia} card={status.get('card')} />; + media = <Card sensitive={status.get('sensitive')} onOpenMedia={this.props.onOpenMedia} card={status.get('card')} />; mediaIcon = 'link'; } diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js index f8f6cff88..bf76c0e57 100644 --- a/app/javascript/flavours/glitch/features/ui/index.js +++ b/app/javascript/flavours/glitch/features/ui/index.js @@ -7,7 +7,7 @@ import { connect } from 'react-redux'; import { Redirect, withRouter } from 'react-router-dom'; import { isMobile } from 'flavours/glitch/util/is_mobile'; import { debounce } from 'lodash'; -import { uploadCompose, resetCompose } from 'flavours/glitch/actions/compose'; +import { uploadCompose, resetCompose, changeComposeSpoilerness } from 'flavours/glitch/actions/compose'; import { expandHomeTimeline } from 'flavours/glitch/actions/timelines'; import { expandNotifications, notificationsSetVisibility } from 'flavours/glitch/actions/notifications'; import { fetchFilters } from 'flavours/glitch/actions/filters'; @@ -81,6 +81,7 @@ const keyMap = { new: 'n', search: 's', forceNew: 'option+n', + toggleComposeSpoilers: 'option+x', focusColumn: ['1', '2', '3', '4', '5', '6', '7', '8', '9'], reply: 'r', favourite: 'f', @@ -396,7 +397,7 @@ class UI extends React.Component { componentDidMount () { this.hotkeys.__mousetrap__.stopCallback = (e, element) => { - return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName); + return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName) && !e.altKey; }; } @@ -455,6 +456,11 @@ class UI extends React.Component { this.props.dispatch(resetCompose()); } + handleHotkeyToggleComposeSpoilers = e => { + e.preventDefault(); + this.props.dispatch(changeComposeSpoilerness()); + } + handleHotkeyFocusColumn = e => { const index = (e.key * 1) + 1; // First child is drawer, skip that const column = this.node.querySelector(`.column:nth-child(${index})`); @@ -569,6 +575,7 @@ class UI extends React.Component { new: this.handleHotkeyNew, search: this.handleHotkeySearch, forceNew: this.handleHotkeyForceNew, + toggleComposeSpoilers: this.handleHotkeyToggleComposeSpoilers, focusColumn: this.handleHotkeyFocusColumn, back: this.handleHotkeyBack, goToHome: this.handleHotkeyGoToHome, |