diff options
Diffstat (limited to 'app/javascript/flavours/glitch/components')
51 files changed, 6193 insertions, 0 deletions
diff --git a/app/javascript/flavours/glitch/components/account.js b/app/javascript/flavours/glitch/components/account.js new file mode 100644 index 000000000..f3e58dfe3 --- /dev/null +++ b/app/javascript/flavours/glitch/components/account.js @@ -0,0 +1,153 @@ +import React, { Fragment } from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import Avatar from './avatar'; +import DisplayName from './display_name'; +import Permalink from './permalink'; +import IconButton from './icon_button'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { me } from 'flavours/glitch/util/initial_state'; + +const messages = defineMessages({ + follow: { id: 'account.follow', defaultMessage: 'Follow' }, + unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, + requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }, + unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, + unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, + mute_notifications: { id: 'account.mute_notifications', defaultMessage: 'You are not currently muting notifications from @{name}. Click to mute notifications' }, + unmute_notifications: { id: 'account.unmute_notifications', defaultMessage: 'You are currently muting notifications from @{name}. Click to unmute notifications' }, +}); + +export default @injectIntl +class Account extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + onFollow: PropTypes.func.isRequired, + onBlock: PropTypes.func.isRequired, + onMute: PropTypes.func.isRequired, + onMuteNotifications: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + hidden: PropTypes.bool, + small: PropTypes.bool, + actionIcon: PropTypes.string, + actionTitle: PropTypes.string, + onActionClick: PropTypes.func, + }; + + handleFollow = () => { + this.props.onFollow(this.props.account); + } + + handleBlock = () => { + this.props.onBlock(this.props.account); + } + + handleMute = () => { + this.props.onMute(this.props.account); + } + + handleMuteNotifications = () => { + this.props.onMuteNotifications(this.props.account, true); + } + + handleUnmuteNotifications = () => { + this.props.onMuteNotifications(this.props.account, false); + } + + handleAction = () => { + this.props.onActionClick(this.props.account); + } + + render () { + const { + account, + hidden, + intl, + small, + onActionClick, + actionIcon, + actionTitle, + } = this.props; + + if (!account) { + return <div />; + } + + if (hidden) { + return ( + <Fragment> + {account.get('display_name')} + {account.get('username')} + </Fragment> + ); + } + + let buttons; + + if (onActionClick && actionIcon) { + buttons = <IconButton icon={actionIcon} title={actionTitle} onClick={this.handleAction} />; + } else if (account.get('id') !== me && !small && account.get('relationship', null) !== null) { + const following = account.getIn(['relationship', 'following']); + const requested = account.getIn(['relationship', 'requested']); + const blocking = account.getIn(['relationship', 'blocking']); + const muting = account.getIn(['relationship', 'muting']); + + if (requested) { + buttons = <IconButton disabled icon='hourglass' title={intl.formatMessage(messages.requested)} />; + } else if (blocking) { + buttons = <IconButton active icon='unlock' title={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.handleBlock} />; + } else if (muting) { + let hidingNotificationsButton; + if (account.getIn(['relationship', 'muting_notifications'])) { + hidingNotificationsButton = <IconButton active icon='bell' title={intl.formatMessage(messages.unmute_notifications, { name: account.get('username') })} onClick={this.handleUnmuteNotifications} />; + } else { + hidingNotificationsButton = <IconButton active icon='bell-slash' title={intl.formatMessage(messages.mute_notifications, { name: account.get('username') })} onClick={this.handleMuteNotifications} />; + } + buttons = ( + <Fragment> + <IconButton active icon='volume-up' title={intl.formatMessage(messages.unmute, { name: account.get('username') })} onClick={this.handleMute} /> + {hidingNotificationsButton} + </Fragment> + ); + } else if (!account.get('moved') || following) { + buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />; + } + } + + return small ? ( + <Permalink + className='account small' + href={account.get('url')} + to={`/accounts/${account.get('id')}`} + > + <div className='account__avatar-wrapper'> + <Avatar + account={account} + size={24} + /> + </div> + <DisplayName + account={account} + inline + /> + </Permalink> + ) : ( + <div className='account'> + <div className='account__wrapper'> + <Permalink key={account.get('id')} className='account__display-name' href={account.get('url')} to={`/accounts/${account.get('id')}`}> + <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div> + <DisplayName account={account} /> + </Permalink> + {buttons ? + <div className='account__relationship'> + {buttons} + </div> + : null} + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/animated_number.js b/app/javascript/flavours/glitch/components/animated_number.js new file mode 100644 index 000000000..e3235e368 --- /dev/null +++ b/app/javascript/flavours/glitch/components/animated_number.js @@ -0,0 +1,65 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedNumber } from 'react-intl'; +import TransitionMotion from 'react-motion/lib/TransitionMotion'; +import spring from 'react-motion/lib/spring'; +import { reduceMotion } from 'flavours/glitch/util/initial_state'; + +export default class AnimatedNumber extends React.PureComponent { + + static propTypes = { + value: PropTypes.number.isRequired, + }; + + state = { + direction: 1, + }; + + componentWillReceiveProps (nextProps) { + if (nextProps.value > this.props.value) { + this.setState({ direction: 1 }); + } else if (nextProps.value < this.props.value) { + this.setState({ direction: -1 }); + } + } + + willEnter = () => { + const { direction } = this.state; + + return { y: -1 * direction }; + } + + willLeave = () => { + const { direction } = this.state; + + return { y: spring(1 * direction, { damping: 35, stiffness: 400 }) }; + } + + render () { + const { value } = this.props; + const { direction } = this.state; + + if (reduceMotion) { + return <FormattedNumber value={value} />; + } + + const styles = [{ + key: `${value}`, + data: value, + style: { y: spring(0, { damping: 35, stiffness: 400 }) }, + }]; + + return ( + <TransitionMotion styles={styles} willEnter={this.willEnter} willLeave={this.willLeave}> + {items => ( + <span className='animated-number'> + {items.map(({ key, data, style }) => ( + <span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}><FormattedNumber value={data} /></span> + ))} + </span> + )} + </TransitionMotion> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/attachment_list.js b/app/javascript/flavours/glitch/components/attachment_list.js new file mode 100644 index 000000000..68d8d29c7 --- /dev/null +++ b/app/javascript/flavours/glitch/components/attachment_list.js @@ -0,0 +1,58 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import Icon from 'flavours/glitch/components/icon'; + +const filename = url => url.split('/').pop().split('#')[0].split('?')[0]; + +export default class AttachmentList extends ImmutablePureComponent { + + static propTypes = { + media: ImmutablePropTypes.list.isRequired, + compact: PropTypes.bool, + }; + + render () { + const { media, compact } = this.props; + + if (compact) { + return ( + <div className='attachment-list compact'> + <ul className='attachment-list__list'> + {media.map(attachment => { + const displayUrl = attachment.get('remote_url') || attachment.get('url'); + + return ( + <li key={attachment.get('id')}> + <a href={displayUrl} target='_blank' rel='noopener noreferrer'><Icon id='link' /> {filename(displayUrl)}</a> + </li> + ); + })} + </ul> + </div> + ); + } + + return ( + <div className='attachment-list'> + <div className='attachment-list__icon'> + <Icon id='link' /> + </div> + + <ul className='attachment-list__list'> + {media.map(attachment => { + const displayUrl = attachment.get('remote_url') || attachment.get('url'); + + return ( + <li key={attachment.get('id')}> + <a href={displayUrl} target='_blank' rel='noopener noreferrer'>{filename(displayUrl)}</a> + </li> + ); + })} + </ul> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/autosuggest_emoji.js b/app/javascript/flavours/glitch/components/autosuggest_emoji.js new file mode 100644 index 000000000..c8609e48f --- /dev/null +++ b/app/javascript/flavours/glitch/components/autosuggest_emoji.js @@ -0,0 +1,42 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import unicodeMapping from 'flavours/glitch/util/emoji/emoji_unicode_mapping_light'; + +const assetHost = process.env.CDN_HOST || ''; + +export default class AutosuggestEmoji extends React.PureComponent { + + static propTypes = { + emoji: PropTypes.object.isRequired, + }; + + render () { + const { emoji } = this.props; + let url; + + if (emoji.custom) { + url = emoji.imageUrl; + } else { + const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')]; + + if (!mapping) { + return null; + } + + url = `${assetHost}/emoji/${mapping.filename}.svg`; + } + + return ( + <div className='emoji'> + <img + className='emojione' + src={url} + alt={emoji.native || emoji.colons} + /> + + {emoji.colons} + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/autosuggest_hashtag.js b/app/javascript/flavours/glitch/components/autosuggest_hashtag.js new file mode 100644 index 000000000..648987dfd --- /dev/null +++ b/app/javascript/flavours/glitch/components/autosuggest_hashtag.js @@ -0,0 +1,28 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { shortNumberFormat } from 'flavours/glitch/util/numbers'; +import { FormattedMessage } from 'react-intl'; + +export default class AutosuggestHashtag extends React.PureComponent { + + static propTypes = { + tag: PropTypes.shape({ + name: PropTypes.string.isRequired, + url: PropTypes.string, + history: PropTypes.array, + }).isRequired, + }; + + render () { + const { tag } = this.props; + const weeklyUses = tag.history && shortNumberFormat(tag.history.reduce((total, day) => total + (day.uses * 1), 0)); + + return ( + <div className='autosuggest-hashtag'> + <div className='autosuggest-hashtag__name'>#<strong>{tag.name}</strong></div> + {tag.history !== undefined && <div className='autosuggest-hashtag__uses'><FormattedMessage id='autosuggest_hashtag.per_week' defaultMessage='{count} per week' values={{ count: weeklyUses }} /></div>} + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/autosuggest_input.js b/app/javascript/flavours/glitch/components/autosuggest_input.js new file mode 100644 index 000000000..1ef7ee216 --- /dev/null +++ b/app/javascript/flavours/glitch/components/autosuggest_input.js @@ -0,0 +1,230 @@ +import React from 'react'; +import AutosuggestAccountContainer from 'flavours/glitch/features/compose/containers/autosuggest_account_container'; +import AutosuggestEmoji from './autosuggest_emoji'; +import AutosuggestHashtag from './autosuggest_hashtag'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { isRtl } from 'flavours/glitch/util/rtl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import classNames from 'classnames'; +import { List as ImmutableList } from 'immutable'; + +const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => { + let word; + + let left = str.slice(0, caretPosition).search(/[^\s\u200B]+$/); + let right = str.slice(caretPosition).search(/[\s\u200B]/); + + if (right < 0) { + word = str.slice(left); + } else { + word = str.slice(left, right + caretPosition); + } + + if (!word || word.trim().length < 3 || searchTokens.indexOf(word[0]) === -1) { + return [null, null]; + } + + word = word.trim().toLowerCase(); + + if (word.length > 0) { + return [left, word]; + } else { + return [null, null]; + } +}; + +export default class AutosuggestInput extends ImmutablePureComponent { + + static propTypes = { + value: PropTypes.string, + suggestions: ImmutablePropTypes.list, + disabled: PropTypes.bool, + placeholder: PropTypes.string, + onSuggestionSelected: PropTypes.func.isRequired, + onSuggestionsClearRequested: PropTypes.func.isRequired, + onSuggestionsFetchRequested: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, + onKeyUp: PropTypes.func, + onKeyDown: PropTypes.func, + autoFocus: PropTypes.bool, + className: PropTypes.string, + id: PropTypes.string, + searchTokens: PropTypes.arrayOf(PropTypes.string), + maxLength: PropTypes.number, + }; + + static defaultProps = { + autoFocus: true, + searchTokens: ImmutableList(['@', ':', '#']), + }; + + state = { + suggestionsHidden: true, + focused: false, + selectedSuggestion: 0, + lastToken: null, + tokenStart: 0, + }; + + onChange = (e) => { + const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart, this.props.searchTokens); + + if (token !== null && this.state.lastToken !== token) { + this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart }); + this.props.onSuggestionsFetchRequested(token); + } else if (token === null) { + this.setState({ lastToken: null }); + this.props.onSuggestionsClearRequested(); + } + + this.props.onChange(e); + } + + onKeyDown = (e) => { + const { suggestions, disabled } = this.props; + const { selectedSuggestion, suggestionsHidden } = this.state; + + if (disabled) { + e.preventDefault(); + return; + } + + if (e.which === 229 || e.isComposing) { + // Ignore key events during text composition + // e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac) + return; + } + + switch(e.key) { + case 'Escape': + if (suggestions.size === 0 || suggestionsHidden) { + document.querySelector('.ui').parentElement.focus(); + } else { + e.preventDefault(); + this.setState({ suggestionsHidden: true }); + } + + break; + case 'ArrowDown': + if (suggestions.size > 0 && !suggestionsHidden) { + e.preventDefault(); + this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) }); + } + + break; + case 'ArrowUp': + if (suggestions.size > 0 && !suggestionsHidden) { + e.preventDefault(); + this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) }); + } + + break; + case 'Enter': + case 'Tab': + // Select suggestion + if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) { + e.preventDefault(); + e.stopPropagation(); + this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion)); + } + + break; + } + + if (e.defaultPrevented || !this.props.onKeyDown) { + return; + } + + this.props.onKeyDown(e); + } + + onBlur = () => { + this.setState({ suggestionsHidden: true, focused: false }); + } + + onFocus = () => { + this.setState({ focused: true }); + } + + onSuggestionClick = (e) => { + const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index')); + e.preventDefault(); + this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion); + this.input.focus(); + } + + componentWillReceiveProps (nextProps) { + if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) { + this.setState({ suggestionsHidden: false }); + } + } + + setInput = (c) => { + this.input = c; + } + + renderSuggestion = (suggestion, i) => { + const { selectedSuggestion } = this.state; + let inner, key; + + if (suggestion.type === 'emoji') { + inner = <AutosuggestEmoji emoji={suggestion} />; + key = suggestion.id; + } else if (suggestion.type ==='hashtag') { + inner = <AutosuggestHashtag tag={suggestion} />; + key = suggestion.name; + } else if (suggestion.type === 'account') { + inner = <AutosuggestAccountContainer id={suggestion.id} />; + key = suggestion.id; + } + + return ( + <div role='button' tabIndex='0' key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}> + {inner} + </div> + ); + } + + render () { + const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength } = this.props; + const { suggestionsHidden } = this.state; + const style = { direction: 'ltr' }; + + if (isRtl(value)) { + style.direction = 'rtl'; + } + + return ( + <div className='autosuggest-input'> + <label> + <span style={{ display: 'none' }}>{placeholder}</span> + + <input + type='text' + ref={this.setInput} + disabled={disabled} + placeholder={placeholder} + autoFocus={autoFocus} + value={value} + onChange={this.onChange} + onKeyDown={this.onKeyDown} + onKeyUp={onKeyUp} + onFocus={this.onFocus} + onBlur={this.onBlur} + style={style} + aria-autocomplete='list' + id={id} + className={className} + maxLength={maxLength} + /> + </label> + + <div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}> + {suggestions.map(this.renderSuggestion)} + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/autosuggest_textarea.js b/app/javascript/flavours/glitch/components/autosuggest_textarea.js new file mode 100644 index 000000000..1ce2f42b4 --- /dev/null +++ b/app/javascript/flavours/glitch/components/autosuggest_textarea.js @@ -0,0 +1,239 @@ +import React from 'react'; +import AutosuggestAccountContainer from 'flavours/glitch/features/compose/containers/autosuggest_account_container'; +import AutosuggestEmoji from './autosuggest_emoji'; +import AutosuggestHashtag from './autosuggest_hashtag'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { isRtl } from 'flavours/glitch/util/rtl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import Textarea from 'react-textarea-autosize'; +import classNames from 'classnames'; + +const textAtCursorMatchesToken = (str, caretPosition) => { + let word; + + let left = str.slice(0, caretPosition).search(/[^\s\u200B]+$/); + let right = str.slice(caretPosition).search(/[\s\u200B]/); + + if (right < 0) { + word = str.slice(left); + } else { + word = str.slice(left, right + caretPosition); + } + + if (!word || word.trim().length < 3 || ['@', ':', '#'].indexOf(word[0]) === -1) { + return [null, null]; + } + + word = word.trim().toLowerCase(); + + if (word.length > 0) { + return [left, word]; + } else { + return [null, null]; + } +}; + +export default class AutosuggestTextarea extends ImmutablePureComponent { + + static propTypes = { + value: PropTypes.string, + suggestions: ImmutablePropTypes.list, + disabled: PropTypes.bool, + placeholder: PropTypes.string, + onSuggestionSelected: PropTypes.func.isRequired, + onSuggestionsClearRequested: PropTypes.func.isRequired, + onSuggestionsFetchRequested: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, + onKeyUp: PropTypes.func, + onKeyDown: PropTypes.func, + onPaste: PropTypes.func.isRequired, + autoFocus: PropTypes.bool, + }; + + static defaultProps = { + autoFocus: true, + }; + + state = { + suggestionsHidden: true, + focused: false, + selectedSuggestion: 0, + lastToken: null, + tokenStart: 0, + }; + + onChange = (e) => { + const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart); + + if (token !== null && this.state.lastToken !== token) { + this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart }); + this.props.onSuggestionsFetchRequested(token); + } else if (token === null) { + this.setState({ lastToken: null }); + this.props.onSuggestionsClearRequested(); + } + + this.props.onChange(e); + } + + onKeyDown = (e) => { + const { suggestions, disabled } = this.props; + const { selectedSuggestion, suggestionsHidden } = this.state; + + if (disabled) { + e.preventDefault(); + return; + } + + if (e.which === 229 || e.isComposing) { + // Ignore key events during text composition + // e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac) + return; + } + + switch(e.key) { + case 'Escape': + if (suggestions.size === 0 || suggestionsHidden) { + document.querySelector('.ui').parentElement.focus(); + } else { + e.preventDefault(); + this.setState({ suggestionsHidden: true }); + } + + break; + case 'ArrowDown': + if (suggestions.size > 0 && !suggestionsHidden) { + e.preventDefault(); + this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) }); + } + + break; + case 'ArrowUp': + if (suggestions.size > 0 && !suggestionsHidden) { + e.preventDefault(); + this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) }); + } + + break; + case 'Enter': + case 'Tab': + // Select suggestion + if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) { + e.preventDefault(); + e.stopPropagation(); + this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion)); + } + + break; + } + + if (e.defaultPrevented || !this.props.onKeyDown) { + return; + } + + this.props.onKeyDown(e); + } + + onBlur = () => { + this.setState({ suggestionsHidden: true, focused: false }); + } + + onFocus = (e) => { + this.setState({ focused: true }); + if (this.props.onFocus) { + this.props.onFocus(e); + } + } + + onSuggestionClick = (e) => { + const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index')); + e.preventDefault(); + this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion); + this.textarea.focus(); + } + + componentWillReceiveProps (nextProps) { + if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) { + this.setState({ suggestionsHidden: false }); + } + } + + setTextarea = (c) => { + this.textarea = c; + } + + onPaste = (e) => { + if (e.clipboardData && e.clipboardData.files.length === 1) { + this.props.onPaste(e.clipboardData.files); + e.preventDefault(); + } + } + + renderSuggestion = (suggestion, i) => { + const { selectedSuggestion } = this.state; + let inner, key; + + if (suggestion.type === 'emoji') { + inner = <AutosuggestEmoji emoji={suggestion} />; + key = suggestion.id; + } else if (suggestion.type === 'hashtag') { + inner = <AutosuggestHashtag tag={suggestion} />; + key = suggestion.name; + } else if (suggestion.type === 'account') { + inner = <AutosuggestAccountContainer id={suggestion.id} />; + key = suggestion.id; + } + + return ( + <div role='button' tabIndex='0' key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}> + {inner} + </div> + ); + } + + render () { + const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, children } = this.props; + const { suggestionsHidden } = this.state; + const style = { direction: 'ltr' }; + + if (isRtl(value)) { + style.direction = 'rtl'; + } + + return [ + <div className='compose-form__autosuggest-wrapper' key='autosuggest-wrapper'> + <div className='autosuggest-textarea'> + <label> + <span style={{ display: 'none' }}>{placeholder}</span> + + <Textarea + ref={this.setTextarea} + className='autosuggest-textarea__textarea' + disabled={disabled} + placeholder={placeholder} + autoFocus={autoFocus} + value={value} + onChange={this.onChange} + onKeyDown={this.onKeyDown} + onKeyUp={onKeyUp} + onFocus={this.onFocus} + onBlur={this.onBlur} + onPaste={this.onPaste} + style={style} + aria-autocomplete='list' + /> + </label> + </div> + {children} + </div>, + + <div className='autosuggest-textarea__suggestions-wrapper' key='suggestions-wrapper'> + <div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}> + {suggestions.map(this.renderSuggestion)} + </div> + </div>, + ]; + } + +} diff --git a/app/javascript/flavours/glitch/components/avatar.js b/app/javascript/flavours/glitch/components/avatar.js new file mode 100644 index 000000000..c5e9072c4 --- /dev/null +++ b/app/javascript/flavours/glitch/components/avatar.js @@ -0,0 +1,77 @@ +import classNames from 'classnames'; +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { autoPlayGif } from 'flavours/glitch/util/initial_state'; + +export default class Avatar extends React.PureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + className: PropTypes.string, + size: PropTypes.number.isRequired, + style: PropTypes.object, + inline: PropTypes.bool, + animate: PropTypes.bool, + }; + + static defaultProps = { + animate: autoPlayGif, + size: 20, + inline: false, + }; + + state = { + hovering: false, + }; + + handleMouseEnter = () => { + if (this.props.animate) return; + this.setState({ hovering: true }); + } + + handleMouseLeave = () => { + if (this.props.animate) return; + this.setState({ hovering: false }); + } + + render () { + const { + account, + animate, + className, + inline, + size, + } = this.props; + const { hovering } = this.state; + + const src = account.get('avatar'); + const staticSrc = account.get('avatar_static'); + + const computedClass = classNames('account__avatar', { 'account__avatar-inline': inline }, className); + + const style = { + ...this.props.style, + width: `${size}px`, + height: `${size}px`, + backgroundSize: `${size}px ${size}px`, + }; + + if (hovering || animate) { + style.backgroundImage = `url(${src})`; + } else { + style.backgroundImage = `url(${staticSrc})`; + } + + return ( + <div + className={computedClass} + onMouseEnter={this.handleMouseEnter} + onMouseLeave={this.handleMouseLeave} + style={style} + data-avatar-of={`@${account.get('acct')}`} + /> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/avatar_composite.js b/app/javascript/flavours/glitch/components/avatar_composite.js new file mode 100644 index 000000000..125b51c44 --- /dev/null +++ b/app/javascript/flavours/glitch/components/avatar_composite.js @@ -0,0 +1,110 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { autoPlayGif } from 'flavours/glitch/util/initial_state'; + +export default class AvatarComposite extends React.PureComponent { + + static propTypes = { + accounts: ImmutablePropTypes.list.isRequired, + animate: PropTypes.bool, + size: PropTypes.number.isRequired, + }; + + static defaultProps = { + animate: autoPlayGif, + }; + + renderItem (account, size, index) { + const { animate } = this.props; + + let width = 50; + let height = 100; + let top = 'auto'; + let left = 'auto'; + let bottom = 'auto'; + let right = 'auto'; + + if (size === 1) { + width = 100; + } + + if (size === 4 || (size === 3 && index > 0)) { + height = 50; + } + + if (size === 2) { + if (index === 0) { + right = '1px'; + } else { + left = '1px'; + } + } else if (size === 3) { + if (index === 0) { + right = '1px'; + } else if (index > 0) { + left = '1px'; + } + + if (index === 1) { + bottom = '1px'; + } else if (index > 1) { + top = '1px'; + } + } else if (size === 4) { + if (index === 0 || index === 2) { + right = '1px'; + } + + if (index === 1 || index === 3) { + left = '1px'; + } + + if (index < 2) { + bottom = '1px'; + } else { + top = '1px'; + } + } + + const style = { + left: left, + top: top, + right: right, + bottom: bottom, + width: `${width}%`, + height: `${height}%`, + backgroundSize: 'cover', + backgroundImage: `url(${account.get(animate ? 'avatar' : 'avatar_static')})`, + }; + + return ( + <a + href={account.get('url')} + target='_blank' + onClick={(e) => this.props.onAccountClick(account.get('id'), e)} + title={`@${account.get('acct')}`} + key={account.get('id')} + > + <div style={style} data-avatar-of={`@${account.get('acct')}`} /> + </a> + ); + } + + render() { + const { accounts, size } = this.props; + + return ( + <div className='account__avatar-composite' style={{ width: `${size}px`, height: `${size}px` }}> + {accounts.take(4).map((account, i) => this.renderItem(account, Math.min(accounts.size, 4), i))} + + {accounts.size > 4 && ( + <span className='account__avatar-composite__label'> + +{accounts.size - 4} + </span> + )} + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/avatar_overlay.js b/app/javascript/flavours/glitch/components/avatar_overlay.js new file mode 100644 index 000000000..23db5182b --- /dev/null +++ b/app/javascript/flavours/glitch/components/avatar_overlay.js @@ -0,0 +1,37 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { autoPlayGif } from 'flavours/glitch/util/initial_state'; + +export default class AvatarOverlay extends React.PureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + friend: ImmutablePropTypes.map.isRequired, + animate: PropTypes.bool, + }; + + static defaultProps = { + animate: autoPlayGif, + }; + + render() { + const { account, friend, animate } = this.props; + + const baseStyle = { + backgroundImage: `url(${account.get(animate ? 'avatar' : 'avatar_static')})`, + }; + + const overlayStyle = { + backgroundImage: `url(${friend.get(animate ? 'avatar' : 'avatar_static')})`, + }; + + return ( + <div className='account__avatar-overlay'> + <div className='account__avatar-overlay-base' style={baseStyle} data-avatar-of={`@${account.get('acct')}`} /> + <div className='account__avatar-overlay-overlay' style={overlayStyle} data-avatar-of={`@${friend.get('acct')}`} /> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/button.js b/app/javascript/flavours/glitch/components/button.js new file mode 100644 index 000000000..cd6528f58 --- /dev/null +++ b/app/javascript/flavours/glitch/components/button.js @@ -0,0 +1,64 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +export default class Button extends React.PureComponent { + + static propTypes = { + text: PropTypes.node, + onClick: PropTypes.func, + disabled: PropTypes.bool, + block: PropTypes.bool, + secondary: PropTypes.bool, + size: PropTypes.number, + className: PropTypes.string, + title: PropTypes.string, + style: PropTypes.object, + children: PropTypes.node, + }; + + static defaultProps = { + size: 36, + }; + + handleClick = (e) => { + if (!this.props.disabled) { + this.props.onClick(e); + } + } + + setRef = (c) => { + this.node = c; + } + + focus() { + this.node.focus(); + } + + render () { + let attrs = { + className: classNames('button', this.props.className, { + 'button-secondary': this.props.secondary, + 'button--block': this.props.block, + }), + disabled: this.props.disabled, + onClick: this.handleClick, + ref: this.setRef, + style: { + padding: `0 ${this.props.size / 2.25}px`, + height: `${this.props.size}px`, + lineHeight: `${this.props.size}px`, + ...this.props.style, + }, + }; + + if (this.props.title) attrs.title = this.props.title; + + return ( + <button {...attrs}> + {this.props.text || this.props.children} + </button> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/column.js b/app/javascript/flavours/glitch/components/column.js new file mode 100644 index 000000000..5819d5362 --- /dev/null +++ b/app/javascript/flavours/glitch/components/column.js @@ -0,0 +1,64 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import detectPassiveEvents from 'detect-passive-events'; +import { scrollTop } from 'flavours/glitch/util/scroll'; + +export default class Column extends React.PureComponent { + + static propTypes = { + children: PropTypes.node, + extraClasses: PropTypes.string, + name: PropTypes.string, + label: PropTypes.string, + bindToDocument: PropTypes.bool, + }; + + scrollTop () { + const scrollable = this.props.bindToDocument ? document.scrollingElement : this.node.querySelector('.scrollable'); + + if (!scrollable) { + return; + } + + this._interruptScrollAnimation = scrollTop(scrollable); + } + + handleWheel = () => { + if (typeof this._interruptScrollAnimation !== 'function') { + return; + } + + this._interruptScrollAnimation(); + } + + setRef = c => { + this.node = c; + } + + componentDidMount () { + if (this.props.bindToDocument) { + document.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false); + } else { + this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false); + } + } + + componentWillUnmount () { + if (this.props.bindToDocument) { + document.removeEventListener('wheel', this.handleWheel); + } else { + this.node.removeEventListener('wheel', this.handleWheel); + } + } + + render () { + const { children, extraClasses, name, label } = this.props; + + return ( + <div role='region' aria-label={label} data-column={name} className={`column ${extraClasses || ''}`} ref={this.setRef}> + {children} + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/column_back_button.js b/app/javascript/flavours/glitch/components/column_back_button.js new file mode 100644 index 000000000..05688f867 --- /dev/null +++ b/app/javascript/flavours/glitch/components/column_back_button.js @@ -0,0 +1,60 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import PropTypes from 'prop-types'; +import Icon from 'flavours/glitch/components/icon'; +import { createPortal } from 'react-dom'; + +export default class ColumnBackButton extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + multiColumn: PropTypes.bool, + }; + + handleClick = (event) => { + // if history is exhausted, or we would leave mastodon, just go to root. + if (window.history.state) { + const state = this.context.router.history.location.state; + if (event.shiftKey && state && state.mastodonBackSteps) { + this.context.router.history.go(-state.mastodonBackSteps); + } else { + this.context.router.history.goBack(); + } + } else { + this.context.router.history.push('/'); + } + } + + render () { + const { multiColumn } = this.props; + + const component = ( + <button onClick={this.handleClick} className='column-back-button'> + <Icon id='chevron-left' className='column-back-button__icon' fixedWidth /> + <FormattedMessage id='column_back_button.label' defaultMessage='Back' /> + </button> + ); + + if (multiColumn) { + return component; + } else { + // The portal container and the component may be rendered to the DOM in + // the same React render pass, so the container might not be available at + // the time `render()` is called. + const container = document.getElementById('tabs-bar__portal'); + if (container === null) { + // The container wasn't available, force a re-render so that the + // component can eventually be inserted in the container and not scroll + // with the rest of the area. + this.forceUpdate(); + return component; + } else { + return createPortal(component, container); + } + } + } + +} diff --git a/app/javascript/flavours/glitch/components/column_back_button_slim.js b/app/javascript/flavours/glitch/components/column_back_button_slim.js new file mode 100644 index 000000000..faa0c23a8 --- /dev/null +++ b/app/javascript/flavours/glitch/components/column_back_button_slim.js @@ -0,0 +1,37 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import PropTypes from 'prop-types'; +import Icon from 'flavours/glitch/components/icon'; + +export default class ColumnBackButtonSlim extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + handleClick = (event) => { + // if history is exhausted, or we would leave mastodon, just go to root. + if (window.history.state) { + const state = this.context.router.history.location.state; + if (event.shiftKey && state && state.mastodonBackSteps) { + this.context.router.history.go(-state.mastodonBackSteps); + } else { + this.context.router.history.goBack(); + } + } else { + this.context.router.history.push('/'); + } + } + + render () { + return ( + <div className='column-back-button--slim'> + <div role='button' tabIndex='0' onClick={this.handleClick} className='column-back-button column-back-button--slim-button'> + <Icon id='chevron-left' className='column-back-button__icon' fixedWidth /> + <FormattedMessage id='column_back_button.label' defaultMessage='Back' /> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/column_header.js b/app/javascript/flavours/glitch/components/column_header.js new file mode 100644 index 000000000..01bd4a246 --- /dev/null +++ b/app/javascript/flavours/glitch/components/column_header.js @@ -0,0 +1,206 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { createPortal } from 'react-dom'; +import classNames from 'classnames'; +import { FormattedMessage, injectIntl, defineMessages } from 'react-intl'; +import Icon from 'flavours/glitch/components/icon'; + +const messages = defineMessages({ + show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' }, + hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' }, + moveLeft: { id: 'column_header.moveLeft_settings', defaultMessage: 'Move column to the left' }, + moveRight: { id: 'column_header.moveRight_settings', defaultMessage: 'Move column to the right' }, +}); + +export default @injectIntl +class ColumnHeader extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + intl: PropTypes.object.isRequired, + title: PropTypes.node, + icon: PropTypes.string, + active: PropTypes.bool, + multiColumn: PropTypes.bool, + extraButton: PropTypes.node, + showBackButton: PropTypes.bool, + children: PropTypes.node, + pinned: PropTypes.bool, + placeholder: PropTypes.bool, + onPin: PropTypes.func, + onMove: PropTypes.func, + onClick: PropTypes.func, + appendContent: PropTypes.node, + }; + + state = { + collapsed: true, + animating: false, + }; + + historyBack = (skip) => { + // if history is exhausted, or we would leave mastodon, just go to root. + if (window.history.state) { + const state = this.context.router.history.location.state; + if (skip && state && state.mastodonBackSteps) { + this.context.router.history.go(-state.mastodonBackSteps); + } else { + this.context.router.history.goBack(); + } + } else { + this.context.router.history.push('/'); + } + } + + handleToggleClick = (e) => { + e.stopPropagation(); + this.setState({ collapsed: !this.state.collapsed, animating: true }); + } + + handleTitleClick = () => { + this.props.onClick(); + } + + handleMoveLeft = () => { + this.props.onMove(-1); + } + + handleMoveRight = () => { + this.props.onMove(1); + } + + handleBackClick = (event) => { + this.historyBack(event.shiftKey); + } + + handleTransitionEnd = () => { + this.setState({ animating: false }); + } + + handlePin = () => { + if (!this.props.pinned) { + this.historyBack(); + } + this.props.onPin(); + } + + render () { + const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent } = this.props; + const { collapsed, animating } = this.state; + + const wrapperClassName = classNames('column-header__wrapper', { + 'active': active, + }); + + const buttonClassName = classNames('column-header', { + 'active': active, + }); + + const collapsibleClassName = classNames('column-header__collapsible', { + 'collapsed': collapsed, + 'animating': animating, + }); + + const collapsibleButtonClassName = classNames('column-header__button', { + 'active': !collapsed, + }); + + let extraContent, pinButton, moveButtons, backButton, collapseButton; + + if (children) { + extraContent = ( + <div key='extra-content' className='column-header__collapsible__extra'> + {children} + </div> + ); + } + + if (multiColumn && pinned) { + pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='times' /> <FormattedMessage id='column_header.unpin' defaultMessage='Unpin' /></button>; + + moveButtons = ( + <div key='move-buttons' className='column-header__setting-arrows'> + <button title={formatMessage(messages.moveLeft)} aria-label={formatMessage(messages.moveLeft)} className='text-btn column-header__setting-btn' onClick={this.handleMoveLeft}><Icon id='chevron-left' /></button> + <button title={formatMessage(messages.moveRight)} aria-label={formatMessage(messages.moveRight)} className='text-btn column-header__setting-btn' onClick={this.handleMoveRight}><Icon id='chevron-right' /></button> + </div> + ); + } else if (multiColumn && this.props.onPin) { + pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='plus' /> <FormattedMessage id='column_header.pin' defaultMessage='Pin' /></button>; + } + + if (!pinned && (multiColumn || showBackButton)) { + backButton = ( + <button onClick={this.handleBackClick} className='column-header__back-button'> + <Icon id='chevron-left' className='column-back-button__icon' fixedWidth /> + <FormattedMessage id='column_back_button.label' defaultMessage='Back' /> + </button> + ); + } + + const collapsedContent = [ + extraContent, + ]; + + if (multiColumn) { + collapsedContent.push(moveButtons); + collapsedContent.push(pinButton); + } + + if (children || (multiColumn && this.props.onPin)) { + collapseButton = <button className={collapsibleButtonClassName} title={formatMessage(collapsed ? messages.show : messages.hide)} aria-label={formatMessage(collapsed ? messages.show : messages.hide)} aria-pressed={collapsed ? 'false' : 'true'} onClick={this.handleToggleClick}><Icon id='sliders' /></button>; + } + + const hasTitle = icon && title; + + const component = ( + <div className={wrapperClassName}> + <h1 className={buttonClassName}> + {hasTitle && ( + <button onClick={this.handleTitleClick}> + <Icon id={icon} fixedWidth className='column-header__icon' /> + {title} + </button> + )} + + {!hasTitle && backButton} + + <div className='column-header__buttons'> + {hasTitle && backButton} + {extraButton} + {collapseButton} + </div> + </h1> + + <div className={collapsibleClassName} tabIndex={collapsed ? -1 : null} onTransitionEnd={this.handleTransitionEnd}> + <div className='column-header__collapsible-inner'> + {(!collapsed || animating) && collapsedContent} + </div> + </div> + + {appendContent} + </div> + ); + + if (multiColumn || placeholder) { + return component; + } else { + // The portal container and the component may be rendered to the DOM in + // the same React render pass, so the container might not be available at + // the time `render()` is called. + const container = document.getElementById('tabs-bar__portal'); + if (container === null) { + // The container wasn't available, force a re-render so that the + // component can eventually be inserted in the container and not scroll + // with the rest of the area. + this.forceUpdate(); + return component; + } else { + return createPortal(component, container); + } + } + } + +} diff --git a/app/javascript/flavours/glitch/components/display_name.js b/app/javascript/flavours/glitch/components/display_name.js new file mode 100644 index 000000000..44662a8b8 --- /dev/null +++ b/app/javascript/flavours/glitch/components/display_name.js @@ -0,0 +1,112 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { autoPlayGif } from 'flavours/glitch/util/initial_state'; + +export default class DisplayName extends React.PureComponent { + + static propTypes = { + account: ImmutablePropTypes.map, + className: PropTypes.string, + inline: PropTypes.bool, + localDomain: PropTypes.string, + others: ImmutablePropTypes.list, + handleClick: PropTypes.func, + }; + + _updateEmojis () { + const node = this.node; + + if (!node || autoPlayGif) { + return; + } + + const emojis = node.querySelectorAll('.custom-emoji'); + + for (var i = 0; i < emojis.length; i++) { + let emoji = emojis[i]; + if (emoji.classList.contains('status-emoji')) { + continue; + } + emoji.classList.add('status-emoji'); + + emoji.addEventListener('mouseenter', this.handleEmojiMouseEnter, false); + emoji.addEventListener('mouseleave', this.handleEmojiMouseLeave, false); + } + } + + componentDidMount () { + this._updateEmojis(); + } + + componentDidUpdate () { + this._updateEmojis(); + } + + handleEmojiMouseEnter = ({ target }) => { + target.src = target.getAttribute('data-original'); + } + + handleEmojiMouseLeave = ({ target }) => { + target.src = target.getAttribute('data-static'); + } + + setRef = (c) => { + this.node = c; + } + + render() { + const { account, className, inline, localDomain, others, onAccountClick } = this.props; + + const computedClass = classNames('display-name', { inline }, className); + + if (!account) return null; + + let displayName, suffix; + + let acct = account.get('acct'); + + if (acct.indexOf('@') === -1 && localDomain) { + acct = `${acct}@${localDomain}`; + } + + if (others && others.size > 0) { + displayName = others.take(2).map(a => ( + <a + href={a.get('url')} + target='_blank' + onClick={(e) => onAccountClick(a.get('id'), e)} + title={`@${a.get('acct')}`} + rel='noopener noreferrer' + > + <bdi key={a.get('id')}> + <strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} /> + </bdi> + </a> + )).reduce((prev, cur) => [prev, ', ', cur]); + + if (others.size - 2 > 0) { + displayName.push(` +${others.size - 2}`); + } + + suffix = ( + <a href={account.get('url')} target='_blank' onClick={(e) => onAccountClick(account.get('id'), e)} rel='noopener noreferrer'> + <span className='display-name__account'>@{acct}</span> + </a> + ); + } else { + displayName = <bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi>; + suffix = <span className='display-name__account'>@{acct}</span>; + } + + return ( + <span className={computedClass} ref={this.setRef}> + {displayName} + {inline ? ' ' : null} + {suffix} + </span> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/domain.js b/app/javascript/flavours/glitch/components/domain.js new file mode 100644 index 000000000..697065d87 --- /dev/null +++ b/app/javascript/flavours/glitch/components/domain.js @@ -0,0 +1,42 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import IconButton from './icon_button'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' }, +}); + +export default @injectIntl +class Account extends ImmutablePureComponent { + + static propTypes = { + domain: PropTypes.string, + onUnblockDomain: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + handleDomainUnblock = () => { + this.props.onUnblockDomain(this.props.domain); + } + + render () { + const { domain, intl } = this.props; + + return ( + <div className='domain'> + <div className='domain__wrapper'> + <span className='domain__domain-name'> + <strong>{domain}</strong> + </span> + + <div className='domain__buttons'> + <IconButton active icon='unlock' title={intl.formatMessage(messages.unblockDomain, { domain })} onClick={this.handleDomainUnblock} /> + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/dropdown_menu.js b/app/javascript/flavours/glitch/components/dropdown_menu.js new file mode 100644 index 000000000..60ed859a3 --- /dev/null +++ b/app/javascript/flavours/glitch/components/dropdown_menu.js @@ -0,0 +1,294 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import IconButton from './icon_button'; +import Overlay from 'react-overlays/lib/Overlay'; +import Motion from 'flavours/glitch/util/optional_motion'; +import spring from 'react-motion/lib/spring'; +import detectPassiveEvents from 'detect-passive-events'; + +const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false; +let id = 0; + +class DropdownMenu extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + items: PropTypes.array.isRequired, + onClose: PropTypes.func.isRequired, + style: PropTypes.object, + placement: PropTypes.string, + arrowOffsetLeft: PropTypes.string, + arrowOffsetTop: PropTypes.string, + openedViaKeyboard: PropTypes.bool, + }; + + static defaultProps = { + style: {}, + placement: 'bottom', + }; + + state = { + mounted: false, + }; + + handleDocumentClick = e => { + if (this.node && !this.node.contains(e.target)) { + this.props.onClose(); + } + } + + componentDidMount () { + document.addEventListener('click', this.handleDocumentClick, false); + document.addEventListener('keydown', this.handleKeyDown, false); + document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); + if (this.focusedItem && this.props.openedViaKeyboard) { + this.focusedItem.focus({ preventScroll: true }); + } + this.setState({ mounted: true }); + } + + componentWillUnmount () { + document.removeEventListener('click', this.handleDocumentClick, false); + document.removeEventListener('keydown', this.handleKeyDown, false); + document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); + } + + setRef = c => { + this.node = c; + } + + setFocusRef = c => { + this.focusedItem = c; + } + + handleKeyDown = e => { + const items = Array.from(this.node.getElementsByTagName('a')); + const index = items.indexOf(document.activeElement); + let element = null; + + switch(e.key) { + case 'ArrowDown': + element = items[index+1] || items[0]; + break; + case 'ArrowUp': + element = items[index-1] || items[items.length-1]; + break; + case 'Tab': + if (e.shiftKey) { + element = items[index-1] || items[items.length-1]; + } else { + element = items[index+1] || items[0]; + } + break; + case 'Home': + element = items[0]; + break; + case 'End': + element = items[items.length-1]; + break; + case 'Escape': + this.props.onClose(); + break; + } + + if (element) { + element.focus(); + e.preventDefault(); + e.stopPropagation(); + } + } + + handleItemKeyPress = e => { + if (e.key === 'Enter' || e.key === ' ') { + this.handleClick(e); + } + } + + handleClick = e => { + const i = Number(e.currentTarget.getAttribute('data-index')); + const { action, to } = this.props.items[i]; + + this.props.onClose(); + + if (typeof action === 'function') { + e.preventDefault(); + action(); + } else if (to) { + e.preventDefault(); + this.context.router.history.push(to); + } + } + + renderItem (option, i) { + if (option === null) { + return <li key={`sep-${i}`} className='dropdown-menu__separator' />; + } + + const { text, href = '#' } = option; + + return ( + <li className='dropdown-menu__item' key={`${text}-${i}`}> + <a href={href} target='_blank' rel='noopener noreferrer' role='button' tabIndex='0' ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyPress={this.handleItemKeyPress} data-index={i}> + {text} + </a> + </li> + ); + } + + render () { + const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = this.props; + const { mounted } = this.state; + + return ( + <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}> + {({ opacity, scaleX, scaleY }) => ( + // It should not be transformed when mounting because the resulting + // size will be used to determine the coordinate of the menu by + // react-overlays + <div className='dropdown-menu' style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} ref={this.setRef}> + <div className={`dropdown-menu__arrow ${placement}`} style={{ left: arrowOffsetLeft, top: arrowOffsetTop }} /> + + <ul> + {items.map((option, i) => this.renderItem(option, i))} + </ul> + </div> + )} + </Motion> + ); + } + +} + +export default class Dropdown extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + icon: PropTypes.string.isRequired, + items: PropTypes.array.isRequired, + size: PropTypes.number.isRequired, + title: PropTypes.string, + disabled: PropTypes.bool, + status: ImmutablePropTypes.map, + isUserTouching: PropTypes.func, + isModalOpen: PropTypes.bool.isRequired, + onOpen: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + dropdownPlacement: PropTypes.string, + openDropdownId: PropTypes.number, + openedViaKeyboard: PropTypes.bool, + }; + + static defaultProps = { + title: 'Menu', + }; + + state = { + id: id++, + }; + + handleClick = ({ target, type }) => { + if (this.state.id === this.props.openDropdownId) { + this.handleClose(); + } else { + const { top } = target.getBoundingClientRect(); + const placement = top * 2 < innerHeight ? 'bottom' : 'top'; + this.props.onOpen(this.state.id, this.handleItemClick, placement, type !== 'click'); + } + } + + handleClose = () => { + if (this.activeElement) { + this.activeElement.focus(); + this.activeElement = null; + } + this.props.onClose(this.state.id); + } + + handleMouseDown = () => { + if (!this.state.open) { + this.activeElement = document.activeElement; + } + } + + handleButtonKeyDown = (e) => { + switch(e.key) { + case ' ': + case 'Enter': + this.handleMouseDown(); + break; + } + } + + handleKeyPress = (e) => { + switch(e.key) { + case ' ': + case 'Enter': + this.handleClick(e); + e.stopPropagation(); + e.preventDefault(); + break; + } + } + + handleItemClick = (i, e) => { + const { action, to } = this.props.items[i]; + + this.handleClose(); + + if (typeof action === 'function') { + e.preventDefault(); + action(); + } else if (to) { + e.preventDefault(); + this.context.router.history.push(to); + } + } + + setTargetRef = c => { + this.target = c; + } + + findTarget = () => { + return this.target; + } + + componentWillUnmount = () => { + if (this.state.id === this.props.openDropdownId) { + this.handleClose(); + } + } + + render () { + const { icon, items, size, title, disabled, dropdownPlacement, openDropdownId, openedViaKeyboard } = this.props; + const open = this.state.id === openDropdownId; + + return ( + <div> + <IconButton + icon={icon} + title={title} + active={open} + disabled={disabled} + size={size} + ref={this.setTargetRef} + onClick={this.handleClick} + onMouseDown={this.handleMouseDown} + onKeyDown={this.handleButtonKeyDown} + onKeyPress={this.handleKeyPress} + /> + + <Overlay show={open} placement={dropdownPlacement} target={this.findTarget}> + <DropdownMenu items={items} onClose={this.handleClose} openedViaKeyboard={openedViaKeyboard} /> + </Overlay> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/error_boundary.js b/app/javascript/flavours/glitch/components/error_boundary.js new file mode 100644 index 000000000..8998802b1 --- /dev/null +++ b/app/javascript/flavours/glitch/components/error_boundary.js @@ -0,0 +1,113 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import { preferencesLink } from 'flavours/glitch/util/backend_links'; +import StackTrace from 'stacktrace-js'; + +export default class ErrorBoundary extends React.PureComponent { + + static propTypes = { + children: PropTypes.node, + }; + + state = { + hasError: false, + errorMessage: undefined, + stackTrace: undefined, + mappedStackTrace: undefined, + componentStack: undefined, + } + + componentDidCatch(error, info) { + this.setState({ + hasError: true, + errorMessage: error.toString(), + stackTrace: error.stack, + componentStack: info && info.componentStack, + mappedStackTrace: undefined, + }); + + StackTrace.fromError(error).then((stackframes) => { + this.setState({ + mappedStackTrace: stackframes.map((sf) => sf.toString()).join('\n'), + }); + }).catch(() => { + this.setState({ + mappedStackTrace: undefined, + }); + }); + } + + handleReload(e) { + e.preventDefault(); + window.location.reload(); + } + + render() { + const { hasError, errorMessage, stackTrace, mappedStackTrace, componentStack } = this.state; + + if (!hasError) return this.props.children; + + let debugInfo = ''; + if (stackTrace) { + debugInfo += 'Stack trace\n-----------\n\n```\n' + errorMessage + '\n' + stackTrace.toString() + '\n```'; + } + if (mappedStackTrace) { + debugInfo += 'Mapped stack trace\n-----------\n\n```\n' + errorMessage + '\n' + mappedStackTrace.toString() + '\n```'; + } + if (componentStack) { + if (debugInfo) { + debugInfo += '\n\n\n'; + } + debugInfo += 'React component stack\n---------------------\n\n```\n' + componentStack.toString() + '\n```'; + } + + return ( + <div tabIndex='-1'> + <div className='error-boundary'> + <h1><FormattedMessage id='web_app_crash.title' defaultMessage="We're sorry, but something went wrong with the Mastodon app." /></h1> + <p> + <FormattedMessage id='web_app_crash.content' defaultMessage='You could try any of the following:' /> + </p> + <ul> + <li> + <FormattedMessage + id='web_app_crash.report_issue' + defaultMessage='Report a bug in the {issuetracker}' + values={{ issuetracker: <a href='https://github.com/glitch-soc/mastodon/issues' rel='noopener noreferrer' target='_blank'><FormattedMessage id='web_app_crash.issue_tracker' defaultMessage='issue tracker' /></a> }} + /> + { debugInfo !== '' && ( + <details> + <summary><FormattedMessage id='web_app_crash.debug_info' defaultMessage='Debug information' /></summary> + <textarea + className='web_app_crash-stacktrace' + value={debugInfo} + rows='10' + readOnly + /> + </details> + )} + </li> + <li> + <FormattedMessage + id='web_app_crash.reload_page' + defaultMessage='{reload} the current page' + values={{ reload: <a href='#' onClick={this.handleReload}><FormattedMessage id='web_app_crash.reload' defaultMessage='Reload' /></a> }} + /> + </li> + { preferencesLink !== undefined && ( + <li> + <FormattedMessage + id='web_app_crash.change_your_settings' + defaultMessage='Change your {settings}' + values={{ settings: <a href={preferencesLink}><FormattedMessage id='web_app_crash.settings' defaultMessage='settings' /></a> }} + /> + </li> + )} + </ul> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/gifv.js b/app/javascript/flavours/glitch/components/gifv.js new file mode 100644 index 000000000..83cfae49c --- /dev/null +++ b/app/javascript/flavours/glitch/components/gifv.js @@ -0,0 +1,75 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +export default class GIFV extends React.PureComponent { + + static propTypes = { + src: PropTypes.string.isRequired, + alt: PropTypes.string, + width: PropTypes.number, + height: PropTypes.number, + onClick: PropTypes.func, + }; + + state = { + loading: true, + }; + + handleLoadedData = () => { + this.setState({ loading: false }); + } + + componentWillReceiveProps (nextProps) { + if (nextProps.src !== this.props.src) { + this.setState({ loading: true }); + } + } + + handleClick = e => { + const { onClick } = this.props; + + if (onClick) { + e.stopPropagation(); + onClick(); + } + } + + render () { + const { src, width, height, alt } = this.props; + const { loading } = this.state; + + return ( + <div className='gifv' style={{ position: 'relative' }}> + {loading && ( + <canvas + width={width} + height={height} + role='button' + tabIndex='0' + aria-label={alt} + title={alt} + onClick={this.handleClick} + /> + )} + + <video + src={src} + width={width} + height={height} + role='button' + tabIndex='0' + aria-label={alt} + title={alt} + muted + loop + autoPlay + playsInline + onClick={this.handleClick} + onLoadedData={this.handleLoadedData} + style={{ position: loading ? 'absolute' : 'static', top: 0, left: 0 }} + /> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/hashtag.js b/app/javascript/flavours/glitch/components/hashtag.js new file mode 100644 index 000000000..d42bee0e9 --- /dev/null +++ b/app/javascript/flavours/glitch/components/hashtag.js @@ -0,0 +1,34 @@ +import React from 'react'; +import { Sparklines, SparklinesCurve } from 'react-sparklines'; +import { FormattedMessage } from 'react-intl'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Permalink from './permalink'; +import { shortNumberFormat } from 'flavours/glitch/util/numbers'; + +const Hashtag = ({ hashtag }) => ( + <div className='trends__item'> + <div className='trends__item__name'> + <Permalink href={hashtag.get('url')} to={`/timelines/tag/${hashtag.get('name')}`}> + #<span>{hashtag.get('name')}</span> + </Permalink> + + <FormattedMessage id='trends.count_by_accounts' defaultMessage='{count} {rawCount, plural, one {person} other {people}} talking' values={{ rawCount: hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1, count: <strong>{shortNumberFormat(hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1)}</strong> }} /> + </div> + + <div className='trends__item__current'> + {shortNumberFormat(hashtag.getIn(['history', 0, 'uses']) * 1 + hashtag.getIn(['history', 1, 'uses']) * 1)} + </div> + + <div className='trends__item__sparkline'> + <Sparklines width={50} height={28} data={hashtag.get('history').reverse().map(day => day.get('uses')).toArray()}> + <SparklinesCurve style={{ fill: 'none' }} /> + </Sparklines> + </div> + </div> +); + +Hashtag.propTypes = { + hashtag: ImmutablePropTypes.map.isRequired, +}; + +export default Hashtag; diff --git a/app/javascript/flavours/glitch/components/icon.js b/app/javascript/flavours/glitch/components/icon.js new file mode 100644 index 000000000..d8a17722f --- /dev/null +++ b/app/javascript/flavours/glitch/components/icon.js @@ -0,0 +1,21 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +export default class Icon extends React.PureComponent { + + static propTypes = { + id: PropTypes.string.isRequired, + className: PropTypes.string, + fixedWidth: PropTypes.bool, + }; + + render () { + const { id, className, fixedWidth, ...other } = this.props; + + return ( + <i role='img' className={classNames('fa', `fa-${id}`, className, { 'fa-fw': fixedWidth })} {...other} /> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/icon_button.js b/app/javascript/flavours/glitch/components/icon_button.js new file mode 100644 index 000000000..e134d0a39 --- /dev/null +++ b/app/javascript/flavours/glitch/components/icon_button.js @@ -0,0 +1,144 @@ +import React from 'react'; +import Motion from 'flavours/glitch/util/optional_motion'; +import spring from 'react-motion/lib/spring'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import Icon from 'flavours/glitch/components/icon'; + +export default class IconButton extends React.PureComponent { + + static propTypes = { + className: PropTypes.string, + title: PropTypes.string.isRequired, + icon: PropTypes.string.isRequired, + onClick: PropTypes.func, + onMouseDown: PropTypes.func, + onKeyDown: PropTypes.func, + onKeyPress: PropTypes.func, + size: PropTypes.number, + active: PropTypes.bool, + pressed: PropTypes.bool, + expanded: PropTypes.bool, + style: PropTypes.object, + activeStyle: PropTypes.object, + disabled: PropTypes.bool, + inverted: PropTypes.bool, + animate: PropTypes.bool, + overlay: PropTypes.bool, + tabIndex: PropTypes.string, + label: PropTypes.string, + }; + + static defaultProps = { + size: 18, + active: false, + disabled: false, + animate: false, + overlay: false, + tabIndex: '0', + }; + + state = { + activate: false, + deactivate: false, + } + + componentWillReceiveProps (nextProps) { + if (!nextProps.animate) return; + + if (this.props.active && !nextProps.active) { + this.setState({ activate: false, deactivate: true }); + } else if (!this.props.active && nextProps.active) { + this.setState({ activate: true, deactivate: false }); + } + } + + handleClick = (e) => { + e.preventDefault(); + + if (!this.props.disabled) { + this.props.onClick(e); + } + } + + handleKeyPress = (e) => { + if (this.props.onKeyPress && !this.props.disabled) { + this.props.onKeyPress(e); + } + } + + handleMouseDown = (e) => { + if (!this.props.disabled && this.props.onMouseDown) { + this.props.onMouseDown(e); + } + } + + handleKeyDown = (e) => { + if (!this.props.disabled && this.props.onKeyDown) { + this.props.onKeyDown(e); + } + } + + render () { + let style = { + fontSize: `${this.props.size}px`, + height: `${this.props.size * 1.28571429}px`, + lineHeight: `${this.props.size}px`, + ...this.props.style, + ...(this.props.active ? this.props.activeStyle : {}), + }; + if (!this.props.label) { + style.width = `${this.props.size * 1.28571429}px`; + } else { + style.textAlign = 'left'; + } + + const { + active, + className, + disabled, + expanded, + icon, + inverted, + overlay, + pressed, + tabIndex, + title, + } = this.props; + + const { + activate, + deactivate, + } = this.state; + + const classes = classNames(className, 'icon-button', { + active, + disabled, + inverted, + activate, + deactivate, + overlayed: overlay, + }); + + return ( + <button + aria-label={title} + aria-pressed={pressed} + aria-expanded={expanded} + title={title} + className={classes} + onClick={this.handleClick} + onMouseDown={this.handleMouseDown} + onKeyDown={this.handleKeyDown} + onKeyPress={this.handleKeyPress} + style={style} + tabIndex={tabIndex} + disabled={disabled} + > + <Icon id={icon} fixedWidth aria-hidden='true' /> + {this.props.label} + </button> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/icon_with_badge.js b/app/javascript/flavours/glitch/components/icon_with_badge.js new file mode 100644 index 000000000..219efc28c --- /dev/null +++ b/app/javascript/flavours/glitch/components/icon_with_badge.js @@ -0,0 +1,20 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Icon from 'flavours/glitch/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/flavours/glitch/components/intersection_observer_article.js b/app/javascript/flavours/glitch/components/intersection_observer_article.js new file mode 100644 index 000000000..88f29892e --- /dev/null +++ b/app/javascript/flavours/glitch/components/intersection_observer_article.js @@ -0,0 +1,130 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import scheduleIdleTask from 'flavours/glitch/util/schedule_idle_task'; +import getRectFromEntry from 'flavours/glitch/util/get_rect_from_entry'; + +// Diff these props in the "unrendered" state +const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight']; + +export default class IntersectionObserverArticle extends React.Component { + + static propTypes = { + intersectionObserverWrapper: PropTypes.object.isRequired, + id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + index: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + listLength: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + saveHeightKey: PropTypes.string, + cachedHeight: PropTypes.number, + onHeightChange: PropTypes.func, + children: PropTypes.node, + }; + + state = { + isHidden: false, // set to true in requestIdleCallback to trigger un-render + } + + shouldComponentUpdate (nextProps, nextState) { + const isUnrendered = !this.state.isIntersecting && (this.state.isHidden || this.props.cachedHeight); + const willBeUnrendered = !nextState.isIntersecting && (nextState.isHidden || nextProps.cachedHeight); + if (!!isUnrendered !== !!willBeUnrendered) { + // If we're going from rendered to unrendered (or vice versa) then update + return true; + } + // If we are and remain hidden, diff based on props + if (isUnrendered) { + return !updateOnPropsForUnrendered.every(prop => nextProps[prop] === this.props[prop]); + } + // Else, assume the children have changed + return true; + } + + + componentDidMount () { + const { intersectionObserverWrapper, id } = this.props; + + intersectionObserverWrapper.observe( + id, + this.node, + this.handleIntersection, + ); + + this.componentMounted = true; + } + + componentWillUnmount () { + const { intersectionObserverWrapper, id } = this.props; + intersectionObserverWrapper.unobserve(id, this.node); + + this.componentMounted = false; + } + + handleIntersection = (entry) => { + this.entry = entry; + + scheduleIdleTask(this.calculateHeight); + this.setState(this.updateStateAfterIntersection); + } + + updateStateAfterIntersection = (prevState) => { + if (prevState.isIntersecting !== false && !this.entry.isIntersecting) { + scheduleIdleTask(this.hideIfNotIntersecting); + } + return { + isIntersecting: this.entry.isIntersecting, + isHidden: false, + }; + } + + calculateHeight = () => { + const { onHeightChange, saveHeightKey, id } = this.props; + // save the height of the fully-rendered element (this is expensive + // on Chrome, where we need to fall back to getBoundingClientRect) + this.height = getRectFromEntry(this.entry).height; + + if (onHeightChange && saveHeightKey) { + onHeightChange(saveHeightKey, id, this.height); + } + } + + hideIfNotIntersecting = () => { + if (!this.componentMounted) { + return; + } + + // When the browser gets a chance, test if we're still not intersecting, + // and if so, set our isHidden to true to trigger an unrender. The point of + // this is to save DOM nodes and avoid using up too much memory. + // See: https://github.com/tootsuite/mastodon/issues/2900 + this.setState((prevState) => ({ isHidden: !prevState.isIntersecting })); + } + + handleRef = (node) => { + this.node = node; + } + + render () { + const { children, id, index, listLength, cachedHeight } = this.props; + const { isIntersecting, isHidden } = this.state; + + const style = {}; + + if (!isIntersecting && (isHidden || cachedHeight)) { + style.height = `${this.height || cachedHeight || 150}px`; + style.opacity = 0; + style.overflow = 'hidden'; + } + + return ( + <article + ref={this.handleRef} + aria-posinset={index + 1} + aria-setsize={listLength} + data-id={id} + tabIndex='0' + style={style}> + {children && React.cloneElement(children, { hidden: !isIntersecting && (isHidden || !!cachedHeight) })} + </article> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/link.js b/app/javascript/flavours/glitch/components/link.js new file mode 100644 index 000000000..de99f7d42 --- /dev/null +++ b/app/javascript/flavours/glitch/components/link.js @@ -0,0 +1,97 @@ +// Inspired by <CommonLink> from Mastodon GO! +// ~ 😘 kibi! + +// Package imports. +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React from 'react'; + +// Utils. +import { assignHandlers } from 'flavours/glitch/util/react_helpers'; + +// Handlers. +const handlers = { + + // We don't handle clicks that are made with modifiers, since these + // often have special browser meanings (eg, "open in new tab"). + click (e) { + const { onClick } = this.props; + if (!onClick || e.button || e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) { + return; + } + onClick(e); + e.preventDefault(); // Prevents following of the link + }, +}; + +// The component. +export default class Link extends React.PureComponent { + + // Constructor. + constructor (props) { + super(props); + assignHandlers(this, handlers); + } + + // Rendering. + render () { + const { click } = this.handlers; + const { + children, + className, + href, + onClick, + role, + title, + ...rest + } = this.props; + const computedClass = classNames('link', className, `role-${role}`); + + // We assume that our `onClick` is a routing function and give it + // the qualities of a link even if no `href` is provided. However, + // if we have neither an `onClick` or an `href`, our link is + // purely presentational. + const conditionalProps = {}; + if (href) { + conditionalProps.href = href; + conditionalProps.onClick = click; + } else if (onClick) { + conditionalProps.onClick = click; + conditionalProps.role = 'link'; + conditionalProps.tabIndex = 0; + } else { + conditionalProps.role = 'presentation'; + } + + // If we were provided a `role` it overwrites any that we may have + // set above. This can be used for "links" which are actually + // buttons. + if (role) { + conditionalProps.role = role; + } + + // Rendering. We set `rel='noopener'` for user privacy, and our + // `target` as `'_blank'`. + return ( + <a + className={computedClass} + {...conditionalProps} + rel='noopener' + target='_blank' + title={title} + {...rest} + >{children}</a> + ); + } + +} + +// Props. +Link.propTypes = { + children: PropTypes.node, + className: PropTypes.string, + href: PropTypes.string, // The link destination + onClick: PropTypes.func, // A function to call instead of opening the link + role: PropTypes.string, // An ARIA role for the link + title: PropTypes.string, // A title for the link +}; diff --git a/app/javascript/flavours/glitch/components/load_gap.js b/app/javascript/flavours/glitch/components/load_gap.js new file mode 100644 index 000000000..fe3f60a58 --- /dev/null +++ b/app/javascript/flavours/glitch/components/load_gap.js @@ -0,0 +1,34 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, defineMessages } from 'react-intl'; +import Icon from 'flavours/glitch/components/icon'; + +const messages = defineMessages({ + load_more: { id: 'status.load_more', defaultMessage: 'Load more' }, +}); + +export default @injectIntl +class LoadGap extends React.PureComponent { + + static propTypes = { + disabled: PropTypes.bool, + maxId: PropTypes.string, + onClick: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + handleClick = () => { + this.props.onClick(this.props.maxId); + } + + render () { + const { disabled, intl } = this.props; + + return ( + <button className='load-more load-gap' disabled={disabled} onClick={this.handleClick} aria-label={intl.formatMessage(messages.load_more)}> + <Icon id='ellipsis-h' /> + </button> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/load_more.js b/app/javascript/flavours/glitch/components/load_more.js new file mode 100644 index 000000000..389c3e1e1 --- /dev/null +++ b/app/javascript/flavours/glitch/components/load_more.js @@ -0,0 +1,27 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import PropTypes from 'prop-types'; + +export default class LoadMore extends React.PureComponent { + + static propTypes = { + onClick: PropTypes.func, + disabled: PropTypes.bool, + visible: PropTypes.bool, + } + + static defaultProps = { + visible: true, + } + + render() { + const { disabled, visible } = this.props; + + return ( + <button className='load-more' disabled={disabled || !visible} style={{ visibility: visible ? 'visible' : 'hidden' }} onClick={this.props.onClick}> + <FormattedMessage id='status.load_more' defaultMessage='Load more' /> + </button> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/load_pending.js b/app/javascript/flavours/glitch/components/load_pending.js new file mode 100644 index 000000000..7e2702403 --- /dev/null +++ b/app/javascript/flavours/glitch/components/load_pending.js @@ -0,0 +1,22 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import PropTypes from 'prop-types'; + +export default class LoadPending extends React.PureComponent { + + static propTypes = { + onClick: PropTypes.func, + count: PropTypes.number, + } + + render() { + const { count } = this.props; + + return ( + <button className='load-more load-gap' onClick={this.props.onClick}> + <FormattedMessage id='load_pending' defaultMessage='{count, plural, one {# new item} other {# new items}}' values={{ count }} /> + </button> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/loading_indicator.js b/app/javascript/flavours/glitch/components/loading_indicator.js new file mode 100644 index 000000000..d6a5adb6f --- /dev/null +++ b/app/javascript/flavours/glitch/components/loading_indicator.js @@ -0,0 +1,11 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; + +const LoadingIndicator = () => ( + <div className='loading-indicator'> + <div className='loading-indicator__figure' /> + <FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' /> + </div> +); + +export default LoadingIndicator; diff --git a/app/javascript/flavours/glitch/components/media_gallery.js b/app/javascript/flavours/glitch/components/media_gallery.js new file mode 100644 index 000000000..973406ad2 --- /dev/null +++ b/app/javascript/flavours/glitch/components/media_gallery.js @@ -0,0 +1,396 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { is } from 'immutable'; +import IconButton from './icon_button'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { isIOS } from 'flavours/glitch/util/is_mobile'; +import classNames from 'classnames'; +import { autoPlayGif, displayMedia, useBlurhash } from 'flavours/glitch/util/initial_state'; +import { decode } from 'blurhash'; + +const messages = defineMessages({ + hidden: { + defaultMessage: 'Media hidden', + id: 'status.media_hidden', + }, + sensitive: { + defaultMessage: 'Sensitive', + id: 'media_gallery.sensitive', + }, + toggle: { + defaultMessage: 'Click to view', + id: 'status.sensitive_toggle', + }, + toggle_visible: { + defaultMessage: 'Hide {number, plural, one {image} other {images}}', + id: 'media_gallery.toggle_visible', + }, + warning: { + defaultMessage: 'Sensitive content', + id: 'status.sensitive_warning', + }, +}); + +class Item extends React.PureComponent { + + static propTypes = { + attachment: ImmutablePropTypes.map.isRequired, + standalone: PropTypes.bool, + index: PropTypes.number.isRequired, + size: PropTypes.number.isRequired, + letterbox: PropTypes.bool, + onClick: PropTypes.func.isRequired, + displayWidth: PropTypes.number, + visible: PropTypes.bool.isRequired, + autoplay: PropTypes.bool, + }; + + static defaultProps = { + standalone: false, + index: 0, + size: 1, + }; + + state = { + loaded: false, + }; + + handleMouseEnter = (e) => { + if (this.hoverToPlay()) { + e.target.play(); + } + } + + handleMouseLeave = (e) => { + if (this.hoverToPlay()) { + e.target.pause(); + e.target.currentTime = 0; + } + } + + getAutoPlay() { + return this.props.autoplay || autoPlayGif; + } + + hoverToPlay () { + const { attachment } = this.props; + return !this.getAutoPlay() && attachment.get('type') === 'gifv'; + } + + handleClick = (e) => { + const { index, onClick } = this.props; + + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + if (this.hoverToPlay()) { + e.target.pause(); + e.target.currentTime = 0; + } + e.preventDefault(); + onClick(index); + } + + e.stopPropagation(); + } + + componentDidMount () { + if (this.props.attachment.get('blurhash')) { + this._decode(); + } + } + + componentDidUpdate (prevProps) { + if (prevProps.attachment.get('blurhash') !== this.props.attachment.get('blurhash') && this.props.attachment.get('blurhash')) { + this._decode(); + } + } + + _decode () { + if (!useBlurhash) return; + + const hash = this.props.attachment.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); + } + } + + setCanvasRef = c => { + this.canvas = c; + } + + handleImageLoad = () => { + this.setState({ loaded: true }); + } + + render () { + const { attachment, index, size, standalone, letterbox, displayWidth, visible } = this.props; + + let width = 50; + let height = 100; + let top = 'auto'; + let left = 'auto'; + let bottom = 'auto'; + let right = 'auto'; + + if (size === 1) { + width = 100; + } + + if (size === 4 || (size === 3 && index > 0)) { + height = 50; + } + + if (size === 2) { + if (index === 0) { + right = '2px'; + } else { + left = '2px'; + } + } else if (size === 3) { + if (index === 0) { + right = '2px'; + } else if (index > 0) { + left = '2px'; + } + + if (index === 1) { + bottom = '2px'; + } else if (index > 1) { + top = '2px'; + } + } else if (size === 4) { + if (index === 0 || index === 2) { + right = '2px'; + } + + if (index === 1 || index === 3) { + left = '2px'; + } + + if (index < 2) { + bottom = '2px'; + } else { + top = '2px'; + } + } + + let thumbnail = ''; + + if (attachment.get('type') === 'unknown') { + return ( + <div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}> + <a className='media-gallery__item-thumbnail' href={attachment.get('remote_url') || attachment.get('url')} style={{ cursor: 'pointer' }} title={attachment.get('description')} target='_blank' rel='noopener noreferrer'> + <canvas width={32} height={32} ref={this.setCanvasRef} className='media-gallery__preview' /> + </a> + </div> + ); + } else if (attachment.get('type') === 'image') { + const previewUrl = attachment.get('preview_url'); + const previewWidth = attachment.getIn(['meta', 'small', 'width']); + + const originalUrl = attachment.get('url'); + const originalWidth = attachment.getIn(['meta', 'original', 'width']); + + const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number'; + + const srcSet = hasSize ? `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w` : null; + const sizes = hasSize && (displayWidth > 0) ? `${displayWidth * (width / 100)}px` : null; + + const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0; + const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0; + const x = ((focusX / 2) + .5) * 100; + const y = ((focusY / -2) + .5) * 100; + + thumbnail = ( + <a + className='media-gallery__item-thumbnail' + href={attachment.get('remote_url') || originalUrl} + onClick={this.handleClick} + target='_blank' + rel='noopener noreferrer' + > + <img + className={letterbox ? 'letterbox' : null} + src={previewUrl} + srcSet={srcSet} + sizes={sizes} + alt={attachment.get('description')} + title={attachment.get('description')} + style={{ objectPosition: letterbox ? null : `${x}% ${y}%` }} + onLoad={this.handleImageLoad} + /> + </a> + ); + } else if (attachment.get('type') === 'gifv') { + const autoPlay = !isIOS() && this.getAutoPlay(); + + thumbnail = ( + <div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}> + <video + className={`media-gallery__item-gifv-thumbnail${letterbox ? ' letterbox' : ''}`} + aria-label={attachment.get('description')} + title={attachment.get('description')} + role='application' + src={attachment.get('url')} + onClick={this.handleClick} + onMouseEnter={this.handleMouseEnter} + onMouseLeave={this.handleMouseLeave} + autoPlay={autoPlay} + loop + muted + /> + + <span className='media-gallery__gifv__label'>GIF</span> + </div> + ); + } + + return ( + <div className={classNames('media-gallery__item', { standalone, letterbox })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}> + <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && this.state.loaded })} /> + {visible && thumbnail} + </div> + ); + } + +} + +export default @injectIntl +class MediaGallery extends React.PureComponent { + + static propTypes = { + sensitive: PropTypes.bool, + standalone: PropTypes.bool, + letterbox: PropTypes.bool, + fullwidth: PropTypes.bool, + hidden: PropTypes.bool, + media: ImmutablePropTypes.list.isRequired, + size: PropTypes.object, + onOpenMedia: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + defaultWidth: PropTypes.number, + cacheWidth: PropTypes.func, + visible: PropTypes.bool, + autoplay: PropTypes.bool, + onToggleVisibility: PropTypes.func, + }; + + static defaultProps = { + standalone: false, + }; + + state = { + 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.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 }); + } + } + + componentDidUpdate (prevProps) { + if (this.node && this.node.offsetWidth && this.node.offsetWidth != this.state.width) { + this.setState({ + width: this.node.offsetWidth, + }); + } + } + + handleOpen = () => { + if (this.props.onToggleVisibility) { + this.props.onToggleVisibility(); + } else { + this.setState({ visible: !this.state.visible }); + } + } + + handleClick = (index) => { + this.props.onOpenMedia(this.props.media, index); + } + + handleRef = (node) => { + this.node = node; + if (node && node.offsetWidth && node.offsetWidth != this.state.width) { + if (this.props.cacheWidth) this.props.cacheWidth(node.offsetWidth); + + this.setState({ + width: node.offsetWidth, + }); + } + } + + isStandaloneEligible() { + const { media, standalone } = this.props; + return standalone && media.size === 1 && media.getIn([0, 'meta', 'small', 'aspect']); + } + + render () { + const { media, intl, sensitive, letterbox, fullwidth, defaultWidth, autoplay } = this.props; + const { visible } = this.state; + const size = media.take(4).size; + const uncached = media.every(attachment => attachment.get('type') === 'unknown'); + + const width = this.state.width || defaultWidth; + + let children, spoilerButton; + + const style = {}; + + const computedClass = classNames('media-gallery', { 'full-width': fullwidth }); + + if (this.isStandaloneEligible() && width) { + style.height = width / this.props.media.getIn([0, 'meta', 'small', 'aspect']); + } else if (width) { + style.height = width / (16/9); + } else { + return (<div className={computedClass} ref={this.handleRef}></div>); + } + + if (this.isStandaloneEligible()) { + children = <Item standalone autoplay={autoplay} onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} visible={visible} />; + } else { + children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} autoplay={autoplay} onClick={this.handleClick} attachment={attachment} index={i} size={size} letterbox={letterbox} displayWidth={width} visible={visible || uncached} />); + } + + if (uncached) { + spoilerButton = ( + <button type='button' disabled className='spoiler-button__overlay'> + <span className='spoiler-button__overlay__label'><FormattedMessage id='status.uncached_media_warning' defaultMessage='Not available' /></span> + </button> + ); + } else if (visible) { + spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible, { number: size })} icon='eye-slash' overlay onClick={this.handleOpen} />; + } else { + spoilerButton = ( + <button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'> + <span className='spoiler-button__overlay__label'>{sensitive ? <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /> : <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />}</span> + </button> + ); + } + + return ( + <div className={computedClass} style={style} ref={this.handleRef}> + <div className={classNames('spoiler-button', { 'spoiler-button--minified': visible && !uncached, 'spoiler-button--click-thru': uncached })}> + {spoilerButton} + {visible && sensitive && ( + <span className='sensitive-marker'> + <FormattedMessage {...messages.sensitive} /> + </span> + )} + </div> + + {children} + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/missing_indicator.js b/app/javascript/flavours/glitch/components/missing_indicator.js new file mode 100644 index 000000000..ee5bf7c1e --- /dev/null +++ b/app/javascript/flavours/glitch/components/missing_indicator.js @@ -0,0 +1,24 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import illustration from 'flavours/glitch/images/elephant_ui_disappointed.svg'; +import classNames from 'classnames'; + +const MissingIndicator = ({ fullPage }) => ( + <div className={classNames('regeneration-indicator', { 'regeneration-indicator--without-header': fullPage })}> + <div className='regeneration-indicator__figure'> + <img src={illustration} alt='' /> + </div> + + <div className='regeneration-indicator__label'> + <FormattedMessage id='missing_indicator.label' tagName='strong' defaultMessage='Not found' /> + <FormattedMessage id='missing_indicator.sublabel' defaultMessage='This resource could not be found' /> + </div> + </div> +); + +MissingIndicator.propTypes = { + fullPage: PropTypes.bool, +}; + +export default MissingIndicator; diff --git a/app/javascript/flavours/glitch/components/modal_root.js b/app/javascript/flavours/glitch/components/modal_root.js new file mode 100644 index 000000000..8d73a1e40 --- /dev/null +++ b/app/javascript/flavours/glitch/components/modal_root.js @@ -0,0 +1,143 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import 'wicg-inert'; +import { createBrowserHistory } from 'history'; + +export default class ModalRoot extends React.PureComponent { + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + children: PropTypes.node, + onClose: PropTypes.func.isRequired, + noEsc: PropTypes.bool, + }; + + state = { + revealed: !!this.props.children, + }; + + activeElement = this.state.revealed ? document.activeElement : null; + + handleKeyUp = (e) => { + if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27) + && !!this.props.children && !this.props.noEsc) { + this.props.onClose(); + } + } + + handleKeyDown = (e) => { + if (e.key === 'Tab') { + const focusable = Array.from(this.node.querySelectorAll('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')).filter((x) => window.getComputedStyle(x).display !== 'none'); + const index = focusable.indexOf(e.target); + + let element; + + if (e.shiftKey) { + element = focusable[index - 1] || focusable[focusable.length - 1]; + } else { + element = focusable[index + 1] || focusable[0]; + } + + if (element) { + element.focus(); + e.stopPropagation(); + e.preventDefault(); + } + } + } + + componentDidMount () { + window.addEventListener('keyup', this.handleKeyUp, false); + window.addEventListener('keydown', this.handleKeyDown, false); + this.history = this.context.router ? this.context.router.history : createBrowserHistory(); + } + + componentWillReceiveProps (nextProps) { + if (!!nextProps.children && !this.props.children) { + this.activeElement = document.activeElement; + + this.getSiblings().forEach(sibling => sibling.setAttribute('inert', true)); + } else if (!nextProps.children) { + this.setState({ revealed: false }); + } + } + + componentDidUpdate (prevProps) { + if (!this.props.children && !!prevProps.children) { + this.getSiblings().forEach(sibling => sibling.removeAttribute('inert')); + + // Because of the wicg-inert polyfill, the activeElement may not be + // immediately selectable, we have to wait for observers to run, as + // described in https://github.com/WICG/inert#performance-and-gotchas + Promise.resolve().then(() => { + this.activeElement.focus({ preventScroll: true }); + this.activeElement = null; + }).catch((error) => { + console.error(error); + }); + + this.handleModalClose(); + } + if (this.props.children) { + requestAnimationFrame(() => { + this.setState({ revealed: true }); + }); + if (!prevProps.children) this.handleModalOpen(); + } + } + + componentWillUnmount () { + window.removeEventListener('keyup', this.handleKeyUp); + window.removeEventListener('keydown', this.handleKeyDown); + } + + handleModalClose () { + this.unlistenHistory(); + + const state = this.history.location.state; + if (state && state.mastodonModalOpen) { + this.history.goBack(); + } + } + + handleModalOpen () { + const history = this.history; + const state = {...history.location.state, mastodonModalOpen: true}; + history.push(history.location.pathname, state); + this.unlistenHistory = history.listen(() => { + this.props.onClose(); + }); + } + + getSiblings = () => { + return Array(...this.node.parentElement.childNodes).filter(node => node !== this.node); + } + + setRef = ref => { + this.node = ref; + } + + render () { + const { children, onClose } = this.props; + const { revealed } = this.state; + const visible = !!children; + + if (!visible) { + return ( + <div className='modal-root' ref={this.setRef} style={{ opacity: 0 }} /> + ); + } + + return ( + <div className='modal-root' ref={this.setRef} style={{ opacity: revealed ? 1 : 0 }}> + <div style={{ pointerEvents: visible ? 'auto' : 'none' }}> + <div role='presentation' className='modal-root__overlay' onClick={onClose} /> + <div role='dialog' className='modal-root__container'>{children}</div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/notification_purge_buttons.js b/app/javascript/flavours/glitch/components/notification_purge_buttons.js new file mode 100644 index 000000000..3c7d67109 --- /dev/null +++ b/app/javascript/flavours/glitch/components/notification_purge_buttons.js @@ -0,0 +1,59 @@ +/** + * Buttons widget for controlling the notification clearing mode. + * In idle state, the cleaning mode button is shown. When the mode is active, + * a Confirm and Abort buttons are shown in its place. + */ + + +// Package imports // +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import Icon from 'flavours/glitch/components/icon'; + +const messages = defineMessages({ + btnAll : { id: 'notification_purge.btn_all', defaultMessage: 'Select\nall' }, + btnNone : { id: 'notification_purge.btn_none', defaultMessage: 'Select\nnone' }, + btnInvert : { id: 'notification_purge.btn_invert', defaultMessage: 'Invert\nselection' }, + btnApply : { id: 'notification_purge.btn_apply', defaultMessage: 'Clear\nselected' }, +}); + +export default @injectIntl +class NotificationPurgeButtons extends ImmutablePureComponent { + + static propTypes = { + onDeleteMarked : PropTypes.func.isRequired, + onMarkAll : PropTypes.func.isRequired, + onMarkNone : PropTypes.func.isRequired, + onInvert : PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + markNewForDelete: PropTypes.bool, + }; + + render () { + const { intl, markNewForDelete } = this.props; + + //className='active' + return ( + <div className='column-header__notif-cleaning-buttons'> + <button onClick={this.props.onMarkAll} className={markNewForDelete ? 'active' : ''}> + <b>∀</b><br />{intl.formatMessage(messages.btnAll)} + </button> + + <button onClick={this.props.onMarkNone} className={!markNewForDelete ? 'active' : ''}> + <b>∅</b><br />{intl.formatMessage(messages.btnNone)} + </button> + + <button onClick={this.props.onInvert}> + <b>¬</b><br />{intl.formatMessage(messages.btnInvert)} + </button> + + <button onClick={this.props.onDeleteMarked}> + <Icon id='trash' /><br />{intl.formatMessage(messages.btnApply)} + </button> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/permalink.js b/app/javascript/flavours/glitch/components/permalink.js new file mode 100644 index 000000000..718b02115 --- /dev/null +++ b/app/javascript/flavours/glitch/components/permalink.js @@ -0,0 +1,51 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +export default class Permalink extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + className: PropTypes.string, + href: PropTypes.string.isRequired, + to: PropTypes.string.isRequired, + children: PropTypes.node, + onInterceptClick: PropTypes.func, + }; + + handleClick = (e) => { + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + if (this.props.onInterceptClick && this.props.onInterceptClick()) { + e.preventDefault(); + return; + } + + if (this.context.router) { + e.preventDefault(); + let state = {...this.context.router.history.location.state}; + state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1; + this.context.router.history.push(this.props.to, state); + } + } + } + + render () { + const { + children, + className, + href, + to, + onInterceptClick, + ...other + } = this.props; + + return ( + <a target='_blank' href={href} onClick={this.handleClick} {...other} className={`permalink${className ? ' ' + className : ''}`}> + {children} + </a> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/poll.js b/app/javascript/flavours/glitch/components/poll.js new file mode 100644 index 000000000..6f57c1950 --- /dev/null +++ b/app/javascript/flavours/glitch/components/poll.js @@ -0,0 +1,212 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import classNames from 'classnames'; +import Motion from 'flavours/glitch/util/optional_motion'; +import spring from 'react-motion/lib/spring'; +import escapeTextContentForBrowser from 'escape-html'; +import emojify from 'flavours/glitch/util/emoji'; +import RelativeTimestamp from './relative_timestamp'; +import Icon from 'flavours/glitch/components/icon'; + +const messages = defineMessages({ + closed: { id: 'poll.closed', defaultMessage: 'Closed' }, + voted: { id: 'poll.voted', defaultMessage: 'You voted for this answer', description: 'Tooltip of the "voted" checkmark in polls' }, +}); + +const makeEmojiMap = record => record.get('emojis').reduce((obj, emoji) => { + obj[`:${emoji.get('shortcode')}:`] = emoji.toJS(); + return obj; +}, {}); + +export default @injectIntl +class Poll extends ImmutablePureComponent { + + static propTypes = { + poll: ImmutablePropTypes.map, + intl: PropTypes.object.isRequired, + disabled: PropTypes.bool, + refresh: PropTypes.func, + onVote: PropTypes.func, + }; + + state = { + selected: {}, + expired: null, + }; + + static getDerivedStateFromProps (props, state) { + const { poll, intl } = props; + const expires_at = poll.get('expires_at'); + const expired = poll.get('expired') || expires_at !== null && (new Date(expires_at)).getTime() < intl.now(); + return (expired === state.expired) ? null : { expired }; + } + + componentDidMount () { + this._setupTimer(); + } + + componentDidUpdate () { + this._setupTimer(); + } + + componentWillUnmount () { + clearTimeout(this._timer); + } + + _setupTimer () { + const { poll, intl } = this.props; + clearTimeout(this._timer); + if (!this.state.expired) { + const delay = (new Date(poll.get('expires_at'))).getTime() - intl.now(); + this._timer = setTimeout(() => { + this.setState({ expired: true }); + }, delay); + } + } + + _toggleOption = value => { + if (this.props.poll.get('multiple')) { + const tmp = { ...this.state.selected }; + if (tmp[value]) { + delete tmp[value]; + } else { + tmp[value] = true; + } + this.setState({ selected: tmp }); + } else { + const tmp = {}; + tmp[value] = true; + this.setState({ selected: tmp }); + } + } + + handleOptionChange = ({ target: { value } }) => { + this._toggleOption(value); + }; + + handleOptionKeyPress = (e) => { + if (e.key === 'Enter' || e.key === ' ') { + this._toggleOption(e.target.getAttribute('data-index')); + e.stopPropagation(); + e.preventDefault(); + } + } + + handleVote = () => { + if (this.props.disabled) { + return; + } + + this.props.onVote(Object.keys(this.state.selected)); + }; + + handleRefresh = () => { + if (this.props.disabled) { + return; + } + + this.props.refresh(); + }; + + renderOption (option, optionIndex, showResults) { + const { poll, disabled, intl } = this.props; + const pollVotesCount = poll.get('voters_count') || poll.get('votes_count'); + const percent = pollVotesCount === 0 ? 0 : (option.get('votes_count') / pollVotesCount) * 100; + const leading = poll.get('options').filterNot(other => other.get('title') === option.get('title')).every(other => option.get('votes_count') >= other.get('votes_count')); + const active = !!this.state.selected[`${optionIndex}`]; + const voted = option.get('voted') || (poll.get('own_votes') && poll.get('own_votes').includes(optionIndex)); + + let titleEmojified = option.get('title_emojified'); + if (!titleEmojified) { + const emojiMap = makeEmojiMap(poll); + titleEmojified = emojify(escapeTextContentForBrowser(option.get('title')), emojiMap); + } + + return ( + <li key={option.get('title')}> + <label className={classNames('poll__option', { selectable: !showResults })}> + <input + name='vote-options' + type={poll.get('multiple') ? 'checkbox' : 'radio'} + value={optionIndex} + checked={active} + onChange={this.handleOptionChange} + disabled={disabled} + /> + + {!showResults && ( + <span + className={classNames('poll__input', { checkbox: poll.get('multiple'), active })} + tabIndex='0' + role={poll.get('multiple') ? 'checkbox' : 'radio'} + onKeyPress={this.handleOptionKeyPress} + aria-checked={active} + aria-label={option.get('title')} + data-index={optionIndex} + /> + )} + {showResults && <span className='poll__number'> + {Math.round(percent)}% + </span>} + + <span + className='poll__option__text' + dangerouslySetInnerHTML={{ __html: titleEmojified }} + /> + + {!!voted && <span className='poll__voted'> + <Icon id='check' className='poll__voted__mark' title={intl.formatMessage(messages.voted)} /> + </span>} + </label> + + {showResults && ( + <Motion defaultStyle={{ width: 0 }} style={{ width: spring(percent, { stiffness: 180, damping: 12 }) }}> + {({ width }) => + <span className={classNames('poll__chart', { leading })} style={{ width: `${width}%` }} /> + } + </Motion> + )} + </li> + ); + } + + render () { + const { poll, intl } = this.props; + const { expired } = this.state; + + if (!poll) { + return null; + } + + const timeRemaining = expired ? intl.formatMessage(messages.closed) : <RelativeTimestamp timestamp={poll.get('expires_at')} futureDate />; + const showResults = poll.get('voted') || expired; + const disabled = this.props.disabled || Object.entries(this.state.selected).every(item => !item); + + let votesCount = null; + + if (poll.get('voters_count') !== null && poll.get('voters_count') !== undefined) { + votesCount = <FormattedMessage id='poll.total_people' defaultMessage='{count, plural, one {# person} other {# people}}' values={{ count: poll.get('voters_count') }} />; + } else { + votesCount = <FormattedMessage id='poll.total_votes' defaultMessage='{count, plural, one {# vote} other {# votes}}' values={{ count: poll.get('votes_count') }} />; + } + + return ( + <div className='poll'> + <ul> + {poll.get('options').map((option, i) => this.renderOption(option, i, showResults))} + </ul> + + <div className='poll__footer'> + {!showResults && <button className='button button-secondary' disabled={disabled} onClick={this.handleVote}><FormattedMessage id='poll.vote' defaultMessage='Vote' /></button>} + {showResults && !this.props.disabled && <span><button className='poll__link' onClick={this.handleRefresh}><FormattedMessage id='poll.refresh' defaultMessage='Refresh' /></button> · </span>} + {votesCount} + {poll.get('expires_at') && <span> · {timeRemaining}</span>} + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/radio_button.js b/app/javascript/flavours/glitch/components/radio_button.js new file mode 100644 index 000000000..0496fa286 --- /dev/null +++ b/app/javascript/flavours/glitch/components/radio_button.js @@ -0,0 +1,35 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +export default class RadioButton extends React.PureComponent { + + static propTypes = { + value: PropTypes.string.isRequired, + checked: PropTypes.bool, + name: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + label: PropTypes.node.isRequired, + }; + + render () { + const { name, value, checked, onChange, label } = this.props; + + return ( + <label className='radio-button'> + <input + name={name} + type='radio' + value={value} + checked={checked} + onChange={onChange} + /> + + <span className={classNames('radio-button__input', { checked })} /> + + <span>{label}</span> + </label> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/regeneration_indicator.js b/app/javascript/flavours/glitch/components/regeneration_indicator.js new file mode 100644 index 000000000..f4e0a79ef --- /dev/null +++ b/app/javascript/flavours/glitch/components/regeneration_indicator.js @@ -0,0 +1,18 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import illustration from 'flavours/glitch/images/elephant_ui_working.svg'; + +const MissingIndicator = () => ( + <div className='regeneration-indicator'> + <div className='regeneration-indicator__figure'> + <img src={illustration} alt='' /> + </div> + + <div className='regeneration-indicator__label'> + <FormattedMessage id='regeneration_indicator.label' tagName='strong' defaultMessage='Loading…' /> + <FormattedMessage id='regeneration_indicator.sublabel' defaultMessage='Your home feed is being prepared!' /> + </div> + </div> +); + +export default MissingIndicator; diff --git a/app/javascript/flavours/glitch/components/relative_timestamp.js b/app/javascript/flavours/glitch/components/relative_timestamp.js new file mode 100644 index 000000000..711181dcd --- /dev/null +++ b/app/javascript/flavours/glitch/components/relative_timestamp.js @@ -0,0 +1,192 @@ +import React from 'react'; +import { injectIntl, defineMessages } from 'react-intl'; +import PropTypes from 'prop-types'; + +const messages = defineMessages({ + today: { id: 'relative_time.today', defaultMessage: 'today' }, + just_now: { id: 'relative_time.just_now', defaultMessage: 'now' }, + seconds: { id: 'relative_time.seconds', defaultMessage: '{number}s' }, + minutes: { id: 'relative_time.minutes', defaultMessage: '{number}m' }, + hours: { id: 'relative_time.hours', defaultMessage: '{number}h' }, + days: { id: 'relative_time.days', defaultMessage: '{number}d' }, + moments_remaining: { id: 'time_remaining.moments', defaultMessage: 'Moments remaining' }, + seconds_remaining: { id: 'time_remaining.seconds', defaultMessage: '{number, plural, one {# second} other {# seconds}} left' }, + minutes_remaining: { id: 'time_remaining.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}} left' }, + hours_remaining: { id: 'time_remaining.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}} left' }, + days_remaining: { id: 'time_remaining.days', defaultMessage: '{number, plural, one {# day} other {# days}} left' }, +}); + +const dateFormatOptions = { + hour12: false, + year: 'numeric', + month: 'short', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', +}; + +const shortDateFormatOptions = { + month: 'short', + day: 'numeric', +}; + +const SECOND = 1000; +const MINUTE = 1000 * 60; +const HOUR = 1000 * 60 * 60; +const DAY = 1000 * 60 * 60 * 24; + +const MAX_DELAY = 2147483647; + +const selectUnits = delta => { + const absDelta = Math.abs(delta); + + if (absDelta < MINUTE) { + return 'second'; + } else if (absDelta < HOUR) { + return 'minute'; + } else if (absDelta < DAY) { + return 'hour'; + } + + return 'day'; +}; + +const getUnitDelay = units => { + switch (units) { + case 'second': + return SECOND; + case 'minute': + return MINUTE; + case 'hour': + return HOUR; + case 'day': + return DAY; + default: + return MAX_DELAY; + } +}; + +export const timeAgoString = (intl, date, now, year, timeGiven = true) => { + const delta = now - date.getTime(); + + let relativeTime; + + if (delta < DAY && !timeGiven) { + relativeTime = intl.formatMessage(messages.today); + } else if (delta < 10 * SECOND) { + relativeTime = intl.formatMessage(messages.just_now); + } else if (delta < 7 * DAY) { + if (delta < MINUTE) { + relativeTime = intl.formatMessage(messages.seconds, { number: Math.floor(delta / SECOND) }); + } else if (delta < HOUR) { + relativeTime = intl.formatMessage(messages.minutes, { number: Math.floor(delta / MINUTE) }); + } else if (delta < DAY) { + relativeTime = intl.formatMessage(messages.hours, { number: Math.floor(delta / HOUR) }); + } else { + relativeTime = intl.formatMessage(messages.days, { number: Math.floor(delta / DAY) }); + } + } else if (date.getFullYear() === year) { + relativeTime = intl.formatDate(date, shortDateFormatOptions); + } else { + relativeTime = intl.formatDate(date, { ...shortDateFormatOptions, year: 'numeric' }); + } + + return relativeTime; +}; + +const timeRemainingString = (intl, date, now, timeGiven = true) => { + const delta = date.getTime() - now; + + let relativeTime; + + if (delta < DAY && !timeGiven) { + relativeTime = intl.formatMessage(messages.today); + } else if (delta < 10 * SECOND) { + relativeTime = intl.formatMessage(messages.moments_remaining); + } else if (delta < MINUTE) { + relativeTime = intl.formatMessage(messages.seconds_remaining, { number: Math.floor(delta / SECOND) }); + } else if (delta < HOUR) { + relativeTime = intl.formatMessage(messages.minutes_remaining, { number: Math.floor(delta / MINUTE) }); + } else if (delta < DAY) { + relativeTime = intl.formatMessage(messages.hours_remaining, { number: Math.floor(delta / HOUR) }); + } else { + relativeTime = intl.formatMessage(messages.days_remaining, { number: Math.floor(delta / DAY) }); + } + + return relativeTime; +}; + +export default @injectIntl +class RelativeTimestamp extends React.Component { + + static propTypes = { + intl: PropTypes.object.isRequired, + timestamp: PropTypes.string.isRequired, + year: PropTypes.number.isRequired, + futureDate: PropTypes.bool, + }; + + state = { + now: this.props.intl.now(), + }; + + static defaultProps = { + year: (new Date()).getFullYear(), + }; + + shouldComponentUpdate (nextProps, nextState) { + // As of right now the locale doesn't change without a new page load, + // but we might as well check in case that ever changes. + return this.props.timestamp !== nextProps.timestamp || + this.props.intl.locale !== nextProps.intl.locale || + this.state.now !== nextState.now; + } + + componentWillReceiveProps (nextProps) { + if (this.props.timestamp !== nextProps.timestamp) { + this.setState({ now: this.props.intl.now() }); + } + } + + componentDidMount () { + this._scheduleNextUpdate(this.props, this.state); + } + + componentWillUpdate (nextProps, nextState) { + this._scheduleNextUpdate(nextProps, nextState); + } + + componentWillUnmount () { + clearTimeout(this._timer); + } + + _scheduleNextUpdate (props, state) { + clearTimeout(this._timer); + + const { timestamp } = props; + const delta = (new Date(timestamp)).getTime() - state.now; + const unitDelay = getUnitDelay(selectUnits(delta)); + const unitRemainder = Math.abs(delta % unitDelay); + const updateInterval = 1000 * 10; + const delay = delta < 0 ? Math.max(updateInterval, unitDelay - unitRemainder) : Math.max(updateInterval, unitRemainder); + + this._timer = setTimeout(() => { + this.setState({ now: this.props.intl.now() }); + }, delay); + } + + render () { + const { timestamp, intl, year, futureDate } = this.props; + + const timeGiven = timestamp.includes('T'); + const date = new Date(timestamp); + const relativeTime = futureDate ? timeRemainingString(intl, date, this.state.now, timeGiven) : timeAgoString(intl, date, this.state.now, year, timeGiven); + + return ( + <time dateTime={timestamp} title={intl.formatDate(date, dateFormatOptions)}> + {relativeTime} + </time> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/scrollable_list.js b/app/javascript/flavours/glitch/components/scrollable_list.js new file mode 100644 index 000000000..fae0a7393 --- /dev/null +++ b/app/javascript/flavours/glitch/components/scrollable_list.js @@ -0,0 +1,351 @@ +import React, { PureComponent } from 'react'; +import { ScrollContainer } from 'react-router-scroll-4'; +import PropTypes from 'prop-types'; +import IntersectionObserverArticleContainer from 'flavours/glitch/containers/intersection_observer_article_container'; +import LoadMore from './load_more'; +import LoadPending from './load_pending'; +import IntersectionObserverWrapper from 'flavours/glitch/util/intersection_observer_wrapper'; +import { throttle } from 'lodash'; +import { List as ImmutableList } from 'immutable'; +import classNames from 'classnames'; +import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from 'flavours/glitch/util/fullscreen'; +import LoadingIndicator from './loading_indicator'; + +const MOUSE_IDLE_DELAY = 300; + +export default class ScrollableList extends PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + scrollKey: PropTypes.string.isRequired, + onLoadMore: PropTypes.func, + onLoadPending: PropTypes.func, + onScrollToTop: PropTypes.func, + onScroll: PropTypes.func, + trackScroll: PropTypes.bool, + shouldUpdateScroll: PropTypes.func, + isLoading: PropTypes.bool, + showLoading: PropTypes.bool, + hasMore: PropTypes.bool, + numPending: PropTypes.number, + prepend: PropTypes.node, + append: PropTypes.node, + alwaysPrepend: PropTypes.bool, + emptyMessage: PropTypes.node, + children: PropTypes.node, + bindToDocument: PropTypes.bool, + }; + + static defaultProps = { + trackScroll: true, + }; + + state = { + fullscreen: null, + cachedMediaWidth: 300, + }; + + intersectionObserverWrapper = new IntersectionObserverWrapper(); + + handleScroll = throttle(() => { + if (this.node) { + const scrollTop = this.getScrollTop(); + const scrollHeight = this.getScrollHeight(); + const clientHeight = this.getClientHeight(); + const offset = scrollHeight - scrollTop - clientHeight; + + if (400 > offset && this.props.onLoadMore && this.props.hasMore && !this.props.isLoading) { + this.props.onLoadMore(); + } + + if (scrollTop < 100 && this.props.onScrollToTop) { + this.props.onScrollToTop(); + } else if (this.props.onScroll) { + this.props.onScroll(); + } + + if (!this.lastScrollWasSynthetic) { + // If the last scroll wasn't caused by setScrollTop(), assume it was + // intentional and cancel any pending scroll reset on mouse idle + this.scrollToTopOnMouseIdle = false; + } + this.lastScrollWasSynthetic = false; + } + }, 150, { + trailing: true, + }); + + mouseIdleTimer = null; + mouseMovedRecently = false; + lastScrollWasSynthetic = false; + scrollToTopOnMouseIdle = false; + + setScrollTop = newScrollTop => { + if (this.getScrollTop() !== newScrollTop) { + this.lastScrollWasSynthetic = true; + + if (this.props.bindToDocument) { + document.scrollingElement.scrollTop = newScrollTop; + } else { + this.node.scrollTop = newScrollTop; + } + } + }; + + clearMouseIdleTimer = () => { + if (this.mouseIdleTimer === null) { + return; + } + clearTimeout(this.mouseIdleTimer); + this.mouseIdleTimer = null; + }; + + handleMouseMove = throttle(() => { + // As long as the mouse keeps moving, clear and restart the idle timer. + this.clearMouseIdleTimer(); + this.mouseIdleTimer = + setTimeout(this.handleMouseIdle, MOUSE_IDLE_DELAY); + + if (!this.mouseMovedRecently && this.getScrollTop() === 0) { + // Only set if we just started moving and are scrolled to the top. + this.scrollToTopOnMouseIdle = true; + } + // Save setting this flag for last, so we can do the comparison above. + this.mouseMovedRecently = true; + }, MOUSE_IDLE_DELAY / 2); + + handleWheel = throttle(() => { + this.scrollToTopOnMouseIdle = false; + }, 150, { + trailing: true, + }); + + handleMouseIdle = () => { + if (this.scrollToTopOnMouseIdle) { + this.setScrollTop(0); + } + this.mouseMovedRecently = false; + this.scrollToTopOnMouseIdle = false; + } + + componentDidMount () { + this.attachScrollListener(); + this.attachIntersectionObserver(); + attachFullscreenListener(this.onFullScreenChange); + + // Handle initial scroll posiiton + this.handleScroll(); + } + + getScrollPosition = () => { + if (this.node && (this.getScrollTop() > 0 || this.mouseMovedRecently)) { + return { height: this.getScrollHeight(), top: this.getScrollTop() }; + } else { + return null; + } + } + + getScrollTop = () => { + return this.props.bindToDocument ? document.scrollingElement.scrollTop : this.node.scrollTop; + } + + getScrollHeight = () => { + return this.props.bindToDocument ? document.scrollingElement.scrollHeight : this.node.scrollHeight; + } + + getClientHeight = () => { + return this.props.bindToDocument ? document.scrollingElement.clientHeight : this.node.clientHeight; + } + + updateScrollBottom = (snapshot) => { + const newScrollTop = this.getScrollHeight() - snapshot; + + this.setScrollTop(newScrollTop); + } + + cacheMediaWidth = (width) => { + if (width && this.state.cachedMediaWidth != width) this.setState({ cachedMediaWidth: width }); + } + + getSnapshotBeforeUpdate (prevProps, prevState) { + const someItemInserted = React.Children.count(prevProps.children) > 0 && + React.Children.count(prevProps.children) < React.Children.count(this.props.children) && + this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props); + const pendingChanged = (prevProps.numPending > 0) !== (this.props.numPending > 0); + + if (pendingChanged || someItemInserted && (this.getScrollTop() > 0 || this.mouseMovedRecently)) { + return this.getScrollHeight() - this.getScrollTop(); + } else { + return null; + } + } + + componentDidUpdate (prevProps, prevState, snapshot) { + // Reset the scroll position when a new child comes in in order not to + // jerk the scrollbar around if you're already scrolled down the page. + if (snapshot !== null) { + this.updateScrollBottom(snapshot); + } + } + + componentWillUnmount () { + this.clearMouseIdleTimer(); + this.detachScrollListener(); + this.detachIntersectionObserver(); + detachFullscreenListener(this.onFullScreenChange); + } + + onFullScreenChange = () => { + this.setState({ fullscreen: isFullscreen() }); + } + + attachIntersectionObserver () { + this.intersectionObserverWrapper.connect({ + root: this.node, + rootMargin: '300% 0px', + }); + } + + detachIntersectionObserver () { + this.intersectionObserverWrapper.disconnect(); + } + + attachScrollListener () { + if (this.props.bindToDocument) { + document.addEventListener('scroll', this.handleScroll); + document.addEventListener('wheel', this.handleWheel); + } else { + this.node.addEventListener('scroll', this.handleScroll); + this.node.addEventListener('wheel', this.handleWheel); + } + } + + detachScrollListener () { + if (this.props.bindToDocument) { + document.removeEventListener('scroll', this.handleScroll); + document.removeEventListener('wheel', this.handleWheel); + } else { + this.node.removeEventListener('scroll', this.handleScroll); + this.node.removeEventListener('wheel', this.handleWheel); + } + } + + getFirstChildKey (props) { + const { children } = props; + let firstChild = children; + + if (children instanceof ImmutableList) { + firstChild = children.get(0); + } else if (Array.isArray(children)) { + firstChild = children[0]; + } + + return firstChild && firstChild.key; + } + + setRef = (c) => { + this.node = c; + } + + handleLoadMore = e => { + e.preventDefault(); + this.props.onLoadMore(); + } + + defaultShouldUpdateScroll = (prevRouterProps, { location }) => { + if ((((prevRouterProps || {}).location || {}).state || {}).mastodonModalOpen) return false; + return !(location.state && location.state.mastodonModalOpen); + } + + handleLoadPending = e => { + e.preventDefault(); + this.props.onLoadPending(); + // Prevent the weird scroll-jumping behavior, as we explicitly don't want to + // scroll to top, and we know the scroll height is going to change + this.scrollToTopOnMouseIdle = false; + this.lastScrollWasSynthetic = false; + this.clearMouseIdleTimer(); + this.mouseIdleTimer = setTimeout(this.handleMouseIdle, MOUSE_IDLE_DELAY); + this.mouseMovedRecently = true; + } + + render () { + const { children, scrollKey, trackScroll, shouldUpdateScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, emptyMessage, onLoadMore } = this.props; + const { fullscreen } = this.state; + const childrenCount = React.Children.count(children); + + const loadMore = (hasMore && onLoadMore) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null; + const loadPending = (numPending > 0) ? <LoadPending count={numPending} onClick={this.handleLoadPending} /> : null; + let scrollableArea = null; + + if (showLoading) { + scrollableArea = ( + <div className='scrollable scrollable--flex' ref={this.setRef}> + <div role='feed' className='item-list'> + {prepend} + </div> + + <div className='scrollable__append'> + <LoadingIndicator /> + </div> + </div> + ); + } else if (isLoading || childrenCount > 0 || hasMore || !emptyMessage) { + scrollableArea = ( + <div className={classNames('scrollable', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove}> + <div role='feed' className='item-list'> + {prepend} + + {loadPending} + + {React.Children.map(this.props.children, (child, index) => ( + <IntersectionObserverArticleContainer + key={child.key} + id={child.key} + index={index} + listLength={childrenCount} + intersectionObserverWrapper={this.intersectionObserverWrapper} + saveHeightKey={trackScroll ? `${this.context.router.route.location.key}:${scrollKey}` : null} + > + {React.cloneElement(child, { + getScrollPosition: this.getScrollPosition, + updateScrollBottom: this.updateScrollBottom, + cachedMediaWidth: this.state.cachedMediaWidth, + cacheMediaWidth: this.cacheMediaWidth, + })} + </IntersectionObserverArticleContainer> + ))} + + {loadMore} + + {!hasMore && append} + </div> + </div> + ); + } else { + scrollableArea = ( + <div className={classNames('scrollable scrollable--flex', { fullscreen })} ref={this.setRef}> + {alwaysPrepend && prepend} + + <div className='empty-column-indicator'> + {emptyMessage} + </div> + </div> + ); + } + + if (trackScroll) { + return ( + <ScrollContainer scrollKey={scrollKey} shouldUpdateScroll={shouldUpdateScroll || this.defaultShouldUpdateScroll}> + {scrollableArea} + </ScrollContainer> + ); + } else { + return scrollableArea; + } + } + +} diff --git a/app/javascript/flavours/glitch/components/setting_text.js b/app/javascript/flavours/glitch/components/setting_text.js new file mode 100644 index 000000000..2c1b70bc3 --- /dev/null +++ b/app/javascript/flavours/glitch/components/setting_text.js @@ -0,0 +1,34 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; + +export default class SettingText extends React.PureComponent { + + static propTypes = { + settings: ImmutablePropTypes.map.isRequired, + settingPath: PropTypes.array.isRequired, + label: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + }; + + handleChange = (e) => { + this.props.onChange(this.props.settingPath, e.target.value); + } + + render () { + const { settings, settingPath, label } = this.props; + + return ( + <label> + <span style={{ display: 'none' }}>{label}</span> + <input + className='setting-text' + value={settings.getIn(settingPath)} + onChange={this.handleChange} + placeholder={label} + /> + </label> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/spoilers.js b/app/javascript/flavours/glitch/components/spoilers.js new file mode 100644 index 000000000..8527403c1 --- /dev/null +++ b/app/javascript/flavours/glitch/components/spoilers.js @@ -0,0 +1,50 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; + +export default +class Spoilers extends React.PureComponent { + static propTypes = { + spoilerText: PropTypes.string, + children: PropTypes.node, + }; + + state = { + hidden: true, + } + + handleSpoilerClick = () => { + this.setState({ hidden: !this.state.hidden }); + } + + render () { + const { spoilerText, children } = this.props; + const { hidden } = this.state; + + const toggleText = hidden ? + <FormattedMessage + id='status.show_more' + defaultMessage='Show more' + key='0' + /> : + <FormattedMessage + id='status.show_less' + defaultMessage='Show less' + key='0' + />; + + return ([ + <p className='spoiler__text'> + {spoilerText} + {' '} + <button tabIndex='0' className='status__content__spoiler-link' onClick={this.handleSpoilerClick}> + {toggleText} + </button> + </p>, + <div className={`status__content__spoiler ${!hidden ? 'status__content__spoiler--visible' : ''}`}> + {children} + </div> + ]); + } +} + diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js new file mode 100644 index 000000000..e036c0da7 --- /dev/null +++ b/app/javascript/flavours/glitch/components/status.js @@ -0,0 +1,766 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import StatusPrepend from './status_prepend'; +import StatusHeader from './status_header'; +import StatusIcons from './status_icons'; +import StatusContent from './status_content'; +import StatusActionBar from './status_action_bar'; +import AttachmentList from './attachment_list'; +import Card from '../features/status/components/card'; +import { injectIntl, FormattedMessage } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { MediaGallery, Video, Audio } from 'flavours/glitch/util/async-components'; +import { HotKeys } from 'react-hotkeys'; +import NotificationOverlayContainer from 'flavours/glitch/features/notifications/containers/overlay_container'; +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 +import Bundle from '../features/ui/components/bundle'; + +export const textForScreenReader = (intl, status, rebloggedByText = false, expanded = false) => { + const displayName = status.getIn(['account', 'display_name']); + + const values = [ + displayName.length === 0 ? status.getIn(['account', 'acct']).split('@')[0] : displayName, + status.get('spoiler_text') && !expanded ? status.get('spoiler_text') : status.get('search_index').slice(status.get('spoiler_text').length), + intl.formatDate(status.get('created_at'), { hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' }), + status.getIn(['account', 'acct']), + ]; + + if (rebloggedByText) { + values.push(rebloggedByText); + } + + 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'); +} + +export default @injectIntl +class Status extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + containerId: PropTypes.string, + id: PropTypes.string, + status: ImmutablePropTypes.map, + otherAccounts: ImmutablePropTypes.list, + account: ImmutablePropTypes.map, + onReply: PropTypes.func, + onFavourite: PropTypes.func, + onReblog: PropTypes.func, + onBookmark: PropTypes.func, + onDelete: PropTypes.func, + onDirect: PropTypes.func, + onMention: PropTypes.func, + onPin: PropTypes.func, + onOpenMedia: PropTypes.func, + onOpenVideo: PropTypes.func, + onBlock: PropTypes.func, + onEmbed: PropTypes.func, + onHeightChange: PropTypes.func, + muted: PropTypes.bool, + collapse: PropTypes.bool, + hidden: PropTypes.bool, + unread: PropTypes.bool, + prepend: PropTypes.string, + withDismiss: PropTypes.bool, + onMoveUp: PropTypes.func, + onMoveDown: PropTypes.func, + getScrollPosition: PropTypes.func, + updateScrollBottom: PropTypes.func, + expanded: PropTypes.bool, + intl: PropTypes.object.isRequired, + cacheMediaWidth: PropTypes.func, + cachedMediaWidth: PropTypes.number, + onClick: PropTypes.func, + }; + + state = { + isCollapsed: false, + autoCollapsed: false, + isExpanded: undefined, + showMedia: undefined, + statusId: undefined, + revealBehindCW: undefined, + showCard: false, + forceFilter: undefined, + } + + // Avoid checking props that are functions (and whose equality will always + // evaluate to false. See react-immutable-pure-component for usage. + updateOnProps = [ + 'status', + 'account', + 'settings', + 'prepend', + 'muted', + 'collapse', + 'notification', + 'hidden', + 'expanded', + ] + + updateOnStates = [ + 'isExpanded', + 'isCollapsed', + 'showMedia', + 'forceFilter', + ] + + // If our settings have changed to disable collapsed statuses, then we + // need to make sure that we uncollapse every one. We do that by watching + // for changes to `settings.collapsed.enabled` in + // `getderivedStateFromProps()`. + + // We also need to watch for changes on the `collapse` prop---if this + // changes to anything other than `undefined`, then we need to collapse or + // uncollapse our status accordingly. + static getDerivedStateFromProps(nextProps, prevState) { + let update = {}; + let updated = false; + + // Make sure the state mirrors props we track… + if (nextProps.collapse !== prevState.collapseProp) { + update.collapseProp = nextProps.collapse; + updated = true; + } + if (nextProps.expanded !== prevState.expandedProp) { + update.expandedProp = nextProps.expanded; + updated = true; + } + + // Update state based on new props + if (!nextProps.settings.getIn(['collapsed', 'enabled'])) { + if (prevState.isCollapsed) { + update.isCollapsed = false; + updated = true; + } + } else if ( + nextProps.collapse !== prevState.collapseProp && + nextProps.collapse !== undefined + ) { + update.isCollapsed = nextProps.collapse; + if (nextProps.collapse) update.isExpanded = false; + updated = true; + } + if (nextProps.expanded !== prevState.expandedProp && + nextProps.expanded !== undefined + ) { + update.isExpanded = nextProps.expanded; + if (nextProps.expanded) update.isCollapsed = false; + updated = true; + } + + if (nextProps.expanded === undefined && + prevState.isExpanded === undefined && + update.isExpanded === undefined + ) { + const isExpanded = autoUnfoldCW(nextProps.settings, nextProps.status); + if (isExpanded !== undefined) { + update.isExpanded = isExpanded; + updated = true; + } + } + + 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; + } + + // When mounting, we just check to see if our status should be collapsed, + // and collapse it if so. We don't need to worry about whether collapsing + // is enabled here, because `setCollapsed()` already takes that into + // account. + + // The cases where a status should be collapsed are: + // + // - The `collapse` prop has been set to `true` + // - The user has decided in local settings to collapse all statuses. + // - The user has decided to collapse all notifications ('muted' + // statuses). + // - The user has decided to collapse long statuses and the status is + // over 400px (without media, or 650px with). + // - The status is a reply and the user has decided to collapse all + // replies. + // - The status contains media and the user has decided to collapse all + // statuses with media. + // - The status is a reblog the user has decided to collapse all + // statuses which are reblogs. + componentDidMount () { + const { node } = this; + const { + status, + settings, + collapse, + muted, + prepend, + } = this.props; + + // Prevent a crash when node is undefined. Not completely sure why this + // happens, might be because status === null. + if (node === undefined) return; + + const autoCollapseSettings = settings.getIn(['collapsed', 'auto']); + + if (function () { + switch (true) { + case !!collapse: + case !!autoCollapseSettings.get('all'): + case autoCollapseSettings.get('notifications') && !!muted: + case autoCollapseSettings.get('lengthy') && node.clientHeight > ( + status.get('media_attachments').size && !muted ? 650 : 400 + ): + case autoCollapseSettings.get('reblogs') && prepend === 'reblogged_by': + case autoCollapseSettings.get('replies') && status.get('in_reply_to_id', null) !== null: + case autoCollapseSettings.get('media') && !(status.get('spoiler_text').length) && !!status.get('media_attachments').size: + return true; + default: + return false; + } + }()) { + this.setCollapsed(true); + // Hack to fix timeline jumps on second rendering when auto-collapsing + this.setState({ autoCollapsed: true }); + } + + // Hack to fix timeline jumps when a preview card is fetched + this.setState({ + showCard: !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card') && this.props.settings.get('inline_preview_cards'), + }); + } + + // Hack to fix timeline jumps on second rendering when auto-collapsing + // or on subsequent rendering when a preview card has been fetched + getSnapshotBeforeUpdate (prevProps, prevState) { + if (!this.props.getScrollPosition) return null; + + const { muted, hidden, status, settings } = this.props; + + const doShowCard = !muted && !hidden && status && status.get('card') && settings.get('inline_preview_cards'); + if (this.state.autoCollapsed || (doShowCard && !this.state.showCard)) { + if (doShowCard) this.setState({ showCard: true }); + if (this.state.autoCollapsed) this.setState({ autoCollapsed: false }); + return this.props.getScrollPosition(); + } else { + return null; + } + } + + componentDidUpdate (prevProps, prevState, snapshot) { + if (snapshot !== null && this.props.updateScrollBottom && this.node.offsetTop < snapshot.top) { + this.props.updateScrollBottom(snapshot.height - snapshot.top); + } + } + + componentWillUnmount() { + if (this.node && this.props.getScrollPosition) { + const position = this.props.getScrollPosition(); + if (position !== null && this.node.offsetTop < position.top) { + requestAnimationFrame(() => { this.props.updateScrollBottom(position.height - position.top); }); + } + } + } + + // `setCollapsed()` sets the value of `isCollapsed` in our state, that is, + // whether the toot is collapsed or not. + + // `setCollapsed()` automatically checks for us whether toot collapsing + // is enabled, so we don't have to. + setCollapsed = (value) => { + if (this.props.settings.getIn(['collapsed', 'enabled'])) { + this.setState({ isCollapsed: value }); + if (value) { + this.setExpansion(false); + } + } else { + this.setState({ isCollapsed: false }); + } + } + + setExpansion = (value) => { + this.setState({ isExpanded: value }); + if (value) { + this.setCollapsed(false); + } + } + + // `parseClick()` takes a click event and responds appropriately. + // If our status is collapsed, then clicking on it should uncollapse it. + // If `Shift` is held, then clicking on it should collapse it. + // Otherwise, we open the url handed to us in `destination`, if + // applicable. + parseClick = (e, destination) => { + const { router } = this.context; + const { status } = this.props; + const { isCollapsed } = this.state; + if (!router) return; + + if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey)) { + if (isCollapsed) this.setCollapsed(false); + else if (e.shiftKey) { + this.setCollapsed(true); + document.getSelection().removeAllRanges(); + } else if (this.props.onClick) { + this.props.onClick(); + return; + } else { + if (destination === undefined) { + destination = `/statuses/${ + status.getIn(['reblog', 'id'], status.get('id')) + }`; + } + let state = {...router.history.location.state}; + state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1; + router.history.push(destination, state); + } + e.preventDefault(); + } + } + + handleToggleMediaVisibility = () => { + this.setState({ showMedia: !this.state.showMedia }); + } + + handleAccountClick = (e) => { + if (this.context.router && e.button === 0) { + const id = e.currentTarget.getAttribute('data-id'); + e.preventDefault(); + let state = {...this.context.router.history.location.state}; + state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1; + this.context.router.history.push(`/accounts/${id}`, state); + } + } + + handleExpandedToggle = () => { + if (this.props.status.get('spoiler_text')) { + this.setExpansion(!this.state.isExpanded); + } + }; + + handleOpenVideo = (media, options) => { + this.props.onOpenVideo(media, options); + } + + handleHotkeyOpenMedia = e => { + const { status, onOpenMedia, onOpenVideo } = this.props; + + e.preventDefault(); + + if (status.get('media_attachments').size > 0) { + if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { + // TODO: toggle play/paused? + } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { + onOpenVideo(status.getIn(['media_attachments', 0]), { startTime: 0 }); + } else { + onOpenMedia(status.get('media_attachments'), 0); + } + } + } + + handleHotkeyReply = e => { + e.preventDefault(); + this.props.onReply(this.props.status, this.context.router.history); + } + + handleHotkeyFavourite = (e) => { + this.props.onFavourite(this.props.status, e); + } + + handleHotkeyBoost = e => { + this.props.onReblog(this.props.status, e); + } + + handleHotkeyBookmark = e => { + this.props.onBookmark(this.props.status, e); + } + + handleHotkeyMention = e => { + e.preventDefault(); + this.props.onMention(this.props.status.get('account'), this.context.router.history); + } + + handleHotkeyOpen = () => { + let state = {...this.context.router.history.location.state}; + state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1; + this.context.router.history.push(`/statuses/${this.props.status.get('id')}`, state); + } + + handleHotkeyOpenProfile = () => { + let state = {...this.context.router.history.location.state}; + state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1; + this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`, state); + } + + handleHotkeyMoveUp = e => { + this.props.onMoveUp(this.props.containerId || this.props.id, e.target.getAttribute('data-featured')); + } + + handleHotkeyMoveDown = e => { + this.props.onMoveDown(this.props.containerId || this.props.id, e.target.getAttribute('data-featured')); + } + + handleHotkeyCollapse = e => { + if (!this.props.settings.getIn(['collapsed', 'enabled'])) + return; + + this.setCollapsed(!this.state.isCollapsed); + } + + handleHotkeyToggleSensitive = () => { + this.handleToggleMediaVisibility(); + } + + handleUnfilterClick = e => { + const { onUnfilter, status } = this.props; + onUnfilter(status.get('reblog') ? status.get('reblog') : status, () => this.setState({ forceFilter: false })); + } + + handleFilterClick = () => { + this.setState({ forceFilter: true }); + } + + handleRef = c => { + this.node = c; + } + + renderLoadingMediaGallery () { + return <div className='media-gallery' style={{ height: '110px' }} />; + } + + renderLoadingVideoPlayer () { + return <div className='video-player' style={{ height: '110px' }} />; + } + + renderLoadingAudioPlayer () { + return <div className='audio-player' style={{ height: '110px' }} />; + } + + render () { + const { + handleRef, + parseClick, + setExpansion, + setCollapsed, + } = this; + const { router } = this.context; + const { + intl, + status, + account, + otherAccounts, + settings, + collapsed, + muted, + prepend, + intersectionObserverWrapper, + onOpenVideo, + onOpenMedia, + notification, + hidden, + unread, + featured, + ...other + } = this.props; + const { isExpanded, isCollapsed, forceFilter } = this.state; + let background = null; + let attachments = null; + let media = null; + let mediaIcon = null; + + if (status === null) { + return null; + } + + const handlers = { + reply: this.handleHotkeyReply, + favourite: this.handleHotkeyFavourite, + boost: this.handleHotkeyBoost, + mention: this.handleHotkeyMention, + open: this.handleHotkeyOpen, + openProfile: this.handleHotkeyOpenProfile, + moveUp: this.handleHotkeyMoveUp, + moveDown: this.handleHotkeyMoveDown, + toggleSpoiler: this.handleExpandedToggle, + bookmark: this.handleHotkeyBookmark, + toggleCollapse: this.handleHotkeyCollapse, + toggleSensitive: this.handleHotkeyToggleSensitive, + openMedia: this.handleHotkeyOpenMedia, + }; + + if (hidden) { + return ( + <HotKeys handlers={handlers}> + <div ref={this.handleRef} className='status focusable' tabIndex='0'> + {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} + {' '} + {status.get('content')} + </div> + </HotKeys> + ); + } + + const filtered = (status.get('filtered') || status.getIn(['reblog', 'filtered'])) && settings.get('filtering_behavior') !== 'content_warning'; + if (forceFilter === undefined ? filtered : forceFilter) { + const minHandlers = this.props.muted ? {} : { + moveUp: this.handleHotkeyMoveUp, + moveDown: this.handleHotkeyMoveDown, + }; + + return ( + <HotKeys handlers={minHandlers}> + <div className='status__wrapper status__wrapper--filtered focusable' tabIndex='0' ref={this.handleRef}> + <FormattedMessage id='status.filtered' defaultMessage='Filtered' /> + {settings.get('filtering_behavior') !== 'upstream' && ' '} + {settings.get('filtering_behavior') !== 'upstream' && ( + <button className='status__wrapper--filtered__button' onClick={this.handleUnfilterClick}> + <FormattedMessage id='status.show_filter_reason' defaultMessage='(show why)' /> + </button> + )} + </div> + </HotKeys> + ); + } + + // If user backgrounds for collapsed statuses are enabled, then we + // initialize our background accordingly. This will only be rendered if + // the status is collapsed. + if (settings.getIn(['collapsed', 'backgrounds', 'user_backgrounds'])) { + background = status.getIn(['account', 'header']); + } + + // This handles our media attachments. + // If a media file is of unknwon type or if the status is muted + // (notification), we show a list of links instead of embedded media. + + // After we have generated our appropriate media element and stored it in + // `media`, we snatch the thumbnail to use as our `background` if media + // backgrounds for collapsed statuses are enabled. + attachments = status.get('media_attachments'); + if (status.get('poll')) { + media = <PollContainer pollId={status.get('poll')} />; + mediaIcon = 'tasks'; + } else if (attachments.size > 0) { + if (muted || attachments.some(item => item.get('type') === 'unknown')) { + media = ( + <AttachmentList + compact + media={status.get('media_attachments')} + /> + ); + } else if (attachments.getIn([0, 'type']) === 'audio') { + const attachment = status.getIn(['media_attachments', 0]); + + media = ( + <Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} > + {Component => ( + <Component + src={attachment.get('url')} + alt={attachment.get('description')} + duration={attachment.getIn(['meta', 'original', 'duration'], 0)} + peaks={[0]} + height={70} + /> + )} + </Bundle> + ); + mediaIcon = 'music'; + } else if (attachments.getIn([0, 'type']) === 'video') { + const attachment = status.getIn(['media_attachments', 0]); + + media = ( + <Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} > + {Component => (<Component + preview={attachment.get('preview_url')} + blurhash={attachment.get('blurhash')} + src={attachment.get('url')} + alt={attachment.get('description')} + inline + sensitive={status.get('sensitive')} + letterbox={settings.getIn(['media', 'letterbox'])} + fullwidth={settings.getIn(['media', 'fullwidth'])} + preventPlayback={isCollapsed || !isExpanded} + onOpenVideo={this.handleOpenVideo} + width={this.props.cachedMediaWidth} + cacheWidth={this.props.cacheMediaWidth} + visible={this.state.showMedia} + onToggleVisibility={this.handleToggleMediaVisibility} + />)} + </Bundle> + ); + mediaIcon = 'video-camera'; + } else { // Media type is 'image' or 'gifv' + media = ( + <Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}> + {Component => ( + <Component + media={attachments} + sensitive={status.get('sensitive')} + letterbox={settings.getIn(['media', 'letterbox'])} + fullwidth={settings.getIn(['media', 'fullwidth'])} + hidden={isCollapsed || !isExpanded} + onOpenMedia={this.props.onOpenMedia} + cacheWidth={this.props.cacheMediaWidth} + defaultWidth={this.props.cachedMediaWidth} + visible={this.state.showMedia} + onToggleVisibility={this.handleToggleMediaVisibility} + /> + )} + </Bundle> + ); + mediaIcon = 'picture-o'; + } + + if (!status.get('sensitive') && !(status.get('spoiler_text').length > 0) && settings.getIn(['collapsed', 'backgrounds', 'preview_images'])) { + background = attachments.getIn([0, 'preview_url']); + } + } else if (status.get('card') && settings.get('inline_preview_cards')) { + media = ( + <Card + onOpenMedia={this.props.onOpenMedia} + card={status.get('card')} + compact + cacheWidth={this.props.cacheMediaWidth} + defaultWidth={this.props.cachedMediaWidth} + sensitive={status.get('sensitive')} + /> + ); + mediaIcon = 'link'; + } + + // Here we prepare extra data-* attributes for CSS selectors. + // Users can use those for theming, hiding avatars etc via UserStyle + const selectorAttribs = { + 'data-status-by': `@${status.getIn(['account', 'acct'])}`, + }; + + if (prepend && account) { + const notifKind = { + favourite: 'favourited', + reblog: 'boosted', + reblogged_by: 'boosted', + }[prepend]; + + selectorAttribs[`data-${notifKind}-by`] = `@${account.get('acct')}`; + } + + let rebloggedByText; + + if (prepend === 'reblog') { + rebloggedByText = intl.formatMessage({ id: 'status.reblogged_by', defaultMessage: '{name} boosted' }, { name: account.get('acct') }); + } + + const computedClass = classNames('status', `status-${status.get('visibility')}`, { + collapsed: isCollapsed, + 'has-background': isCollapsed && background, + 'status__wrapper-reply': !!status.get('in_reply_to_id'), + read: unread === false, + muted, + }, 'focusable'); + + return ( + <HotKeys handlers={handlers}> + <div + className={computedClass} + style={isCollapsed && background ? { backgroundImage: `url(${background})` } : null} + {...selectorAttribs} + ref={handleRef} + tabIndex='0' + data-featured={featured ? 'true' : null} + aria-label={textForScreenReader(intl, status, rebloggedByText, !status.get('hidden'))} + > + <header className='status__info'> + <span> + {prepend && account ? ( + <StatusPrepend + type={prepend} + account={account} + parseClick={parseClick} + notificationId={this.props.notificationId} + /> + ) : null} + {!muted || !isCollapsed ? ( + <StatusHeader + status={status} + friend={account} + collapsed={isCollapsed} + parseClick={parseClick} + otherAccounts={otherAccounts} + /> + ) : null} + </span> + <StatusIcons + status={status} + mediaIcon={mediaIcon} + collapsible={settings.getIn(['collapsed', 'enabled'])} + collapsed={isCollapsed} + setCollapsed={setCollapsed} + directMessage={!!otherAccounts} + /> + </header> + <StatusContent + status={status} + media={media} + mediaIcon={mediaIcon} + expanded={isExpanded} + onExpandedToggle={this.handleExpandedToggle} + parseClick={parseClick} + disabled={!router} + tagLinks={settings.get('tag_misleading_links')} + rewriteMentions={settings.get('rewrite_mentions')} + /> + {!isCollapsed || !(muted || !settings.getIn(['collapsed', 'show_action_bar'])) ? ( + <StatusActionBar + {...other} + status={status} + account={status.get('account')} + showReplyCount={settings.get('show_reply_count')} + directMessage={!!otherAccounts} + onFilter={this.handleFilterClick} + /> + ) : null} + {notification ? ( + <NotificationOverlayContainer + notification={notification} + /> + ) : null} + </div> + </HotKeys> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/status_action_bar.js b/app/javascript/flavours/glitch/components/status_action_bar.js new file mode 100644 index 000000000..0a481c816 --- /dev/null +++ b/app/javascript/flavours/glitch/components/status_action_bar.js @@ -0,0 +1,312 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import IconButton from './icon_button'; +import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { me, isStaff } from 'flavours/glitch/util/initial_state'; +import RelativeTimestamp from './relative_timestamp'; +import { accountAdminLink, statusAdminLink } from 'flavours/glitch/util/backend_links'; + +const messages = defineMessages({ + delete: { id: 'status.delete', defaultMessage: 'Delete' }, + redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' }, + direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' }, + mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, + mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, + block: { id: 'account.block', defaultMessage: 'Block @{name}' }, + reply: { id: 'status.reply', defaultMessage: 'Reply' }, + share: { id: 'status.share', defaultMessage: 'Share' }, + more: { id: 'status.more', defaultMessage: 'More' }, + replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' }, + reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, + reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost to original audience' }, + cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, + favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, + bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' }, + open: { id: 'status.open', defaultMessage: 'Expand this status' }, + report: { id: 'status.report', defaultMessage: 'Report @{name}' }, + muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, + unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' }, + pin: { id: 'status.pin', defaultMessage: 'Pin on profile' }, + unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' }, + embed: { id: 'status.embed', defaultMessage: 'Embed' }, + admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' }, + admin_status: { id: 'status.admin_status', defaultMessage: 'Open this status in the moderation interface' }, + copy: { id: 'status.copy', defaultMessage: 'Copy link to status' }, + hide: { id: 'status.hide', defaultMessage: 'Hide toot' }, +}); + +const obfuscatedCount = count => { + if (count < 0) { + return 0; + } else if (count <= 1) { + return count; + } else { + return '1+'; + } +}; + +export default @injectIntl +class StatusActionBar extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + status: ImmutablePropTypes.map.isRequired, + onReply: PropTypes.func, + onFavourite: PropTypes.func, + onReblog: PropTypes.func, + onDelete: PropTypes.func, + onDirect: PropTypes.func, + onMention: PropTypes.func, + onMute: PropTypes.func, + onBlock: PropTypes.func, + onReport: PropTypes.func, + onEmbed: PropTypes.func, + onMuteConversation: PropTypes.func, + onPin: PropTypes.func, + onBookmark: PropTypes.func, + onFilter: PropTypes.func, + withDismiss: PropTypes.bool, + showReplyCount: PropTypes.bool, + directMessage: PropTypes.bool, + intl: PropTypes.object.isRequired, + }; + + // Avoid checking props that are functions (and whose equality will always + // evaluate to false. See react-immutable-pure-component for usage. + updateOnProps = [ + 'status', + 'showReplyCount', + 'withDismiss', + ] + + handleReplyClick = () => { + if (me) { + this.props.onReply(this.props.status, this.context.router.history); + } else { + this._openInteractionDialog('reply'); + } + } + + handleShareClick = () => { + navigator.share({ + text: this.props.status.get('search_index'), + url: this.props.status.get('url'), + }); + } + + handleFavouriteClick = (e) => { + if (me) { + this.props.onFavourite(this.props.status, e); + } else { + this._openInteractionDialog('favourite'); + } + } + + handleBookmarkClick = (e) => { + this.props.onBookmark(this.props.status, e); + } + + handleReblogClick = e => { + if (me) { + this.props.onReblog(this.props.status, e); + } else { + this._openInteractionDialog('reblog'); + } + } + + _openInteractionDialog = type => { + window.open(`/interact/${this.props.status.get('id')}?type=${type}`, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes'); + } + + handleDeleteClick = () => { + this.props.onDelete(this.props.status, this.context.router.history); + } + + handleRedraftClick = () => { + this.props.onDelete(this.props.status, this.context.router.history, true); + } + + handlePinClick = () => { + this.props.onPin(this.props.status); + } + + handleMentionClick = () => { + this.props.onMention(this.props.status.get('account'), this.context.router.history); + } + + handleDirectClick = () => { + this.props.onDirect(this.props.status.get('account'), this.context.router.history); + } + + handleMuteClick = () => { + this.props.onMute(this.props.status.get('account')); + } + + handleBlockClick = () => { + this.props.onBlock(this.props.status); + } + + handleOpen = () => { + let state = {...this.context.router.history.location.state}; + if (state.mastodonModalOpen) { + this.context.router.history.replace(`/statuses/${this.props.status.get('id')}`, { mastodonBackSteps: (state.mastodonBackSteps || 0) + 1 }); + } else { + state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1; + this.context.router.history.push(`/statuses/${this.props.status.get('id')}`, state); + } + } + + handleEmbed = () => { + this.props.onEmbed(this.props.status); + } + + handleReport = () => { + this.props.onReport(this.props.status); + } + + handleConversationMuteClick = () => { + this.props.onMuteConversation(this.props.status); + } + + handleCopy = () => { + const url = this.props.status.get('url'); + const textarea = document.createElement('textarea'); + + textarea.textContent = url; + textarea.style.position = 'fixed'; + + document.body.appendChild(textarea); + + try { + textarea.select(); + document.execCommand('copy'); + } catch (e) { + + } finally { + document.body.removeChild(textarea); + } + } + + handleFilterClick = () => { + this.props.onFilter(); + } + + render () { + const { status, intl, withDismiss, showReplyCount, directMessage } = this.props; + + const mutingConversation = status.get('muted'); + const anonymousAccess = !me; + const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); + const reblogDisabled = status.get('visibility') === 'direct' || (status.get('visibility') === 'private' && me !== status.getIn(['account', 'id'])); + const reblogMessage = status.get('visibility') === 'private' ? messages.reblog_private : messages.reblog; + + let menu = []; + let reblogIcon = 'retweet'; + let replyIcon; + let replyTitle; + + menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen }); + + if (publicStatus) { + menu.push({ text: intl.formatMessage(messages.copy), action: this.handleCopy }); + menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed }); + } + + menu.push(null); + + if (status.getIn(['account', 'id']) === me || withDismiss) { + menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); + menu.push(null); + } + + if (status.getIn(['account', 'id']) === me) { + 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 }); + menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick }); + } else { + menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); + menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick }); + menu.push(null); + menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick }); + menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick }); + menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport }); + + if (isStaff && (accountAdminLink || statusAdminLink)) { + menu.push(null); + if (accountAdminLink !== undefined) { + menu.push({ + text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), + href: accountAdminLink(status.getIn(['account', 'id'])), + }); + } + if (statusAdminLink !== undefined) { + menu.push({ + text: intl.formatMessage(messages.admin_status), + href: statusAdminLink(status.getIn(['account', 'id']), status.get('id')), + }); + } + } + } + + if (status.get('in_reply_to_id', null) === null) { + replyIcon = 'reply'; + replyTitle = intl.formatMessage(messages.reply); + } else { + replyIcon = 'reply-all'; + replyTitle = intl.formatMessage(messages.replyAll); + } + + const shareButton = ('share' in navigator) && publicStatus && ( + <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShareClick} /> + ); + + const filterButton = status.get('filtered') && ( + <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.hide)} icon='eye' onClick={this.handleFilterClick} /> + ); + + let replyButton = ( + <IconButton + className='status__action-bar-button' + title={replyTitle} + icon={replyIcon} + onClick={this.handleReplyClick} + /> + ); + if (showReplyCount) { + replyButton = ( + <div className='status__action-bar__counter'> + {replyButton} + <span className='status__action-bar__counter__label' >{obfuscatedCount(status.get('replies_count'))}</span> + </div> + ); + } + + return ( + <div className='status__action-bar'> + {replyButton} + {!directMessage && [ + <IconButton key='reblog-button' className='status__action-bar-button' disabled={reblogDisabled} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogDisabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(reblogMessage)} icon={reblogIcon} onClick={this.handleReblogClick} />, + <IconButton key='favourite-button' className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />, + shareButton, + <IconButton key='bookmark-button' className='status__action-bar-button bookmark-icon' disabled={anonymousAccess} active={status.get('bookmarked')} pressed={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />, + filterButton, + <div key='dropdown-button' className='status__action-bar-dropdown'> + <DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel={intl.formatMessage(messages.more)} /> + </div>, + ]} + + <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/status_content.js b/app/javascript/flavours/glitch/components/status_content.js new file mode 100644 index 000000000..a5822866a --- /dev/null +++ b/app/javascript/flavours/glitch/components/status_content.js @@ -0,0 +1,394 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { isRtl } from 'flavours/glitch/util/rtl'; +import { FormattedMessage } from 'react-intl'; +import Permalink from './permalink'; +import classnames from 'classnames'; +import Icon from 'flavours/glitch/components/icon'; +import { autoPlayGif } from 'flavours/glitch/util/initial_state'; +import { decode as decodeIDNA } from 'flavours/glitch/util/idna'; + +const textMatchesTarget = (text, origin, host) => { + return (text === origin || text === host + || text.startsWith(origin + '/') || text.startsWith(host + '/') + || 'www.' + text === host || ('www.' + text).startsWith(host + '/')); +} + +const isLinkMisleading = (link) => { + let linkTextParts = []; + + // Reconstruct visible text, as we do not have much control over how links + // from remote software look, and we can't rely on `innerText` because the + // `invisible` class does not set `display` to `none`. + + const walk = (node) => { + switch (node.nodeType) { + case Node.TEXT_NODE: + linkTextParts.push(node.textContent); + break; + case Node.ELEMENT_NODE: + if (node.classList.contains('invisible')) return; + const children = node.childNodes; + for (let i = 0; i < children.length; i++) { + walk(children[i]); + } + break; + } + }; + + walk(link); + + const linkText = linkTextParts.join(''); + const targetURL = new URL(link.href); + + if (targetURL.protocol === 'magnet:') { + return !linkText.startsWith('magnet:'); + } + + if (targetURL.protocol === 'xmpp:') { + return !(linkText === targetURL.href || 'xmpp:' + linkText === targetURL.href); + } + + // The following may not work with international domain names + if (textMatchesTarget(linkText, targetURL.origin, targetURL.host) || textMatchesTarget(linkText.toLowerCase(), targetURL.origin, targetURL.host)) { + return false; + } + + // The link hasn't been recognized, maybe it features an international domain name + const hostname = decodeIDNA(targetURL.hostname).normalize('NFKC'); + const host = targetURL.host.replace(targetURL.hostname, hostname); + const origin = targetURL.origin.replace(targetURL.host, host); + const text = linkText.normalize('NFKC'); + return !(textMatchesTarget(text, origin, host) || textMatchesTarget(text.toLowerCase(), origin, host)); +}; + +export default class StatusContent extends React.PureComponent { + + static propTypes = { + status: ImmutablePropTypes.map.isRequired, + expanded: PropTypes.bool, + collapsed: PropTypes.bool, + onExpandedToggle: PropTypes.func, + media: PropTypes.element, + mediaIcon: PropTypes.string, + parseClick: PropTypes.func, + disabled: PropTypes.bool, + onUpdate: PropTypes.func, + tagLinks: PropTypes.bool, + rewriteMentions: PropTypes.string, + }; + + static defaultProps = { + tagLinks: true, + rewriteMentions: 'no', + }; + + state = { + hidden: true, + }; + + _updateStatusLinks () { + const node = this.contentsNode; + const { tagLinks, rewriteMentions } = this.props; + + if (!node) { + return; + } + + const links = node.querySelectorAll('a'); + + for (var i = 0; i < links.length; ++i) { + let link = links[i]; + if (link.classList.contains('status-link')) { + continue; + } + link.classList.add('status-link'); + + let mention = this.props.status.get('mentions').find(item => link.href === item.get('url')); + + if (mention) { + link.addEventListener('click', this.onMentionClick.bind(this, mention), false); + link.setAttribute('title', mention.get('acct')); + if (rewriteMentions !== 'no') { + while (link.firstChild) link.removeChild(link.firstChild); + link.appendChild(document.createTextNode('@')); + const acctSpan = document.createElement('span'); + acctSpan.textContent = rewriteMentions === 'acct' ? mention.get('acct') : mention.get('username'); + link.appendChild(acctSpan); + } + } else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) { + link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false); + } else { + link.addEventListener('click', this.onLinkClick.bind(this), false); + link.setAttribute('title', link.href); + link.classList.add('unhandled-link'); + + try { + if (tagLinks && isLinkMisleading(link)) { + // Add a tag besides the link to display its origin + + const url = new URL(link.href); + const tag = document.createElement('span'); + tag.classList.add('link-origin-tag'); + switch (url.protocol) { + case 'xmpp:': + tag.textContent = `[${url.href}]`; + break; + case 'magnet:': + tag.textContent = '(magnet)'; + break; + default: + tag.textContent = `[${url.host}]`; + } + link.insertAdjacentText('beforeend', ' '); + link.insertAdjacentElement('beforeend', tag); + } + } catch (e) { + // The URL is invalid, remove the href just to be safe + if (tagLinks && e instanceof TypeError) link.removeAttribute('href'); + } + } + + link.setAttribute('target', '_blank'); + link.setAttribute('rel', 'noopener noreferrer'); + } + } + + _updateStatusEmojis () { + const node = this.node; + + if (!node || autoPlayGif) { + return; + } + + const emojis = node.querySelectorAll('.custom-emoji'); + + for (var i = 0; i < emojis.length; i++) { + let emoji = emojis[i]; + if (emoji.classList.contains('status-emoji')) { + continue; + } + emoji.classList.add('status-emoji'); + + emoji.addEventListener('mouseenter', this.handleEmojiMouseEnter, false); + emoji.addEventListener('mouseleave', this.handleEmojiMouseLeave, false); + } + } + + componentDidMount () { + this._updateStatusLinks(); + this._updateStatusEmojis(); + } + + componentDidUpdate () { + this._updateStatusLinks(); + this._updateStatusEmojis(); + if (this.props.onUpdate) this.props.onUpdate(); + } + + onLinkClick = (e) => { + if (this.props.collapsed) { + if (this.props.parseClick) this.props.parseClick(e); + } + } + + onMentionClick = (mention, e) => { + if (this.props.parseClick) { + this.props.parseClick(e, `/accounts/${mention.get('id')}`); + } + } + + onHashtagClick = (hashtag, e) => { + hashtag = hashtag.replace(/^#/, ''); + + if (this.props.parseClick) { + this.props.parseClick(e, `/timelines/tag/${hashtag}`); + } + } + + handleEmojiMouseEnter = ({ target }) => { + target.src = target.getAttribute('data-original'); + } + + handleEmojiMouseLeave = ({ target }) => { + target.src = target.getAttribute('data-static'); + } + + handleMouseDown = (e) => { + this.startXY = [e.clientX, e.clientY]; + } + + handleMouseUp = (e) => { + const { parseClick, disabled } = this.props; + + if (disabled || !this.startXY) { + return; + } + + const [ startX, startY ] = this.startXY; + const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)]; + + let element = e.target; + while (element) { + if (['button', 'video', 'a', 'label', 'wave'].includes(element.localName)) { + return; + } + element = element.parentNode; + } + + if (deltaX + deltaY < 5 && e.button === 0 && parseClick) { + parseClick(e); + } + + this.startXY = null; + } + + handleSpoilerClick = (e) => { + e.preventDefault(); + + if (this.props.onExpandedToggle) { + this.props.onExpandedToggle(); + } else { + this.setState({ hidden: !this.state.hidden }); + } + } + + setRef = (c) => { + this.node = c; + } + + setContentsRef = (c) => { + this.contentsNode = c; + } + + render () { + const { + status, + media, + mediaIcon, + parseClick, + disabled, + tagLinks, + rewriteMentions, + } = this.props; + + const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden; + + const content = { __html: status.get('contentHtml') }; + const spoilerContent = { __html: status.get('spoilerHtml') }; + const directionStyle = { direction: 'ltr' }; + const classNames = classnames('status__content', { + 'status__content--with-action': parseClick && !disabled, + 'status__content--with-spoiler': status.get('spoiler_text').length > 0, + }); + + if (isRtl(status.get('search_index'))) { + directionStyle.direction = 'rtl'; + } + + if (status.get('spoiler_text').length > 0) { + let mentionsPlaceholder = ''; + + const mentionLinks = status.get('mentions').map(item => ( + <Permalink + to={`/accounts/${item.get('id')}`} + href={item.get('url')} + key={item.get('id')} + className='mention' + > + @<span>{item.get('username')}</span> + </Permalink> + )).reduce((aggregate, item) => [...aggregate, item, ' '], []); + + const toggleText = hidden ? [ + <FormattedMessage + id='status.show_more' + defaultMessage='Show more' + key='0' + />, + mediaIcon ? ( + <Icon + fixedWidth + className='status__content__spoiler-icon' + id={mediaIcon} + aria-hidden='true' + key='1' + /> + ) : null, + ] : [ + <FormattedMessage + id='status.show_less' + defaultMessage='Show less' + key='0' + />, + ]; + + if (hidden) { + mentionsPlaceholder = <div>{mentionLinks}</div>; + } + + return ( + <div className={classNames} tabIndex='0' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} ref={this.setRef}> + <p + style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }} + > + <span dangerouslySetInnerHTML={spoilerContent} /> + {' '} + <button tabIndex='0' className='status__content__spoiler-link' onClick={this.handleSpoilerClick}> + {toggleText} + </button> + </p> + + {mentionsPlaceholder} + + <div className={`status__content__spoiler ${!hidden ? 'status__content__spoiler--visible' : ''}`}> + <div + ref={this.setContentsRef} + key={`contents-${tagLinks}`} + style={directionStyle} + tabIndex={!hidden ? 0 : null} + dangerouslySetInnerHTML={content} + className='status__content__text' + /> + {media} + </div> + + </div> + ); + } else if (parseClick) { + return ( + <div + className={classNames} + style={directionStyle} + onMouseDown={this.handleMouseDown} + onMouseUp={this.handleMouseUp} + tabIndex='0' + ref={this.setRef} + > + <div + ref={this.setContentsRef} + key={`contents-${tagLinks}-${rewriteMentions}`} + dangerouslySetInnerHTML={content} + className='status__content__text' + tabIndex='0' + /> + {media} + </div> + ); + } else { + return ( + <div + className='status__content' + style={directionStyle} + tabIndex='0' + ref={this.setRef} + > + <div ref={this.setContentsRef} key={`contents-${tagLinks}`} className='status__content__text' dangerouslySetInnerHTML={content} tabIndex='0' /> + {media} + </div> + ); + } + } + +} diff --git a/app/javascript/flavours/glitch/components/status_header.js b/app/javascript/flavours/glitch/components/status_header.js new file mode 100644 index 000000000..06296e124 --- /dev/null +++ b/app/javascript/flavours/glitch/components/status_header.js @@ -0,0 +1,90 @@ +// Package imports. +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; + +// Mastodon imports. +import Avatar from './avatar'; +import AvatarOverlay from './avatar_overlay'; +import AvatarComposite from './avatar_composite'; +import DisplayName from './display_name'; + +export default class StatusHeader extends React.PureComponent { + + static propTypes = { + status: ImmutablePropTypes.map.isRequired, + friend: ImmutablePropTypes.map, + parseClick: PropTypes.func.isRequired, + otherAccounts: ImmutablePropTypes.list, + }; + + // Handles clicks on account name/image + handleClick = (id, e) => { + const { parseClick } = this.props; + parseClick(e, `/accounts/${id}`); + } + + handleAccountClick = (e) => { + const { status } = this.props; + this.handleClick(status.getIn(['account', 'id']), e); + } + + // Rendering. + render () { + const { + status, + friend, + otherAccounts, + } = this.props; + + const account = status.get('account'); + + let statusAvatar; + if (otherAccounts && otherAccounts.size > 0) { + statusAvatar = <AvatarComposite accounts={otherAccounts} size={48} onAccountClick={this.handleClick} />; + } else if (friend === undefined || friend === null) { + statusAvatar = <Avatar account={account} size={48} />; + } else { + statusAvatar = <AvatarOverlay account={account} friend={friend} />; + } + + if (!otherAccounts) { + return ( + <div className='status__info__account'> + <a + href={account.get('url')} + target='_blank' + className='status__avatar' + onClick={this.handleAccountClick} + rel='noopener noreferrer' + > + {statusAvatar} + </a> + <a + href={account.get('url')} + target='_blank' + className='status__display-name' + onClick={this.handleAccountClick} + rel='noopener noreferrer' + > + <DisplayName account={account} others={otherAccounts} /> + </a> + </div> + ); + } else { + // This is a DM conversation + return ( + <div className='status__info__account'> + <span className='status__avatar'> + {statusAvatar} + </span> + + <span className='status__display-name'> + <DisplayName account={account} others={otherAccounts} onAccountClick={this.handleClick} /> + </span> + </div> + ); + } + } + +} diff --git a/app/javascript/flavours/glitch/components/status_icons.js b/app/javascript/flavours/glitch/components/status_icons.js new file mode 100644 index 000000000..f4d0a7405 --- /dev/null +++ b/app/javascript/flavours/glitch/components/status_icons.js @@ -0,0 +1,121 @@ +// Package imports. +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages, injectIntl } from 'react-intl'; + +// Mastodon imports. +import IconButton from './icon_button'; +import VisibilityIcon from './status_visibility_icon'; +import Icon from 'flavours/glitch/components/icon'; + +// Messages for use with internationalization stuff. +const messages = defineMessages({ + collapse: { id: 'status.collapse', defaultMessage: 'Collapse' }, + uncollapse: { id: 'status.uncollapse', defaultMessage: 'Uncollapse' }, + inReplyTo: { id: 'status.in_reply_to', defaultMessage: 'This toot is a reply' }, + previewCard: { id: 'status.has_preview_card', defaultMessage: 'Features an attached preview card' }, + pictures: { id: 'status.has_pictures', defaultMessage: 'Features attached pictures' }, + poll: { id: 'status.is_poll', defaultMessage: 'This toot is a poll' }, + video: { id: 'status.has_video', defaultMessage: 'Features attached videos' }, + audio: { id: 'status.has_audio', defaultMessage: 'Features attached audio files' }, + localOnly: { id: 'status.local_only', defaultMessage: 'Only visible from your instance' }, +}); + +export default @injectIntl +class StatusIcons extends React.PureComponent { + + static propTypes = { + status: ImmutablePropTypes.map.isRequired, + mediaIcon: PropTypes.string, + collapsible: PropTypes.bool, + collapsed: PropTypes.bool, + directMessage: PropTypes.bool, + setCollapsed: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + // Handles clicks on collapsed button + handleCollapsedClick = (e) => { + const { collapsed, setCollapsed } = this.props; + if (e.button === 0) { + setCollapsed(!collapsed); + e.preventDefault(); + } + } + + mediaIconTitleText () { + const { intl, mediaIcon } = this.props; + + switch (mediaIcon) { + case 'link': + return intl.formatMessage(messages.previewCard); + case 'picture-o': + return intl.formatMessage(messages.pictures); + case 'tasks': + return intl.formatMessage(messages.poll); + case 'video-camera': + return intl.formatMessage(messages.video); + case 'music': + return intl.formatMessage(messages.audio); + } + } + + // Rendering. + render () { + const { + status, + mediaIcon, + collapsible, + collapsed, + directMessage, + intl, + } = this.props; + + return ( + <div className='status__info__icons'> + {status.get('in_reply_to_id', null) !== null ? ( + <Icon + className='status__reply-icon' + fixedWidth + id='comment' + aria-hidden='true' + title={intl.formatMessage(messages.inReplyTo)} + /> + ) : null} + {status.get('local_only') && + <Icon + fixedWidth + id='home' + aria-hidden='true' + title={intl.formatMessage(messages.localOnly)} + />} + {mediaIcon ? ( + <Icon + fixedWidth + className='status__media-icon' + id={mediaIcon} + aria-hidden='true' + title={this.mediaIconTitleText()} + /> + ) : null} + {!directMessage && <VisibilityIcon visibility={status.get('visibility')} />} + {collapsible ? ( + <IconButton + className='status__collapse-button' + animate + active={collapsed} + title={ + collapsed ? + intl.formatMessage(messages.uncollapse) : + intl.formatMessage(messages.collapse) + } + icon='angle-double-up' + onClick={this.handleCollapsedClick} + /> + ) : null} + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/status_list.js b/app/javascript/flavours/glitch/components/status_list.js new file mode 100644 index 000000000..a399ff567 --- /dev/null +++ b/app/javascript/flavours/glitch/components/status_list.js @@ -0,0 +1,126 @@ +import { debounce } from 'lodash'; +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import StatusContainer from 'flavours/glitch/containers/status_container'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import LoadGap from './load_gap'; +import ScrollableList from './scrollable_list'; +import RegenerationIndicator from 'flavours/glitch/components/regeneration_indicator'; + +export default class StatusList extends ImmutablePureComponent { + + static propTypes = { + scrollKey: PropTypes.string.isRequired, + statusIds: ImmutablePropTypes.list.isRequired, + featuredStatusIds: ImmutablePropTypes.list, + onLoadMore: PropTypes.func, + onScrollToTop: PropTypes.func, + onScroll: PropTypes.func, + trackScroll: PropTypes.bool, + shouldUpdateScroll: PropTypes.func, + isLoading: PropTypes.bool, + isPartial: PropTypes.bool, + hasMore: PropTypes.bool, + prepend: PropTypes.node, + alwaysPrepend: PropTypes.bool, + emptyMessage: PropTypes.node, + timelineId: PropTypes.string.isRequired, + }; + + static defaultProps = { + trackScroll: true, + }; + + getFeaturedStatusCount = () => { + return this.props.featuredStatusIds ? this.props.featuredStatusIds.size : 0; + } + + getCurrentStatusIndex = (id, featured) => { + if (featured) { + return this.props.featuredStatusIds.indexOf(id); + } else { + return this.props.statusIds.indexOf(id) + this.getFeaturedStatusCount(); + } + } + + handleMoveUp = (id, featured) => { + const elementIndex = this.getCurrentStatusIndex(id, featured) - 1; + this._selectChild(elementIndex, true); + } + + handleMoveDown = (id, featured) => { + const elementIndex = this.getCurrentStatusIndex(id, featured) + 1; + this._selectChild(elementIndex, false); + } + + handleLoadOlder = debounce(() => { + this.props.onLoadMore(this.props.statusIds.size > 0 ? this.props.statusIds.last() : undefined); + }, 300, { leading: true }) + + _selectChild (index, align_top) { + const container = this.node.node; + const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`); + + if (element) { + if (align_top && container.scrollTop > element.offsetTop) { + element.scrollIntoView(true); + } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) { + element.scrollIntoView(false); + } + element.focus(); + } + } + + setRef = c => { + this.node = c; + } + + render () { + const { statusIds, featuredStatusIds, onLoadMore, timelineId, ...other } = this.props; + const { isLoading, isPartial } = other; + + if (isPartial) { + return <RegenerationIndicator />; + } + + let scrollableContent = (isLoading || statusIds.size > 0) ? ( + statusIds.map((statusId, index) => statusId === null ? ( + <LoadGap + key={'gap:' + statusIds.get(index + 1)} + disabled={isLoading} + maxId={index > 0 ? statusIds.get(index - 1) : null} + onClick={onLoadMore} + /> + ) : ( + <StatusContainer + key={statusId} + id={statusId} + onMoveUp={this.handleMoveUp} + onMoveDown={this.handleMoveDown} + contextType={timelineId} + /> + )) + ) : null; + + if (scrollableContent && featuredStatusIds) { + scrollableContent = featuredStatusIds.map(statusId => ( + <StatusContainer + key={`f-${statusId}`} + id={statusId} + featured + onMoveUp={this.handleMoveUp} + onMoveDown={this.handleMoveDown} + contextType={timelineId} + /> + )).concat(scrollableContent); + } + + return ( + <ScrollableList {...other} showLoading={isLoading && statusIds.size === 0} onLoadMore={onLoadMore && this.handleLoadOlder} ref={this.setRef}> + {scrollableContent} + </ScrollableList> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/status_prepend.js b/app/javascript/flavours/glitch/components/status_prepend.js new file mode 100644 index 000000000..637c4f23a --- /dev/null +++ b/app/javascript/flavours/glitch/components/status_prepend.js @@ -0,0 +1,104 @@ +// Package imports // +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { FormattedMessage } from 'react-intl'; +import Icon from 'flavours/glitch/components/icon'; +import { me } from 'flavours/glitch/util/initial_state'; + +export default class StatusPrepend extends React.PureComponent { + + static propTypes = { + type: PropTypes.string.isRequired, + account: ImmutablePropTypes.map.isRequired, + parseClick: PropTypes.func.isRequired, + notificationId: PropTypes.number, + }; + + handleClick = (e) => { + const { account, parseClick } = this.props; + parseClick(e, `/accounts/${account.get('id')}`); + } + + Message = () => { + const { type, account } = this.props; + let link = ( + <a + onClick={this.handleClick} + href={account.get('url')} + className='status__display-name' + > + <b + dangerouslySetInnerHTML={{ + __html : account.get('display_name_html') || account.get('username'), + }} + /> + </a> + ); + switch (type) { + case 'featured': + return ( + <FormattedMessage id='status.pinned' defaultMessage='Pinned toot' /> + ); + case 'reblogged_by': + return ( + <FormattedMessage + id='status.reblogged_by' + defaultMessage='{name} boosted' + values={{ name : link }} + /> + ); + case 'favourite': + return ( + <FormattedMessage + id='notification.favourite' + defaultMessage='{name} favourited your status' + values={{ name : link }} + /> + ); + case 'reblog': + return ( + <FormattedMessage + id='notification.reblog' + defaultMessage='{name} boosted your status' + values={{ name : link }} + /> + ); + case 'poll': + if (me === account.get('id')) { + return ( + <FormattedMessage + id='notification.own_poll' + defaultMessage='Your poll has ended' + /> + ); + } else { + return ( + <FormattedMessage + id='notification.poll' + defaultMessage='A poll you have voted in has ended' + /> + ); + } + } + return null; + } + + render () { + const { Message } = this; + const { type } = this.props; + + return !type ? null : ( + <aside className={type === 'reblogged_by' || type === 'featured' ? 'status__prepend' : 'notification__message'}> + <div className={type === 'reblogged_by' || type === 'featured' ? 'status__prepend-icon-wrapper' : 'notification__favourite-icon-wrapper'}> + <Icon + className={`status__prepend-icon ${type === 'favourite' ? 'star-icon' : ''}`} + id={type === 'favourite' ? 'star' : (type === 'featured' ? 'thumb-tack' : (type === 'poll' ? 'tasks' : 'retweet'))} + /> + </div> + <Message /> + </aside> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/status_visibility_icon.js b/app/javascript/flavours/glitch/components/status_visibility_icon.js new file mode 100644 index 000000000..e2e0f30b8 --- /dev/null +++ b/app/javascript/flavours/glitch/components/status_visibility_icon.js @@ -0,0 +1,51 @@ +// Package imports // +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import Icon from 'flavours/glitch/components/icon'; + +const messages = defineMessages({ + public: { id: 'privacy.public.short', defaultMessage: 'Public' }, + unlisted: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, + private: { id: 'privacy.private.short', defaultMessage: 'Followers-only' }, + direct: { id: 'privacy.direct.short', defaultMessage: 'Direct' }, +}); + +export default @injectIntl +class VisibilityIcon extends ImmutablePureComponent { + + static propTypes = { + visibility: PropTypes.string, + intl: PropTypes.object.isRequired, + withLabel: PropTypes.bool, + }; + + render() { + const { withLabel, visibility, intl } = this.props; + + const visibilityIcon = { + public: 'globe', + unlisted: 'unlock', + private: 'lock', + direct: 'envelope', + }[visibility]; + + const label = intl.formatMessage(messages[visibility]); + + const icon = (<Icon + className='status__visibility-icon' + fixedWidth + id={visibilityIcon} + title={label} + aria-hidden='true' + />); + + if (withLabel) { + return (<span style={{ whiteSpace: 'nowrap' }}>{icon} {label}</span>); + } else { + return icon; + } + } + +} diff --git a/app/javascript/flavours/glitch/components/timeline_hint.js b/app/javascript/flavours/glitch/components/timeline_hint.js new file mode 100644 index 000000000..fb55a62cc --- /dev/null +++ b/app/javascript/flavours/glitch/components/timeline_hint.js @@ -0,0 +1,18 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; + +const TimelineHint = ({ resource, url }) => ( + <div className='timeline-hint'> + <strong><FormattedMessage id='timeline_hint.remote_resource_not_displayed' defaultMessage='{resource} from other servers are not displayed.' values={{ resource }} /></strong> + <br /> + <a href={url} target='_blank'><FormattedMessage id='account.browse_more_on_origin_server' defaultMessage='Browse more on the original profile' /></a> + </div> +); + +TimelineHint.propTypes = { + resource: PropTypes.node.isRequired, + url: PropTypes.string.isRequired, +}; + +export default TimelineHint; |