diff options
Diffstat (limited to 'app/javascript/flavours/glitch/components')
9 files changed, 200 insertions, 285 deletions
diff --git a/app/javascript/flavours/glitch/components/account.js b/app/javascript/flavours/glitch/components/account.js index a1f075491..ced18b348 100644 --- a/app/javascript/flavours/glitch/components/account.js +++ b/app/javascript/flavours/glitch/components/account.js @@ -30,6 +30,7 @@ export default class Account extends ImmutablePureComponent { onMuteNotifications: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, hidden: PropTypes.bool, + small: PropTypes.bool, }; handleFollow = () => { @@ -53,7 +54,12 @@ export default class Account extends ImmutablePureComponent { } render () { - const { account, intl, hidden } = this.props; + const { + account, + hidden, + intl, + small, + } = this.props; if (!account) { return <div />; @@ -70,7 +76,7 @@ export default class Account extends ImmutablePureComponent { let buttons; - if (account.get('id') !== me && account.get('relationship', null) !== null) { + 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']); @@ -98,17 +104,23 @@ export default class Account extends ImmutablePureComponent { } } - return ( + return small ? ( + <div className='account small'> + <div className='account__avatar-wrapper'><Avatar account={account} size={18} /></div> + <DisplayName account={account} /> + </div> + ) : ( <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> - - <div className='account__relationship'> - {buttons} - </div> + {buttons ? + <div className='account__relationship'> + {buttons} + </div> + : null} </div> </div> ); diff --git a/app/javascript/flavours/glitch/components/autosuggest_emoji.js b/app/javascript/flavours/glitch/components/autosuggest_emoji.js deleted file mode 100644 index 79e113d9c..000000000 --- a/app/javascript/flavours/glitch/components/autosuggest_emoji.js +++ /dev/null @@ -1,42 +0,0 @@ -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='autosuggest-emoji'> - <img - className='emojione' - src={url} - alt={emoji.native || emoji.colons} - /> - - {emoji.colons} - </div> - ); - } - -} diff --git a/app/javascript/flavours/glitch/components/autosuggest_textarea.js b/app/javascript/flavours/glitch/components/autosuggest_textarea.js deleted file mode 100644 index a29b2c9c5..000000000 --- a/app/javascript/flavours/glitch/components/autosuggest_textarea.js +++ /dev/null @@ -1,223 +0,0 @@ -import React from 'react'; -import AutosuggestAccountContainer from 'flavours/glitch/features/compose/containers/autosuggest_account_container'; -import AutosuggestEmoji from './autosuggest_emoji'; -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 + 1, 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: 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; - } - - switch(e.key) { - case 'Escape': - if (!suggestionsHidden) { - 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); - } - - onKeyUp = e => { - if (e.key === 'Escape' && this.state.suggestionsHidden) { - document.querySelector('.ui').parentElement.focus(); - } - - if (this.props.onKeyUp) { - this.props.onKeyUp(e); - } - } - - onBlur = () => { - this.setState({ suggestionsHidden: 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.textarea.focus(); - } - - componentWillReceiveProps (nextProps) { - if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden) { - 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 (typeof suggestion === 'object') { - inner = <AutosuggestEmoji emoji={suggestion} />; - key = suggestion.id; - } else { - inner = <AutosuggestAccountContainer id={suggestion} />; - key = suggestion; - } - - 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, autoFocus } = this.props; - const { suggestionsHidden } = this.state; - const style = { direction: 'ltr' }; - - if (isRtl(value)) { - style.direction = 'rtl'; - } - - return ( - <div className='autosuggest-textarea'> - <label> - <span style={{ display: 'none' }}>{placeholder}</span> - - <Textarea - inputRef={this.setTextarea} - className='autosuggest-textarea__textarea' - disabled={disabled} - placeholder={placeholder} - autoFocus={autoFocus} - value={value} - onChange={this.onChange} - onKeyDown={this.onKeyDown} - onKeyUp={this.onKeyUp} - onBlur={this.onBlur} - onPaste={this.onPaste} - style={style} - aria-autocomplete='list' - /> - </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/avatar.js b/app/javascript/flavours/glitch/components/avatar.js index 82ab0f45a..c5e9072c4 100644 --- a/app/javascript/flavours/glitch/components/avatar.js +++ b/app/javascript/flavours/glitch/components/avatar.js @@ -1,3 +1,4 @@ +import classNames from 'classnames'; import React from 'react'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; @@ -7,6 +8,7 @@ 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, @@ -34,17 +36,19 @@ export default class Avatar extends React.PureComponent { } render () { - const { account, size, animate, inline } = this.props; + const { + account, + animate, + className, + inline, + size, + } = this.props; const { hovering } = this.state; const src = account.get('avatar'); const staticSrc = account.get('avatar_static'); - let className = 'account__avatar'; - - if (inline) { - className = className + ' account__avatar-inline'; - } + const computedClass = classNames('account__avatar', { 'account__avatar-inline': inline }, className); const style = { ...this.props.style, @@ -61,7 +65,7 @@ export default class Avatar extends React.PureComponent { return ( <div - className={className} + className={computedClass} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} style={style} diff --git a/app/javascript/flavours/glitch/components/display_name.js b/app/javascript/flavours/glitch/components/display_name.js index 2cf84f8f4..ad1c3a534 100644 --- a/app/javascript/flavours/glitch/components/display_name.js +++ b/app/javascript/flavours/glitch/components/display_name.js @@ -1,3 +1,5 @@ +import classNames from 'classnames'; +import PropTypes from 'prop-types'; import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; @@ -5,13 +7,19 @@ export default class DisplayName extends React.PureComponent { static propTypes = { account: ImmutablePropTypes.map.isRequired, + className: PropTypes.string, }; render () { - const displayNameHtml = { __html: this.props.account.get('display_name_html') }; + const { + account, + className, + } = this.props; + const computedClass = classNames('display-name', className); + const displayNameHtml = { __html: account.get('display_name_html') }; return ( - <span className='display-name'> + <span className={computedClass}> <strong className='display-name__html' dangerouslySetInnerHTML={displayNameHtml} /> <span className='display-name__account'>@{this.props.account.get('acct')}</span> </span> ); diff --git a/app/javascript/flavours/glitch/components/dropdown_menu.js b/app/javascript/flavours/glitch/components/dropdown_menu.js index d4a886a8b..706390c92 100644 --- a/app/javascript/flavours/glitch/components/dropdown_menu.js +++ b/app/javascript/flavours/glitch/components/dropdown_menu.js @@ -133,8 +133,13 @@ export default class Dropdown extends React.PureComponent { this.props.onModalOpen({ status, - actions: items, - onClick: this.handleItemClick, + actions: items.map( + (item, i) => item ? { + ...item, + name: `${item.text}-${i}`, + onClick: this.handleItemClick.bind(i), + } : null + ), }); return; @@ -162,8 +167,7 @@ export default class Dropdown extends React.PureComponent { } } - handleItemClick = e => { - const i = Number(e.currentTarget.getAttribute('data-index')); + handleItemClick = (i, e) => { const { action, to } = this.props.items[i]; this.handleClose(); diff --git a/app/javascript/flavours/glitch/components/icon.js b/app/javascript/flavours/glitch/components/icon.js new file mode 100644 index 000000000..8f55a0115 --- /dev/null +++ b/app/javascript/flavours/glitch/components/icon.js @@ -0,0 +1,26 @@ +// Package imports. +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React from 'react'; + +// This just renders a FontAwesome icon. +export default function Icon ({ + className, + fullwidth, + icon, +}) { + const computedClass = classNames('icon', 'fa', { 'fa-fw': fullwidth }, `fa-${icon}`, className); + return icon ? ( + <span + aria-hidden='true' + className={computedClass} + /> + ) : null; +} + +// Props. +Icon.propTypes = { + className: PropTypes.string, + fullwidth: PropTypes.bool, + icon: PropTypes.string, +}; 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/text_icon_button.js b/app/javascript/flavours/glitch/components/text_icon_button.js new file mode 100644 index 000000000..9c8ffab1f --- /dev/null +++ b/app/javascript/flavours/glitch/components/text_icon_button.js @@ -0,0 +1,29 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +export default class TextIconButton extends React.PureComponent { + + static propTypes = { + label: PropTypes.string.isRequired, + title: PropTypes.string, + active: PropTypes.bool, + onClick: PropTypes.func.isRequired, + ariaControls: PropTypes.string, + }; + + handleClick = (e) => { + e.preventDefault(); + this.props.onClick(); + } + + render () { + const { label, title, active, ariaControls } = this.props; + + return ( + <button title={title} aria-label={title} className={`text-icon-button ${active ? 'active' : ''}`} aria-expanded={active} onClick={this.handleClick} aria-controls={ariaControls}> + {label} + </button> + ); + } + +} |