Large update for all components

reorganization, linting, updating file imports, consolidation
warning: there will be errors in this commit
todo: update webpack, add missing styles, scss files, consolidate the rest of components within features/*
This commit is contained in:
mgabdev
2019-08-07 01:02:36 -04:00
parent 5505f60119
commit 280dc51d85
341 changed files with 8876 additions and 8321 deletions

View File

@@ -1,8 +1,8 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Button /> adds class "button-secondary" if props.secondary given 1`] = `
exports[`<Button /> adds class "button--secondary" if props.secondary given 1`] = `
<button
className="button button-secondary"
className="button button--secondary"
onClick={[Function]}
style={
Object {

View File

@@ -65,7 +65,7 @@ describe('<Button />', () => {
expect(tree).toMatchSnapshot();
});
it('adds class "button-secondary" if props.secondary given', () => {
it('adds class "button--secondary" if props.secondary given', () => {
const component = renderer.create(<Button secondary />);
const tree = component.toJSON();

View File

@@ -0,0 +1,128 @@
import { Fragment } from 'react';
import { Link } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Avatar from '../avatar/avatar';
import DisplayName from '../display_name';
import IconButton from '../icon_button';
import { me } from '../../initial_state';
import './account.scss';
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: 'Mute notifications from @{name}' },
unmute_notifications: { id: 'account.unmute_notifications', defaultMessage: 'Unmute notifications from @{name}' },
});
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,
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, intl, hidden, 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 && 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 (
<div className='account'>
<div className='account__wrapper'>
<Link key={account.get('id')} className='account__display-name' title={account.get('acct')} to={`/${account.get('acct')}`}>
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
<DisplayName account={account} />
</Link>
<div className='account__relationship'>
{buttons}
</div>
</div>
</div>
);
}
}

View File

@@ -1,128 +1 @@
import { Fragment } from 'react';
import { Link } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Avatar from '../avatar';
import DisplayName from '../display_name';
import IconButton from '../icon_button';
import { me } from '../../initial_state';
import './index.scss';
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: 'Mute notifications from @{name}' },
unmute_notifications: { id: 'account.unmute_notifications', defaultMessage: 'Unmute notifications from @{name}' },
});
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,
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, intl, hidden, 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 && 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 (
<div className='account'>
<div className='account__wrapper'>
<Link key={account.get('id')} className='account__display-name' title={account.get('acct')} to={`/${account.get('acct')}`}>
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
<DisplayName account={account} />
</Link>
<div className='account__relationship'>
{buttons}
</div>
</div>
</div>
);
}
}
export { default } from './account';

View File

@@ -0,0 +1,35 @@
import unicodeMapping from '../emoji/emoji_unicode_mapping_light';
import './autosuggest_emoji.scss';
const assetHost = process.env.CDN_HOST || '';
export default class AutosuggestEmoji extends 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>
);
}
}

View File

@@ -1,35 +1 @@
import unicodeMapping from '../../features/emoji/emoji_unicode_mapping_light';
import './index.scss';
const assetHost = process.env.CDN_HOST || '';
export default class AutosuggestEmoji extends 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>
);
}
}
export { default } from './autosuggest_emoji';

View File

@@ -0,0 +1,267 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
import classNames from 'classnames';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Textarea from 'react-textarea-autosize';
import AutosuggestAccount from '../../features/compose/components/autosuggest_account';
import AutosuggestEmoji from '../autosuggest_emoji';
import { isRtl } from '../../utils/rtl';
import { textAtCursorMatchesToken } from '../../utils/cursor_token_match';
import './autosuggest_textbox.scss';
export default class AutosuggestTextbox 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,
onPaste: PropTypes.func,
onFocus: PropTypes.func,
onBlur: PropTypes.func,
textarea: PropTypes.bool,
};
static defaultProps = {
autoFocus: true,
searchTokens: ['@', ':', '#'],
textarea: false,
};
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;
}
// 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)
if (e.which === 229 || e.isComposing) 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 });
if (this.props.onBlur) {
this.props.onBlur();
}
}
onFocus = () => {
this.setState({ focused: true });
if (this.props.onFocus) {
this.props.onFocus();
}
}
onPaste = (e) => {
if (this.props.onPaste && e.clipboardData && e.clipboardData.files.length === 1) {
this.props.onPaste(e.clipboardData.files);
e.preventDefault();
}
}
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.textbox.focus();
}
componentWillReceiveProps (nextProps) {
if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
this.setState({ suggestionsHidden: false });
}
}
setTextbox = (c) => {
this.textbox = c;
}
renderSuggestion = (suggestion, i) => {
const { selectedSuggestion } = this.state;
let inner, key;
if (typeof suggestion === 'object') {
inner = <AutosuggestEmoji emoji={suggestion} />;
key = suggestion.id;
} else if (suggestion[0] === '#') {
inner = suggestion;
key = suggestion;
} else {
inner = <AutosuggestAccount id={suggestion} />;
key = suggestion;
}
const classes = classNames('autosuggest-textarea__suggestions__item', {
selected: i === selectedSuggestion,
});
return (
<div
role='button'
tabIndex='0'
key={key}
data-index={i}
className={classes}
onMouseDown={this.onSuggestionClick}
>
{inner}
</div>
);
}
render () {
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, children, className, id, maxLength, textarea } = this.props;
const { suggestionsHidden } = this.state;
const style = { direction: 'ltr' };
if (isRtl(value)) {
style.direction = 'rtl';
}
if (textarea) {
return [
<div className='autosuggest-textarea__wrapper' key='autosuggest-textarea__wrapper'>
<div className='autosuggest-textarea'>
<label>
<span style={{ display: 'none' }}>{placeholder}</span>
<Textarea
inputRef={this.setTextbox}
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='autosuggest-textarea__suggestions-wrapper'>
<div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
{suggestions.map(this.renderSuggestion)}
</div>
</div>,
];
}
return (
<div className='autosuggest-input'>
<label>
<span style={{ display: 'none' }}>{placeholder}</span>
<input
type='text'
ref={this.setTextbox}
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>
);
}
}

View File

@@ -1,267 +1 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
import classNames from 'classnames';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Textarea from 'react-textarea-autosize';
import AutosuggestAccountContainer from '../../features/compose/containers/autosuggest_account_container';
import AutosuggestEmoji from '../autosuggest_emoji';
import { isRtl } from '../../utils/rtl';
import { textAtCursorMatchesToken } from '../../utils/cursor_token_match';
import './index.scss';
export default class AutosuggestTextbox 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,
onPaste: PropTypes.func,
onFocus: PropTypes.func,
onBlur: PropTypes.func,
textarea: PropTypes.bool,
};
static defaultProps = {
autoFocus: true,
searchTokens: ['@', ':', '#'],
textarea: false,
};
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;
}
// 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)
if (e.which === 229 || e.isComposing) 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 });
if (this.props.onBlur) {
this.props.onBlur();
}
}
onFocus = () => {
this.setState({ focused: true });
if (this.props.onFocus) {
this.props.onFocus();
}
}
onPaste = (e) => {
if (this.props.onPaste && e.clipboardData && e.clipboardData.files.length === 1) {
this.props.onPaste(e.clipboardData.files);
e.preventDefault();
}
}
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.textbox.focus();
}
componentWillReceiveProps (nextProps) {
if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
this.setState({ suggestionsHidden: false });
}
}
setTextbox = (c) => {
this.textbox = c;
}
renderSuggestion = (suggestion, i) => {
const { selectedSuggestion } = this.state;
let inner, key;
if (typeof suggestion === 'object') {
inner = <AutosuggestEmoji emoji={suggestion} />;
key = suggestion.id;
} else if (suggestion[0] === '#') {
inner = suggestion;
key = suggestion;
} else {
inner = <AutosuggestAccountContainer id={suggestion} />;
key = suggestion;
}
const classes = classNames('autosuggest-textarea__suggestions__item', {
selected: i === selectedSuggestion,
});
return (
<div
role='button'
tabIndex='0'
key={key}
data-index={i}
className={classes}
onMouseDown={this.onSuggestionClick}
>
{inner}
</div>
);
}
render () {
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, children, className, id, maxLength, textarea } = this.props;
const { suggestionsHidden } = this.state;
const style = { direction: 'ltr' };
if (isRtl(value)) {
style.direction = 'rtl';
}
if (textarea) {
return [
<div className='autosuggest-textarea__wrapper' key='autosuggest-textarea__wrapper'>
<div className='autosuggest-textarea'>
<label>
<span style={{ display: 'none' }}>{placeholder}</span>
<Textarea
inputRef={this.setTextbox}
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='autosuggest-textarea__suggestions-wrapper'>
<div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
{suggestions.map(this.renderSuggestion)}
</div>
</div>,
];
}
return (
<div className='autosuggest-input'>
<label>
<span style={{ display: 'none' }}>{placeholder}</span>
<input
type='text'
ref={this.setTextbox}
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>
);
}
}
export { default } from './autosuggest_textbox';

View File

@@ -0,0 +1,69 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { Map as ImmutableMap } from 'immutable';
import classNames from 'classnames';
import { autoPlayGif } from '../../initial_state';
import './avatar.scss';
export default class Avatar extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map,
size: PropTypes.number,
inline: PropTypes.bool,
animate: PropTypes.bool,
};
static defaultProps = {
account: ImmutableMap(),
animate: autoPlayGif,
inline: false,
};
state = {
hovering: false,
sameImg: this.props.account.get('avatar') === this.props.account.get('avatar_static'),
};
handleMouseEnter = () => {
if (this.props.animate || this.state.sameImg) return;
this.setState({ hovering: true });
}
handleMouseLeave = () => {
if (this.props.animate || this.state.sameImg) return;
this.setState({ hovering: false });
}
render () {
const { account, size, animate, inline } = this.props;
const { hovering } = this.state;
// : TODO : remove inline and change all avatars to be sized using css
const style = !size ? {} : {
width: `${size}px`,
height: `${size}px`,
};
const theSrc = account.get((hovering || animate) ? 'avatar' : 'avatar_static');
const className = classNames('account__avatar', {
'account__avatar--inline': inline,
});
return (
<img
className={className}
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
style={style}
src={theSrc}
alt={account.get('display_name')}
/>
);
}
}

View File

@@ -1,69 +1 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { Map as ImmutableMap } from 'immutable';
import classNames from 'classnames';
import { autoPlayGif } from '../../initial_state';
import './index.scss';
export default class Avatar extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map,
size: PropTypes.number,
inline: PropTypes.bool,
animate: PropTypes.bool,
};
static defaultProps = {
account: ImmutableMap(),
animate: autoPlayGif,
inline: false,
};
state = {
hovering: false,
sameImg: this.props.account.get('avatar') === this.props.account.get('avatar_static'),
};
handleMouseEnter = () => {
if (this.props.animate || this.state.sameImg) return;
this.setState({ hovering: true });
}
handleMouseLeave = () => {
if (this.props.animate || this.state.sameImg) return;
this.setState({ hovering: false });
}
render () {
const { account, size, animate, inline } = this.props;
const { hovering } = this.state;
// : TODO : remove inline and change all avatars to be sized using css
const style = !size ? {} : {
width: `${size}px`,
height: `${size}px`,
};
const theSrc = account.get((hovering || animate) ? 'avatar' : 'avatar_static');
const className = classNames('account__avatar', {
'account__avatar--inline': inline,
});
return (
<img
className={className}
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
style={style}
src={theSrc}
alt={account.get('display_name')}
/>
);
}
}
export { default } from './avatar';

View File

@@ -0,0 +1,33 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { autoPlayGif } from '../../initial_state';
import './avatar_overlay.scss';
export default class AvatarOverlay extends ImmutablePureComponent {
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 baseSrc = account.get(animate ? 'avatar' : 'avatar_static');
const overlaySrc = friend.get(animate ? 'avatar' : 'avatar_static');
return (
<div className='avatar-overlay'>
<img className='avatar-overlay__base' src={baseSrc} />
<img className='avatar-overlay__overlay' src={overlaySrc} />
</div>
);
}
}

View File

@@ -1,33 +1 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { autoPlayGif } from '../../initial_state';
import './index.scss';
export default class AvatarOverlay extends ImmutablePureComponent {
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 baseSrc = account.get(animate ? 'avatar' : 'avatar_static');
const overlaySrc = friend.get(animate ? 'avatar' : 'avatar_static');
return (
<div className='avatar-overlay'>
<img className='avatar-overlay__base' src={baseSrc} />
<img className='avatar-overlay__overlay' src={overlaySrc} />
</div>
);
}
}
export { default } from './avatar_overlay';

View File

@@ -0,0 +1,25 @@
import './badge.scss';
export default class Badge extends PureComponent {
static propTypes = {
type: PropTypes.oneOf([
'pro',
'donor',
'investor',
]).isRequired,
};
render() {
const { type } = this.props;
if (!type) return null;
return (
<span className={`badge badge--${type}`}>
{type.toUpperCase()}
</span>
);
}
};

View File

@@ -1,25 +1 @@
import './index.scss';
export default class Badge extends PureComponent {
static propTypes = {
type: PropTypes.oneOf([
'pro',
'donor',
'investor',
]).isRequired,
};
render() {
const { type } = this.props;
if (!type) return null;
return (
<span className={`badge badge--${type}`}>
{type.toUpperCase()}
</span>
);
}
};
export { default } from './badge';

View File

@@ -0,0 +1,40 @@
import { defineMessages, injectIntl } from 'react-intl';
import Column from '../column';
import ColumnHeader from '../column_header';
import IconButton from '../icon_button';
import './bundle_column_error.scss';
const messages = defineMessages({
title: { id: 'bundle_column_error.title', defaultMessage: 'Network error' },
body: { id: 'bundle_column_error.body', defaultMessage: 'Something went wrong while loading this component.' },
retry: { id: 'bundle_column_error.retry', defaultMessage: 'Try again' },
});
export default @injectIntl
class BundleColumnError extends PureComponent {
static propTypes = {
onRetry: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
}
handleRetry = () => {
this.props.onRetry();
}
render () {
const { intl: { formatMessage } } = this.props;
return (
<Column>
<ColumnHeader icon='exclamation-circle' type={formatMessage(messages.title)} />
<div className='error-column'>
<IconButton title={formatMessage(messages.retry)} icon='refresh' onClick={this.handleRetry} size={64} />
{formatMessage(messages.body)}
</div>
</Column>
);
}
}

View File

@@ -0,0 +1,29 @@
.error-column {
color: $dark-text-color;
background: $ui-base-color;
padding: 40px;
cursor: default;
flex: 1 1 auto;
min-height: 160px;
@include flex(center, center, column);
@include text-sizing(15px, 400, 1, center);
@supports(display: grid) {
// hack to fix Chrome <57
contain: strict;
}
&>span {
max-width: 400px;
}
a {
color: $highlight-text-color;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}

View File

@@ -0,0 +1 @@
export { default } from './bundle_column_error';

View File

@@ -0,0 +1,52 @@
import { defineMessages, injectIntl } from 'react-intl';
import IconButton from '../icon_button';
import './bundle_modal_error.scss';
const messages = defineMessages({
error: { id: 'bundle_modal_error.message', defaultMessage: 'Something went wrong while loading this component.' },
retry: { id: 'bundle_modal_error.retry', defaultMessage: 'Try again' },
close: { id: 'bundle_modal_error.close', defaultMessage: 'Close' },
});
export default @injectIntl
class BundleModalError extends PureComponent {
static propTypes = {
onRetry: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
}
handleRetry = () => {
this.props.onRetry();
}
render () {
const { onClose, intl: { formatMessage } } = this.props;
// Keep the markup in sync with <ModalLoading />
// (make sure they have the same dimensions)
return (
<div className='modal-root__modal error-modal'>
<div className='error-modal__body'>
<IconButton title={formatMessage(messages.retry)} icon='refresh' onClick={this.handleRetry} size={64} />
{formatMessage(messages.error)}
</div>
<div className='error-modal__footer'>
<div>
<button
onClick={onClose}
className='error-modal__nav onboarding-modal__skip'
>
{formatMessage(messages.close)}
</button>
</div>
</div>
</div>
);
}
}

View File

@@ -0,0 +1 @@
export { default } from './bundle_modal_error';

View File

@@ -0,0 +1,49 @@
import classNames from 'classnames';
import './button.scss';
export default class Button extends PureComponent {
static propTypes = {
text: PropTypes.node,
onClick: PropTypes.func,
disabled: PropTypes.bool,
block: PropTypes.bool,
secondary: PropTypes.bool,
className: PropTypes.string,
children: PropTypes.node,
};
handleClick = (e) => {
if (!this.props.disabled && this.props.onClick) {
this.props.onClick(e);
}
}
setRef = (c) => {
this.node = c;
}
focus() {
this.node.focus();
}
render () {
const className = classNames('button', this.props.className, {
'button--secondary': this.props.secondary,
'button--block': this.props.block,
});
return (
<button
className={className}
disabled={this.props.disabled}
onClick={this.handleClick}
ref={this.setRef}
>
{this.props.text || this.props.children}
</button>
);
}
}

View File

@@ -1,47 +1 @@
import classNames from 'classnames';
export default class Button extends PureComponent {
static propTypes = {
text: PropTypes.node,
onClick: PropTypes.func,
disabled: PropTypes.bool,
block: PropTypes.bool,
secondary: PropTypes.bool,
className: PropTypes.string,
children: PropTypes.node,
};
handleClick = (e) => {
if (!this.props.disabled && this.props.onClick) {
this.props.onClick(e);
}
}
setRef = (c) => {
this.node = c;
}
focus() {
this.node.focus();
}
render () {
const className = classNames('button', this.props.className, {
'button--secondary': this.props.secondary,
'button--block': this.props.block,
});
return (
<button
className={className}
disabled={this.props.disabled}
onClick={this.handleClick}
ref={this.setRef}
>
{this.props.text || this.props.children}
</button>
);
}
}
export { default } from './button';

View File

@@ -0,0 +1,38 @@
import ColumnHeader from '../column_header';
import { isMobile } from '../../utils/is_mobile';
import ColumnBackButton from '../column_back_button';
import './column.scss';
export default class Column extends PureComponent {
static propTypes = {
heading: PropTypes.string,
icon: PropTypes.string,
children: PropTypes.node,
active: PropTypes.bool,
hideHeadingOnMobile: PropTypes.bool,
backBtn: PropTypes.oneOf([
'normal',
'slim',
]),
};
render () {
const { heading, icon, children, active, hideHeadingOnMobile, backBtn } = this.props;
const showHeading = heading && (!hideHeadingOnMobile || (hideHeadingOnMobile && !isMobile(window.innerWidth)));
const columnHeaderId = showHeading && heading.replace(/ /g, '-');
// const header = showHeading && (
// <ColumnHeader icon={icon} active={active} type={heading} columnHeaderId={columnHeaderId} />
// );
return (
<div role='region' aria-labelledby={columnHeaderId} className='column'>
{ backBtn && <ColumnBackButton slim={backBtn === 'slim'} />}
{children}
</div>
);
}
}

View File

@@ -1,20 +1 @@
import './index.scss';
export default class Column extends PureComponent {
static propTypes = {
children: PropTypes.node,
label: PropTypes.string,
};
render () {
const { label, children } = this.props;
return (
<div role='region' aria-label={label} className='column'>
{children}
</div>
);
}
}
export { default } from './column';

View File

@@ -0,0 +1,40 @@
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import Icon from '../icon';
import './column_back_button.scss';
export default class ColumnBackButton extends PureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
slim: PropTypes.bool,
};
handleClick = () => {
if (window.history && window.history.length === 1) {
this.context.router.history.push('/home'); // homehack
} else {
this.context.router.history.goBack();
}
}
render () {
const { slim } = this.props;
const btnClasses = classNames('column-back-button', {
'column-back-button--slim': slim,
});
return (
<button className={btnClasses} onClick={this.handleClick}>
<Icon id='chevron-left' className='column-back-button__icon' fixedWidth />
<FormattedMessage id='column_back_button.label' defaultMessage='Back' />
</button>
);
}
}

View File

@@ -1,40 +1 @@
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import Icon from '../icon';
import './index.scss';
export default class ColumnBackButton extends PureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
slim: PropTypes.bool,
};
handleClick = () => {
if (window.history && window.history.length === 1) {
this.context.router.history.push('/home'); // homehack
} else {
this.context.router.history.goBack();
}
}
render () {
const { slim } = this.props;
const btnClasses = classNames('column-back-button', {
'column-back-button--slim': slim,
});
return (
<button className={btnClasses} onClick={this.handleClick}>
<Icon id='chevron-left' className='column-back-button__icon' fixedWidth />
<FormattedMessage id='column_back_button.label' defaultMessage='Back' />
</button>
);
}
}
export { default } from './column_back_button';

View File

@@ -0,0 +1,125 @@
import { Fragment } from 'react';
import classNames from 'classnames';
import { injectIntl, defineMessages } from 'react-intl';
import Icon from '../icon';
import './column_header.scss';
const messages = defineMessages({
show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' },
});
export default @injectIntl
class ColumnHeader extends PureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
intl: PropTypes.object.isRequired,
title: PropTypes.node,
icon: PropTypes.string,
active: PropTypes.bool,
children: PropTypes.node,
};
state = {
collapsed: true,
};
historyBack = () => {
if (window.history && window.history.length === 1) {
this.context.router.history.push('/home'); // homehack
} else {
this.context.router.history.goBack();
}
}
handleToggleClick = (e) => {
e.stopPropagation();
this.setState({
collapsed: !this.state.collapsed,
});
}
handleBackClick = () => {
this.historyBack();
}
render () {
const { title, icon, active, children, intl: { formatMessage } } = this.props;
const { collapsed } = this.state;
const wrapperClassName = classNames('column-header__wrapper', {
'column-header__wrapper--active': active,
});
const buttonClassName = classNames('column-header', {
'column-header--active': active,
});
const btnTitle = formatMessage(collapsed ? messages.show : messages.hide);
const hasTitle = icon && title;
const hasChildren = !!children;
if (!hasChildren && !hasTitle) {
return null;
} else if (!hasChildren && hasTitle) {
return (
<div className={wrapperClassName}>
<h1 className={buttonClassName}>
<Icon id={icon} fixedWidth className='column-header__icon' />
{title}
</h1>
</div>
);
}
const collapsibleClassName = classNames('column-header__collapsible', {
'column-header__collapsible--collapsed': collapsed,
});
const collapsibleButtonClassName = classNames('column-header__button', {
'column-header__button--active': !collapsed,
});
return (
<div className={wrapperClassName}>
<h1 className={buttonClassName}>
{
hasTitle && (
<Fragment>
<Icon id={icon} fixedWidth className='column-header__icon' />
{title}
</Fragment>
)
}
<button
className={collapsibleButtonClassName}
title={btnTitle}
aria-label={btnTitle}
aria-pressed={!collapsed}
onClick={this.handleToggleClick}
>
<Icon id='sliders' />
</button>
</h1>
<div className={collapsibleClassName} tabIndex={collapsed ? -1 : null}>
<div className='column-header__collapsible-inner'>
{
!collapsed &&
<div key='extra-content' className='column-header__collapsible__extra'>
{children}
</div>
}
</div>
</div>
</div>
);
}
}

View File

@@ -35,7 +35,6 @@
&--active {
&::before {
margin: 0 auto;
pointer-events: none;
z-index: 1;
background: radial-gradient(ellipse, rgba($ui-highlight-color, 0.23) 0%, rgba($ui-highlight-color, 0) 60%);
@@ -43,6 +42,7 @@
@include pseudo;
@include size(60%, 28px);
@include abs-position(35px, 0, auto, 0, false);
@include margin-center;
}
}
}

View File

@@ -7,7 +7,7 @@ import { createSelector } from 'reselect';
import Icon from '../icon';
import { fetchLists } from '../../actions/lists';
import './index.scss';
import './column_header.scss';
const messages = defineMessages({
show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
@@ -31,7 +31,9 @@ const mapStateToProps = state => {
};
};
class ColumnHeader extends ImmutablePureComponent {
export default @connect(mapStateToProps)
@injectIntl
class HomeColumnHeader extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
@@ -165,6 +167,4 @@ class ColumnHeader extends ImmutablePureComponent {
);
}
}
export default injectIntl(connect(mapStateToProps)(ColumnHeader));
}

View File

@@ -1,125 +1,7 @@
import { Fragment } from 'react';
import classNames from 'classnames';
import { injectIntl, defineMessages } from 'react-intl';
import Icon from '../icon';
import ColumnHeader from './column_header';
import HomeColumnHeader from './home_column_header';
import './index.scss';
const messages = defineMessages({
show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' },
});
export default @injectIntl
class ColumnHeader extends PureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
intl: PropTypes.object.isRequired,
title: PropTypes.node,
icon: PropTypes.string,
active: PropTypes.bool,
children: PropTypes.node,
};
state = {
collapsed: true,
};
historyBack = () => {
if (window.history && window.history.length === 1) {
this.context.router.history.push('/home'); // homehack
} else {
this.context.router.history.goBack();
}
}
handleToggleClick = (e) => {
e.stopPropagation();
this.setState({
collapsed: !this.state.collapsed,
});
}
handleBackClick = () => {
this.historyBack();
}
render () {
const { title, icon, active, children, intl: { formatMessage } } = this.props;
const { collapsed } = this.state;
const wrapperClassName = classNames('column-header__wrapper', {
'column-header__wrapper--active': active,
});
const buttonClassName = classNames('column-header', {
'column-header--active': active,
});
const btnTitle = formatMessage(collapsed ? messages.show : messages.hide);
const hasTitle = icon && title;
const hasChildren = !!children;
if (!hasChildren && !hasTitle) {
return null;
} else if (!hasChildren && hasTitle) {
return (
<div className={wrapperClassName}>
<h1 className={buttonClassName}>
<Icon id={icon} fixedWidth className='column-header__icon' />
{title}
</h1>
</div>
);
}
const collapsibleClassName = classNames('column-header__collapsible', {
'column-header__collapsible--collapsed': collapsed,
});
const collapsibleButtonClassName = classNames('column-header__button', {
'column-header__button--active': !collapsed,
});
return (
<div className={wrapperClassName}>
<h1 className={buttonClassName}>
{
hasTitle && (
<Fragment>
<Icon id={icon} fixedWidth className='column-header__icon' />
{title}
</Fragment>
)
}
<button
className={collapsibleButtonClassName}
title={btnTitle}
aria-label={btnTitle}
aria-pressed={!collapsed}
onClick={this.handleToggleClick}
>
<Icon id='sliders' />
</button>
</h1>
<div className={collapsibleClassName} tabIndex={collapsed ? -1 : null}>
<div className='column-header__collapsible-inner'>
{
!collapsed &&
<div key='extra-content' className='column-header__collapsible__extra'>
{children}
</div>
}
</div>
</div>
</div>
);
}
}
export {
ColumnHeader,
HomeColumnHeader,
}

View File

@@ -0,0 +1,42 @@
import { defineMessages, injectIntl } from 'react-intl';
import Column from '../column';
import './column_indicator.scss';
const messages = defineMessages({
loading: { id: 'loading_indicator.label', defaultMessage: 'Loading...' },
missing: { id: 'missing_indicator.sublabel', defaultMessage: 'This resource could not be found.' },
});
export default @injectIntl
class ColumnIndicator extends PureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
type: PropTypes.oneOf([
'loading',
'missing',
'error',
]),
message: PropTypes.oneOfType([
PropTypes.string,
PropTypes.object,
]),
};
render() {
const { type, message, intl } = this.props;
const title = type !== 'error' ? intl.formatMessage(messages[type]) : message;
return (
<Column>
<div className={`column-indicator column-indicator--${type}`}>
<div className='column-indicator__figure' />
<span className='column-indicator__title'>{title}</span>
</div>
</Column>
);
}
};

View File

@@ -1,41 +1 @@
import { defineMessages, injectIntl } from 'react-intl';
import Column from '../column';
import './index.scss';
const messages = defineMessages({
loading: { id: 'loading_indicator.label', defaultMessage: 'Loading...' },
missing: { id: 'missing_indicator.sublabel', defaultMessage: 'This resource could not be found.' },
});
export default @injectIntl
class ColumnIndicator extends PureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
type: PropTypes.oneOf([
'loading',
'missing',
'error',
]),
message: PropTypes.oneOfType([
PropTypes.string,
PropTypes.object,
]),
};
render() {
const { type, message, intl } = this.props;
const title = type !== 'error' ? intl.formatMessage(messages[type]) : message;
return (
<Column>
<div className={`column-indicator column-indicator--${type}`}>
<div className='column-indicator__figure' />
<span className='column-indicator__title'>{title}</span>
</div>
</Column>
);
}
};
export { default } from './column_indicator';

View File

@@ -0,0 +1,22 @@
import { Link } from 'react-router-dom';
import Icon from '../../components/icon';
export default class ColumnLink extends PureComponent {
static propTypes = {
icon: PropTypes.string.isRequired,
text: PropTypes.string.isRequired,
to: PropTypes.string,
};
render() {
const { to, icon, text } = this.props;
return (
<Link to={to} className='column-link'>
<Icon id={icon} fixedWidth className='column-link__icon' />
{text}
</Link>
);
}
};

View File

@@ -0,0 +1,39 @@
.column-link {
background: lighten($ui-base-color, 8%);
color: $primary-text-color;
display: block;
font-size: 16px;
padding: 15px;
text-decoration: none;
&:hover,
&:focus,
&:active {
background: lighten($ui-base-color, 11%);
}
&:focus {
outline: 0;
}
&--transparent {
background: transparent;
color: $ui-secondary-color;
&:hover,
&:focus,
&:active {
background: transparent;
color: $primary-text-color;
}
&.active {
color: $ui-highlight-color;
}
}
&__icon {
display: inline-block;
margin-right: 5px;
}
}

View File

@@ -0,0 +1 @@
export { default } from './column_link';

View File

@@ -0,0 +1,17 @@
import './column_subheading.scss';
export default class ColumnSubheading extends PureComponent {
static propTypes = {
text: PropTypes.string.isRequired,
};
render() {
const { text } = this.props;
return (
<div className='column-subheading'>
{text}
</div>
);
}
};

View File

@@ -0,0 +1,9 @@
.column-subheading {
background: $ui-base-color;
color: $dark-text-color;
padding: 8px 20px;
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
cursor: default;
}

View File

@@ -0,0 +1 @@
export { default } from './column_subheading';

View File

@@ -0,0 +1,42 @@
import './columns_area.scss';
export default class ColumnsArea extends PureComponent {
static propTypes = {
children: PropTypes.node,
layout: PropTypes.object,
};
render () {
const { children } = this.props;
const layout = this.props.layout || {LEFT:null,RIGHT:null};
return (
<div className='page'>
<div className='page__columns'>
<div className='columns-area__panels'>
<div className='columns-area__panels__pane columns-area__panels__pane--left'>
<div className='columns-area__panels__pane__inner'>
{layout.LEFT}
</div>
</div>
<div className='columns-area__panels__main'>
<div className='columns-area columns-area--mobile'>
{children}
</div>
</div>
<div className='columns-area__panels__pane columns-area__panels__pane--right'>
<div className='columns-area__panels__pane__inner'>
{layout.RIGHT}
</div>
</div>
</div>
</div>
</div>
)
}
}

View File

@@ -0,0 +1,187 @@
.page {
display: flex;
flex-direction: column;
width: 100%;
&__top {
display: flex;
z-index: 105;
@include size(100%, auto);
@media (min-width:895px) {
top: -290px;
position: sticky;
}
}
&__columns {
display: flex;
flex-direction: column;
@include size(100%);
}
}
.columns-area {
display: flex;
flex: 1 1 auto;
flex-direction: row;
justify-content: flex-start;
position: relative;
&__panels {
display: flex;
justify-content: center;
@include size(100%);
&__pane {
height: 100%;
pointer-events: none;
display: flex;
justify-content: flex-end;
padding-top: 15px;
&--start {
justify-content: flex-start;
}
&__inner {
pointer-events: auto;
@include size(265px, 100%);
}
}
&__main {
display: flex;
flex-direction: column;
box-sizing: border-box;
width: 100%;
max-width: 600px;
padding: 0 20px;
@media screen and (max-width: 375px) {
padding: 0 10px;
}
@media screen and (min-width: 895px) {
margin: 0 20px;
padding: 0;
}
}
}
}
@media screen and (min-width: 631px) {
.columns-area {
padding: 0;
}
.columns-area>div {
.column,
.drawer {
padding-left: 5px;
padding-right: 5px;
}
}
}
.columns-area--mobile {
flex-direction: column;
width: 100%;
@include margin-center;
.column,
.drawer {
padding: 0;
@include size(100%);
}
.search__input {
line-height: 18px;
font-size: 16px;
padding: 15px 30px 15px 15px;
}
.search__icon .fa {
top: 15px;
}
@media screen and (min-width: 360px) {
padding: 15px 0;
}
@media screen and (min-width: 630px) {
.detailed-status {
padding: 15px;
.media-gallery,
.video-player {
margin-top: 15px;
}
}
.account__header__bar {
padding: 5px 10px;
}
.navigation-bar,
.compose-form {
padding: 15px;
}
.compose-form .compose-form__publish .compose-form__publish-button-wrapper {
padding-top: 15px;
}
.account {
padding: 15px 10px;
}
.notification {
&__message {
margin-left: 48px + 15px * 2;
padding-top: 15px;
}
&__favourite-icon-wrapper {
left: -32px;
}
.status {
padding-top: 8px;
}
.account {
padding-top: 8px;
}
.account__avatar-wrapper {
margin-left: 17px;
margin-right: 15px;
}
}
}
}
@media screen and (max-width: 895px) {
.columns-area__panels__pane--left {
display: none;
}
}
@media screen and (max-width: 1180px) {
.columns-area__panels__pane--right {
display: none;
}
}
.columns-area--mobile .column {
// @include gab-container-standards();
}

View File

@@ -0,0 +1 @@
export { default } from './columns_area';

View File

@@ -0,0 +1,27 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import VerifiedIcon from '../verified_icon/verified_icon';
import './display_name.scss';
export default class DisplayName extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
};
render () {
const { account } = this.props;
return (
<span className='display-name'>
<bdi>
<strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} />
</bdi>
{account.get('is_verified') && <VerifiedIcon />}
<span className='display-name__account'>@{account.get('acct')}</span>
</span>
);
}
}

View File

@@ -1,27 +1 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import VerifiedIcon from '../verified_icon';
import './index.scss';
export default class DisplayName extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
};
render () {
const { account } = this.props;
return (
<span className='display-name'>
<bdi>
<strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} />
</bdi>
{account.get('is_verified') && <VerifiedIcon />}
<span className='display-name__account'>@{account.get('acct')}</span>
</span>
);
}
}
export { default } from './display_name';

View File

@@ -0,0 +1,48 @@
import IconButton from '../icon_button';
import { defineMessages, injectIntl } from 'react-intl';
import './domain.scss';
const messages = defineMessages({
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' },
});
export default @injectIntl
class Domain extends PureComponent {
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__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>
);
}
}

View File

@@ -1,48 +1 @@
import IconButton from '../icon_button';
import { defineMessages, injectIntl } from 'react-intl';
import './index.scss';
const messages = defineMessages({
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' },
});
export default @injectIntl
class Domain extends PureComponent {
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__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>
);
}
}
export { default } from './domain';

View File

@@ -0,0 +1,272 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
import detectPassiveEvents from 'detect-passive-events';
import Overlay from 'react-overlays/lib/Overlay';
import spring from 'react-motion/lib/spring';
import IconButton from '../icon_button';
import Motion from '../../features/ui/util/optional_motion';
import './dropdown_menu.scss';
const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
let id = 0;
class DropdownMenu extends 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();
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;
switch(e.key) {
case 'ArrowDown':
element = items[index+1];
if (element) element.focus();
break;
case 'ArrowUp':
element = items[index-1];
if (element) element.focus();
break;
case 'Home':
element = items[0];
if (element) element.focus();
break;
case 'End':
element = items[items.length-1];
if (element) element.focus();
break;
}
}
handleItemKeyDown = e => {
if (e.key === 'Enter') {
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(e);
} 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 = '#', newTab, isLogout } = option;
return (
<li className='dropdown-menu__item' key={`${text}-${i}`}>
<a
href={href}
role='button'
tabIndex='0'
ref={i === 0 ? this.setFocusRef : null}
onClick={this.handleClick}
onKeyDown={this.handleItemKeyDown}
data-index={i}
target={newTab ? '_blank' : null}
data-method={isLogout ? 'delete' : null}
>
{text}
</a>
</li>
);
}
render () {
const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = this.props;
const { mounted } = this.state;
return (
<Motion defaultStyle={{ opacity: 0, scaleX: 1, scaleY: 1 }} 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 ${placement}`} 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 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 = () => {
this.props.onClose(this.state.id);
}
handleKeyDown = e => {
switch(e.key) {
case ' ':
case 'Enter':
this.handleClick(e);
e.preventDefault();
break;
case 'Escape':
this.handleClose();
break;
}
}
handleItemClick = e => {
const i = Number(e.currentTarget.getAttribute('data-index'));
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 onKeyDown={this.handleKeyDown}>
<IconButton
icon={icon}
title={title}
active={open}
disabled={disabled}
size={size}
ref={this.setTargetRef}
onClick={this.handleClick}
/>
<Overlay show={open} placement={dropdownPlacement} target={this.findTarget}>
<DropdownMenu items={items} onClose={this.handleClose} openedViaKeyboard={openedViaKeyboard} />
</Overlay>
</div>
);
}
}

View File

@@ -1,272 +1 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
import detectPassiveEvents from 'detect-passive-events';
import Overlay from 'react-overlays/lib/Overlay';
import spring from 'react-motion/lib/spring';
import IconButton from '../icon_button';
import Motion from '../../features/ui/util/optional_motion';
import './index.scss';
const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
let id = 0;
class DropdownMenu extends 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();
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;
switch(e.key) {
case 'ArrowDown':
element = items[index+1];
if (element) element.focus();
break;
case 'ArrowUp':
element = items[index-1];
if (element) element.focus();
break;
case 'Home':
element = items[0];
if (element) element.focus();
break;
case 'End':
element = items[items.length-1];
if (element) element.focus();
break;
}
}
handleItemKeyDown = e => {
if (e.key === 'Enter') {
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(e);
} 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 = '#', newTab, isLogout } = option;
return (
<li className='dropdown-menu__item' key={`${text}-${i}`}>
<a
href={href}
role='button'
tabIndex='0'
ref={i === 0 ? this.setFocusRef : null}
onClick={this.handleClick}
onKeyDown={this.handleItemKeyDown}
data-index={i}
target={newTab ? '_blank' : null}
data-method={isLogout ? 'delete' : null}
>
{text}
</a>
</li>
);
}
render () {
const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = this.props;
const { mounted } = this.state;
return (
<Motion defaultStyle={{ opacity: 0, scaleX: 1, scaleY: 1 }} 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 ${placement}`} 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 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 = () => {
this.props.onClose(this.state.id);
}
handleKeyDown = e => {
switch(e.key) {
case ' ':
case 'Enter':
this.handleClick(e);
e.preventDefault();
break;
case 'Escape':
this.handleClose();
break;
}
}
handleItemClick = e => {
const i = Number(e.currentTarget.getAttribute('data-index'));
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 onKeyDown={this.handleKeyDown}>
<IconButton
icon={icon}
title={title}
active={open}
disabled={disabled}
size={size}
ref={this.setTargetRef}
onClick={this.handleClick}
/>
<Overlay show={open} placement={dropdownPlacement} target={this.findTarget}>
<DropdownMenu items={items} onClose={this.handleClose} openedViaKeyboard={openedViaKeyboard} />
</Overlay>
</div>
);
}
}
export { default } from './dropdown_menu';

View File

@@ -0,0 +1,82 @@
import emojify from '../emoji';
describe('emoji', () => {
describe('.emojify', () => {
it('ignores unknown shortcodes', () => {
expect(emojify(':foobarbazfake:')).toEqual(':foobarbazfake:');
});
it('ignores shortcodes inside of tags', () => {
expect(emojify('<p data-foo=":smile:"></p>')).toEqual('<p data-foo=":smile:"></p>');
});
it('works with unclosed tags', () => {
expect(emojify('hello>')).toEqual('hello>');
expect(emojify('<hello')).toEqual('<hello');
});
it('works with unclosed shortcodes', () => {
expect(emojify('smile:')).toEqual('smile:');
expect(emojify(':smile')).toEqual(':smile');
});
it('does unicode', () => {
expect(emojify('\uD83D\uDC69\u200D\uD83D\uDC69\u200D\uD83D\uDC66\u200D\uD83D\uDC66')).toEqual(
'<img draggable="false" class="emojione" alt="👩‍👩‍👦‍👦" title=":woman-woman-boy-boy:" src="/emoji/1f469-200d-1f469-200d-1f466-200d-1f466.svg" />');
expect(emojify('👨‍👩‍👧‍👧')).toEqual(
'<img draggable="false" class="emojione" alt="👨‍👩‍👧‍👧" title=":man-woman-girl-girl:" src="/emoji/1f468-200d-1f469-200d-1f467-200d-1f467.svg" />');
expect(emojify('👩‍👩‍👦')).toEqual('<img draggable="false" class="emojione" alt="👩‍👩‍👦" title=":woman-woman-boy:" src="/emoji/1f469-200d-1f469-200d-1f466.svg" />');
expect(emojify('\u2757')).toEqual(
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" />');
});
it('does multiple unicode', () => {
expect(emojify('\u2757 #\uFE0F\u20E3')).toEqual(
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" /> <img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg" />');
expect(emojify('\u2757#\uFE0F\u20E3')).toEqual(
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" /><img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg" />');
expect(emojify('\u2757 #\uFE0F\u20E3 \u2757')).toEqual(
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" /> <img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg" /> <img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" />');
expect(emojify('foo \u2757 #\uFE0F\u20E3 bar')).toEqual(
'foo <img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" /> <img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg" /> bar');
});
it('ignores unicode inside of tags', () => {
expect(emojify('<p data-foo="\uD83D\uDC69\uD83D\uDC69\uD83D\uDC66"></p>')).toEqual('<p data-foo="\uD83D\uDC69\uD83D\uDC69\uD83D\uDC66"></p>');
});
it('does multiple emoji properly (issue 5188)', () => {
expect(emojify('👌🌈💕')).toEqual('<img draggable="false" class="emojione" alt="👌" title=":ok_hand:" src="/emoji/1f44c.svg" /><img draggable="false" class="emojione" alt="🌈" title=":rainbow:" src="/emoji/1f308.svg" /><img draggable="false" class="emojione" alt="💕" title=":two_hearts:" src="/emoji/1f495.svg" />');
expect(emojify('👌 🌈 💕')).toEqual('<img draggable="false" class="emojione" alt="👌" title=":ok_hand:" src="/emoji/1f44c.svg" /> <img draggable="false" class="emojione" alt="🌈" title=":rainbow:" src="/emoji/1f308.svg" /> <img draggable="false" class="emojione" alt="💕" title=":two_hearts:" src="/emoji/1f495.svg" />');
});
it('does an emoji that has no shortcode', () => {
expect(emojify('👁‍🗨')).toEqual('<img draggable="false" class="emojione" alt="👁‍🗨" title="" src="/emoji/1f441-200d-1f5e8.svg" />');
});
it('does an emoji whose filename is irregular', () => {
expect(emojify('↙️')).toEqual('<img draggable="false" class="emojione" alt="↙️" title=":arrow_lower_left:" src="/emoji/2199.svg" />');
});
it('avoid emojifying on invisible text', () => {
expect(emojify('<a href="http://example.com/test%F0%9F%98%84"><span class="invisible">http://</span><span class="ellipsis">example.com/te</span><span class="invisible">st😄</span></a>'))
.toEqual('<a href="http://example.com/test%F0%9F%98%84"><span class="invisible">http://</span><span class="ellipsis">example.com/te</span><span class="invisible">st😄</span></a>');
expect(emojify('<span class="invisible">:luigi:</span>', { ':luigi:': { static_url: 'luigi.exe' } }))
.toEqual('<span class="invisible">:luigi:</span>');
});
it('avoid emojifying on invisible text with nested tags', () => {
expect(emojify('<span class="invisible">😄<span class="foo">bar</span>😴</span>😇'))
.toEqual('<span class="invisible">😄<span class="foo">bar</span>😴</span><img draggable="false" class="emojione" alt="😇" title=":innocent:" src="/emoji/1f607.svg" />');
expect(emojify('<span class="invisible">😄<span class="invisible">😕</span>😴</span>😇'))
.toEqual('<span class="invisible">😄<span class="invisible">😕</span>😴</span><img draggable="false" class="emojione" alt="😇" title=":innocent:" src="/emoji/1f607.svg" />');
expect(emojify('<span class="invisible">😄<br/>😴</span>😇'))
.toEqual('<span class="invisible">😄<br/>😴</span><img draggable="false" class="emojione" alt="😇" title=":innocent:" src="/emoji/1f607.svg" />');
});
it('skips the textual presentation VS15 character', () => {
expect(emojify('✴︎')) // This is U+2734 EIGHT POINTED BLACK STAR then U+FE0E VARIATION SELECTOR-15
.toEqual('<img draggable="false" class="emojione" alt="✴" title=":eight_pointed_black_star:" src="/emoji/2734.svg" />');
});
});
});

View File

@@ -0,0 +1,176 @@
import { pick } from 'lodash';
import { emojiIndex } from 'emoji-mart';
import { search } from '../emoji_mart_search_light';
const trimEmojis = emoji => pick(emoji, ['id', 'unified', 'native', 'custom']);
describe('emoji_index', () => {
it('should give same result for emoji_index_light and emoji-mart', () => {
const expected = [
{
id: 'pineapple',
unified: '1f34d',
native: '🍍',
},
];
expect(search('pineapple').map(trimEmojis)).toEqual(expected);
expect(emojiIndex.search('pineapple').map(trimEmojis)).toEqual(expected);
});
it('orders search results correctly', () => {
const expected = [
{
id: 'apple',
unified: '1f34e',
native: '🍎',
},
{
id: 'pineapple',
unified: '1f34d',
native: '🍍',
},
{
id: 'green_apple',
unified: '1f34f',
native: '🍏',
},
{
id: 'iphone',
unified: '1f4f1',
native: '📱',
},
];
expect(search('apple').map(trimEmojis)).toEqual(expected);
expect(emojiIndex.search('apple').map(trimEmojis)).toEqual(expected);
});
it('can include/exclude categories', () => {
expect(search('flag', { include: ['people'] })).toEqual([]);
expect(emojiIndex.search('flag', { include: ['people'] })).toEqual([]);
});
it('(different behavior from emoji-mart) do not erases custom emoji if not passed again', () => {
const custom = [
{
id: 'gabsocial',
name: 'gabsocial',
short_names: ['gabsocial'],
text: '',
emoticons: [],
keywords: ['gabsocial'],
imageUrl: 'http://example.com',
custom: true,
},
];
search('', { custom });
emojiIndex.search('', { custom });
const expected = [];
const lightExpected = [
{
id: 'gabsocial',
custom: true,
},
];
expect(search('masto').map(trimEmojis)).toEqual(lightExpected);
expect(emojiIndex.search('masto').map(trimEmojis)).toEqual(expected);
});
it('(different behavior from emoji-mart) erases custom emoji if another is passed', () => {
const custom = [
{
id: 'gabsocial',
name: 'gabsocial',
short_names: ['gabsocial'],
text: '',
emoticons: [],
keywords: ['gabsocial'],
imageUrl: 'http://example.com',
custom: true,
},
];
search('', { custom });
emojiIndex.search('', { custom });
const expected = [];
expect(search('masto', { custom: [] }).map(trimEmojis)).toEqual(expected);
expect(emojiIndex.search('masto').map(trimEmojis)).toEqual(expected);
});
it('handles custom emoji', () => {
const custom = [
{
id: 'gabsocial',
name: 'gabsocial',
short_names: ['gabsocial'],
text: '',
emoticons: [],
keywords: ['gabsocial'],
imageUrl: 'http://example.com',
custom: true,
},
];
search('', { custom });
emojiIndex.search('', { custom });
const expected = [
{
id: 'gabsocial',
custom: true,
},
];
expect(search('masto', { custom }).map(trimEmojis)).toEqual(expected);
expect(emojiIndex.search('masto', { custom }).map(trimEmojis)).toEqual(expected);
});
it('should filter only emojis we care about, exclude pineapple', () => {
const emojisToShowFilter = emoji => emoji.unified !== '1F34D';
expect(search('apple', { emojisToShowFilter }).map((obj) => obj.id))
.not.toContain('pineapple');
expect(emojiIndex.search('apple', { emojisToShowFilter }).map((obj) => obj.id))
.not.toContain('pineapple');
});
it('does an emoji whose unified name is irregular', () => {
const expected = [
{
'id': 'water_polo',
'unified': '1f93d',
'native': '🤽',
},
{
'id': 'man-playing-water-polo',
'unified': '1f93d-200d-2642-fe0f',
'native': '🤽‍♂️',
},
{
'id': 'woman-playing-water-polo',
'unified': '1f93d-200d-2640-fe0f',
'native': '🤽‍♀️',
},
];
expect(search('polo').map(trimEmojis)).toEqual(expected);
expect(emojiIndex.search('polo').map(trimEmojis)).toEqual(expected);
});
it('can search for thinking_face', () => {
const expected = [
{
id: 'thinking_face',
unified: '1f914',
native: '🤔',
},
];
expect(search('thinking_fac').map(trimEmojis)).toEqual(expected);
expect(emojiIndex.search('thinking_fac').map(trimEmojis)).toEqual(expected);
});
it('can search for woman-facepalming', () => {
const expected = [
{
id: 'woman-facepalming',
unified: '1f926-200d-2640-fe0f',
native: '🤦‍♀️',
},
];
expect(search('woman-facep').map(trimEmojis)).toEqual(expected);
expect(emojiIndex.search('woman-facep').map(trimEmojis)).toEqual(expected);
});
});

View File

@@ -0,0 +1,99 @@
import { autoPlayGif } from '../../initial_state';
import unicodeMapping from './emoji_unicode_mapping_light';
import Trie from 'substring-trie';
const trie = new Trie(Object.keys(unicodeMapping));
const assetHost = process.env.CDN_HOST || '';
const emojify = (str, customEmojis = {}) => {
const tagCharsWithoutEmojis = '<&';
const tagCharsWithEmojis = Object.keys(customEmojis).length ? '<&:' : '<&';
let rtn = '', tagChars = tagCharsWithEmojis, invisible = 0;
for (;;) {
let match, i = 0, tag;
while (i < str.length && (tag = tagChars.indexOf(str[i])) === -1 && (invisible || !(match = trie.search(str.slice(i))))) {
i += str.codePointAt(i) < 65536 ? 1 : 2;
}
let rend, replacement = '';
if (i === str.length) {
break;
} else if (str[i] === ':') {
if (!(() => {
rend = str.indexOf(':', i + 1) + 1;
if (!rend) return false; // no pair of ':'
const lt = str.indexOf('<', i + 1);
if (!(lt === -1 || lt >= rend)) return false; // tag appeared before closing ':'
const shortname = str.slice(i, rend);
// now got a replacee as ':shortname:'
// if you want additional emoji handler, add statements below which set replacement and return true.
if (shortname in customEmojis) {
const filename = autoPlayGif ? customEmojis[shortname].url : customEmojis[shortname].static_url;
replacement = `<img draggable="false" class="emojione" alt="${shortname}" title="${shortname}" src="${filename}" />`;
return true;
}
return false;
})()) rend = ++i;
} else if (tag >= 0) { // <, &
rend = str.indexOf('>;'[tag], i + 1) + 1;
if (!rend) {
break;
}
if (tag === 0) {
if (invisible) {
if (str[i + 1] === '/') { // closing tag
if (!--invisible) {
tagChars = tagCharsWithEmojis;
}
} else if (str[rend - 2] !== '/') { // opening tag
invisible++;
}
} else {
if (str.startsWith('<span class="invisible">', i)) {
// avoid emojifying on invisible text
invisible = 1;
tagChars = tagCharsWithoutEmojis;
}
}
}
i = rend;
} else { // matched to unicode emoji
const { filename, shortCode } = unicodeMapping[match];
const title = shortCode ? `:${shortCode}:` : '';
replacement = `<img draggable="false" class="emojione" alt="${match}" title="${title}" src="${assetHost}/emoji/${filename}.svg" />`;
rend = i + match.length;
// If the matched character was followed by VS15 (for selecting text presentation), skip it.
if (str.codePointAt(rend) === 65038) {
rend += 1;
}
}
rtn += str.slice(0, i) + replacement;
str = str.slice(rend);
}
return rtn + str;
};
export default emojify;
export const buildCustomEmojis = (customEmojis) => {
const emojis = [];
customEmojis.forEach(emoji => {
const shortcode = emoji.get('shortcode');
const url = autoPlayGif ? emoji.get('url') : emoji.get('static_url');
const name = shortcode.replace(':', '');
emojis.push({
id: name,
name,
short_names: [name],
text: '',
emoticons: [],
keywords: [name],
imageUrl: url,
custom: true,
});
});
return emojis;
};

View File

@@ -0,0 +1,99 @@
// @preval
// http://www.unicode.org/Public/emoji/5.0/emoji-test.txt
// This file contains the compressed version of the emoji data from
// both emoji_map.json and from emoji-mart's emojiIndex and data objects.
// It's designed to be emitted in an array format to take up less space
// over the wire.
const { unicodeToFilename } = require('./unicode_to_filename');
const { unicodeToUnifiedName } = require('./unicode_to_unified_name');
const emojiMap = require('./emoji_map.json');
const { emojiIndex } = require('emoji-mart');
const { uncompress: emojiMartUncompress } = require('emoji-mart/dist/utils/data');
let data = require('emoji-mart/data/all.json');
if(data.compressed) {
data = emojiMartUncompress(data);
}
const emojiMartData = data;
const excluded = ['®', '©', '™'];
const skins = ['🏻', '🏼', '🏽', '🏾', '🏿'];
const shortcodeMap = {};
const shortCodesToEmojiData = {};
const emojisWithoutShortCodes = [];
Object.keys(emojiIndex.emojis).forEach(key => {
shortcodeMap[emojiIndex.emojis[key].native] = emojiIndex.emojis[key].id;
});
const stripModifiers = unicode => {
skins.forEach(tone => {
unicode = unicode.replace(tone, '');
});
return unicode;
};
Object.keys(emojiMap).forEach(key => {
if (excluded.includes(key)) {
delete emojiMap[key];
return;
}
const normalizedKey = stripModifiers(key);
let shortcode = shortcodeMap[normalizedKey];
if (!shortcode) {
shortcode = shortcodeMap[normalizedKey + '\uFE0F'];
}
const filename = emojiMap[key];
const filenameData = [key];
if (unicodeToFilename(key) !== filename) {
// filename can't be derived using unicodeToFilename
filenameData.push(filename);
}
if (typeof shortcode === 'undefined') {
emojisWithoutShortCodes.push(filenameData);
} else {
if (!Array.isArray(shortCodesToEmojiData[shortcode])) {
shortCodesToEmojiData[shortcode] = [[]];
}
shortCodesToEmojiData[shortcode][0].push(filenameData);
}
});
Object.keys(emojiIndex.emojis).forEach(key => {
const { native } = emojiIndex.emojis[key];
let { short_names, search, unified } = emojiMartData.emojis[key];
if (short_names[0] !== key) {
throw new Error('The compresser expects the first short_code to be the ' +
'key. It may need to be rewritten if the emoji change such that this ' +
'is no longer the case.');
}
short_names = short_names.slice(1); // first short name can be inferred from the key
const searchData = [native, short_names, search];
if (unicodeToUnifiedName(native) !== unified) {
// unified name can't be derived from unicodeToUnifiedName
searchData.push(unified);
}
shortCodesToEmojiData[key].push(searchData);
});
// JSON.parse/stringify is to emulate what @preval is doing and avoid any
// inconsistent behavior in dev mode
module.exports = JSON.parse(JSON.stringify([
shortCodesToEmojiData,
emojiMartData.skins,
emojiMartData.categories,
emojiMartData.aliases,
emojisWithoutShortCodes,
]));

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,41 @@
// The output of this module is designed to mimic emoji-mart's
// "data" object, such that we can use it for a light version of emoji-mart's
// emojiIndex.search functionality.
const { unicodeToUnifiedName } = require('./unicode_to_unified_name');
const [ shortCodesToEmojiData, skins, categories, short_names ] = require('./emoji_compressed');
const emojis = {};
// decompress
Object.keys(shortCodesToEmojiData).forEach((shortCode) => {
let [
filenameData, // eslint-disable-line no-unused-vars
searchData,
] = shortCodesToEmojiData[shortCode];
let [
native,
short_names,
search,
unified,
] = searchData;
if (!unified) {
// unified name can be derived from unicodeToUnifiedName
unified = unicodeToUnifiedName(native);
}
short_names = [shortCode].concat(short_names);
emojis[shortCode] = {
native,
search,
short_names,
unified,
};
});
module.exports = {
emojis,
skins,
categories,
short_names,
};

View File

@@ -0,0 +1,185 @@
// This code is largely borrowed from:
// https://github.com/missive/emoji-mart/blob/5f2ffcc/src/utils/emoji-index.js
import data from './emoji_mart_data_light';
import { getData, getSanitizedData, uniq, intersect } from './emoji_utils';
let originalPool = {};
let index = {};
let emojisList = {};
let emoticonsList = {};
let customEmojisList = [];
for (let emoji in data.emojis) {
let emojiData = data.emojis[emoji];
let { short_names, emoticons } = emojiData;
let id = short_names[0];
if (emoticons) {
emoticons.forEach(emoticon => {
if (emoticonsList[emoticon]) {
return;
}
emoticonsList[emoticon] = id;
});
}
emojisList[id] = getSanitizedData(id);
originalPool[id] = emojiData;
}
function clearCustomEmojis(pool) {
customEmojisList.forEach((emoji) => {
let emojiId = emoji.id || emoji.short_names[0];
delete pool[emojiId];
delete emojisList[emojiId];
});
}
function addCustomToPool(custom, pool) {
if (customEmojisList.length) clearCustomEmojis(pool);
custom.forEach((emoji) => {
let emojiId = emoji.id || emoji.short_names[0];
if (emojiId && !pool[emojiId]) {
pool[emojiId] = getData(emoji);
emojisList[emojiId] = getSanitizedData(emoji);
}
});
customEmojisList = custom;
index = {};
}
function search(value, { emojisToShowFilter, maxResults, include, exclude, custom } = {}) {
if (custom !== undefined) {
if (customEmojisList !== custom)
addCustomToPool(custom, originalPool);
} else {
custom = [];
}
maxResults = maxResults || 75;
include = include || [];
exclude = exclude || [];
let results = null,
pool = originalPool;
if (value.length) {
if (value === '-' || value === '-1') {
return [emojisList['-1']];
}
let values = value.toLowerCase().split(/[\s|,|\-|_]+/),
allResults = [];
if (values.length > 2) {
values = [values[0], values[1]];
}
if (include.length || exclude.length) {
pool = {};
data.categories.forEach(category => {
let isIncluded = include && include.length ? include.indexOf(category.name.toLowerCase()) > -1 : true;
let isExcluded = exclude && exclude.length ? exclude.indexOf(category.name.toLowerCase()) > -1 : false;
if (!isIncluded || isExcluded) {
return;
}
category.emojis.forEach(emojiId => pool[emojiId] = data.emojis[emojiId]);
});
if (custom.length) {
let customIsIncluded = include && include.length ? include.indexOf('custom') > -1 : true;
let customIsExcluded = exclude && exclude.length ? exclude.indexOf('custom') > -1 : false;
if (customIsIncluded && !customIsExcluded) {
addCustomToPool(custom, pool);
}
}
}
const searchValue = (value) => {
let aPool = pool,
aIndex = index,
length = 0;
for (let charIndex = 0; charIndex < value.length; charIndex++) {
const char = value[charIndex];
length++;
aIndex[char] = aIndex[char] || {};
aIndex = aIndex[char];
if (!aIndex.results) {
let scores = {};
aIndex.results = [];
aIndex.pool = {};
for (let id in aPool) {
let emoji = aPool[id],
{ search } = emoji,
sub = value.substr(0, length),
subIndex = search.indexOf(sub);
if (subIndex !== -1) {
let score = subIndex + 1;
if (sub === id) score = 0;
aIndex.results.push(emojisList[id]);
aIndex.pool[id] = emoji;
scores[id] = score;
}
}
aIndex.results.sort((a, b) => {
let aScore = scores[a.id],
bScore = scores[b.id];
return aScore - bScore;
});
}
aPool = aIndex.pool;
}
return aIndex.results;
};
if (values.length > 1) {
results = searchValue(value);
} else {
results = [];
}
allResults = values.map(searchValue).filter(a => a);
if (allResults.length > 1) {
allResults = intersect.apply(null, allResults);
} else if (allResults.length) {
allResults = allResults[0];
}
results = uniq(results.concat(allResults));
}
if (results) {
if (emojisToShowFilter) {
results = results.filter((result) => emojisToShowFilter(data.emojis[result.id]));
}
if (results && results.length > maxResults) {
results = results.slice(0, maxResults);
}
}
return results;
}
export { search };

View File

@@ -0,0 +1,7 @@
import Picker from 'emoji-mart/dist-es/components/picker/picker';
import Emoji from 'emoji-mart/dist-es/components/emoji/emoji';
export {
Picker,
Emoji,
};

View File

@@ -0,0 +1,35 @@
// A mapping of unicode strings to an object containing the filename
// (i.e. the svg filename) and a shortCode intended to be shown
// as a "title" attribute in an HTML element (aka tooltip).
const [
shortCodesToEmojiData,
skins, // eslint-disable-line no-unused-vars
categories, // eslint-disable-line no-unused-vars
short_names, // eslint-disable-line no-unused-vars
emojisWithoutShortCodes,
] = require('./emoji_compressed');
const { unicodeToFilename } = require('./unicode_to_filename');
// decompress
const unicodeMapping = {};
function processEmojiMapData(emojiMapData, shortCode) {
let [ native, filename ] = emojiMapData;
if (!filename) {
// filename name can be derived from unicodeToFilename
filename = unicodeToFilename(native);
}
unicodeMapping[native] = {
shortCode: shortCode,
filename: filename,
};
}
Object.keys(shortCodesToEmojiData).forEach((shortCode) => {
let [ filenameData ] = shortCodesToEmojiData[shortCode];
filenameData.forEach(emojiMapData => processEmojiMapData(emojiMapData, shortCode));
});
emojisWithoutShortCodes.forEach(emojiMapData => processEmojiMapData(emojiMapData));
module.exports = unicodeMapping;

View File

@@ -0,0 +1,258 @@
// This code is largely borrowed from:
// https://github.com/missive/emoji-mart/blob/5f2ffcc/src/utils/index.js
import data from './emoji_mart_data_light';
const buildSearch = (data) => {
const search = [];
let addToSearch = (strings, split) => {
if (!strings) {
return;
}
(Array.isArray(strings) ? strings : [strings]).forEach((string) => {
(split ? string.split(/[-|_|\s]+/) : [string]).forEach((s) => {
s = s.toLowerCase();
if (search.indexOf(s) === -1) {
search.push(s);
}
});
});
};
addToSearch(data.short_names, true);
addToSearch(data.name, true);
addToSearch(data.keywords, false);
addToSearch(data.emoticons, false);
return search.join(',');
};
const _String = String;
const stringFromCodePoint = _String.fromCodePoint || function () {
let MAX_SIZE = 0x4000;
let codeUnits = [];
let highSurrogate;
let lowSurrogate;
let index = -1;
let length = arguments.length;
if (!length) {
return '';
}
let result = '';
while (++index < length) {
let codePoint = Number(arguments[index]);
if (
!isFinite(codePoint) || // `NaN`, `+Infinity`, or `-Infinity`
codePoint < 0 || // not a valid Unicode code point
codePoint > 0x10FFFF || // not a valid Unicode code point
Math.floor(codePoint) !== codePoint // not an integer
) {
throw RangeError('Invalid code point: ' + codePoint);
}
if (codePoint <= 0xFFFF) { // BMP code point
codeUnits.push(codePoint);
} else { // Astral code point; split in surrogate halves
// http://mathiasbynens.be/notes/javascript-encoding#surrogate-formulae
codePoint -= 0x10000;
highSurrogate = (codePoint >> 10) + 0xD800;
lowSurrogate = (codePoint % 0x400) + 0xDC00;
codeUnits.push(highSurrogate, lowSurrogate);
}
if (index + 1 === length || codeUnits.length > MAX_SIZE) {
result += String.fromCharCode.apply(null, codeUnits);
codeUnits.length = 0;
}
}
return result;
};
const _JSON = JSON;
const COLONS_REGEX = /^(?:\:([^\:]+)\:)(?:\:skin-tone-(\d)\:)?$/;
const SKINS = [
'1F3FA', '1F3FB', '1F3FC',
'1F3FD', '1F3FE', '1F3FF',
];
function unifiedToNative(unified) {
let unicodes = unified.split('-'),
codePoints = unicodes.map((u) => `0x${u}`);
return stringFromCodePoint.apply(null, codePoints);
}
function sanitize(emoji) {
let { name, short_names, skin_tone, skin_variations, emoticons, unified, custom, imageUrl } = emoji,
id = emoji.id || short_names[0],
colons = `:${id}:`;
if (custom) {
return {
id,
name,
colons,
emoticons,
custom,
imageUrl,
};
}
if (skin_tone) {
colons += `:skin-tone-${skin_tone}:`;
}
return {
id,
name,
colons,
emoticons,
unified: unified.toLowerCase(),
skin: skin_tone || (skin_variations ? 1 : null),
native: unifiedToNative(unified),
};
}
function getSanitizedData() {
return sanitize(getData(...arguments));
}
function getData(emoji, skin, set) {
let emojiData = {};
if (typeof emoji === 'string') {
let matches = emoji.match(COLONS_REGEX);
if (matches) {
emoji = matches[1];
if (matches[2]) {
skin = parseInt(matches[2]);
}
}
if (data.short_names.hasOwnProperty(emoji)) {
emoji = data.short_names[emoji];
}
if (data.emojis.hasOwnProperty(emoji)) {
emojiData = data.emojis[emoji];
}
} else if (emoji.id) {
if (data.short_names.hasOwnProperty(emoji.id)) {
emoji.id = data.short_names[emoji.id];
}
if (data.emojis.hasOwnProperty(emoji.id)) {
emojiData = data.emojis[emoji.id];
skin = skin || emoji.skin;
}
}
if (!Object.keys(emojiData).length) {
emojiData = emoji;
emojiData.custom = true;
if (!emojiData.search) {
emojiData.search = buildSearch(emoji);
}
}
emojiData.emoticons = emojiData.emoticons || [];
emojiData.variations = emojiData.variations || [];
if (emojiData.skin_variations && skin > 1 && set) {
emojiData = JSON.parse(_JSON.stringify(emojiData));
let skinKey = SKINS[skin - 1],
variationData = emojiData.skin_variations[skinKey];
if (!variationData.variations && emojiData.variations) {
delete emojiData.variations;
}
if (variationData[`has_img_${set}`]) {
emojiData.skin_tone = skin;
for (let k in variationData) {
let v = variationData[k];
emojiData[k] = v;
}
}
}
if (emojiData.variations && emojiData.variations.length) {
emojiData = JSON.parse(_JSON.stringify(emojiData));
emojiData.unified = emojiData.variations.shift();
}
return emojiData;
}
function uniq(arr) {
return arr.reduce((acc, item) => {
if (acc.indexOf(item) === -1) {
acc.push(item);
}
return acc;
}, []);
}
function intersect(a, b) {
const uniqA = uniq(a);
const uniqB = uniq(b);
return uniqA.filter(item => uniqB.indexOf(item) >= 0);
}
function deepMerge(a, b) {
let o = {};
for (let key in a) {
let originalValue = a[key],
value = originalValue;
if (b.hasOwnProperty(key)) {
value = b[key];
}
if (typeof value === 'object') {
value = deepMerge(originalValue, value);
}
o[key] = value;
}
return o;
}
// https://github.com/sonicdoe/measure-scrollbar
function measureScrollbar() {
const div = document.createElement('div');
div.style.width = '100px';
div.style.height = '100px';
div.style.overflow = 'scroll';
div.style.position = 'absolute';
div.style.top = '-9999px';
document.body.appendChild(div);
const scrollbarWidth = div.offsetWidth - div.clientWidth;
document.body.removeChild(div);
return scrollbarWidth;
}
export {
getData,
getSanitizedData,
uniq,
intersect,
deepMerge,
unifiedToNative,
measureScrollbar,
};

View File

@@ -0,0 +1,26 @@
// taken from:
// https://github.com/twitter/twemoji/blob/47732c7/twemoji-generator.js#L848-L866
exports.unicodeToFilename = (str) => {
let result = '';
let charCode = 0;
let p = 0;
let i = 0;
while (i < str.length) {
charCode = str.charCodeAt(i++);
if (p) {
if (result.length > 0) {
result += '-';
}
result += (0x10000 + ((p - 0xD800) << 10) + (charCode - 0xDC00)).toString(16);
p = 0;
} else if (0xD800 <= charCode && charCode <= 0xDBFF) {
p = charCode;
} else {
if (result.length > 0) {
result += '-';
}
result += charCode.toString(16);
}
}
return result;
};

View File

@@ -0,0 +1,17 @@
function padLeft(str, num) {
while (str.length < num) {
str = '0' + str;
}
return str;
}
exports.unicodeToUnifiedName = (str) => {
let output = '';
for (let i = 0; i < str.length; i += 2) {
if (i > 0) {
output += '-';
}
output += padLeft(str.codePointAt(i).toString(16).toUpperCase(), 4);
}
return output;
};

View File

@@ -0,0 +1,40 @@
import { FormattedMessage } from 'react-intl';
import './error_boundary.scss';
export default class ErrorBoundary extends PureComponent {
static propTypes = {
children: PropTypes.node,
};
state = {
hasError: false,
stackTrace: undefined,
componentStack: undefined,
}
componentDidCatch(error, info) {
this.setState({
hasError: true,
stackTrace: error.stack,
componentStack: info && info.componentStack,
});
}
render() {
const { hasError } = this.state;
if (!hasError) return this.props.children;
return (
<div className='error-boundary'>
<div className='error-boundary__container'>
<FormattedMessage id='alert.unexpected.message' defaultMessage='Error' />
<a className='error-boundary__link' href='/home'>Return Home</a>
</div>
</div>
);
}
}

View File

@@ -1,6 +1,6 @@
.error-boundary {
&__container {
margin: auto;
@include margin-center(auto);
}
span {
@@ -11,8 +11,9 @@
&__link {
display: block;
margin: 15px auto;
text-align: center;
color: $gab-brand-default;
@include margin-center(15px);
}
}

View File

@@ -1,40 +1 @@
import { FormattedMessage } from 'react-intl';
import './index.scss';
export default class ErrorBoundary extends PureComponent {
static propTypes = {
children: PropTypes.node,
};
state = {
hasError: false,
stackTrace: undefined,
componentStack: undefined,
}
componentDidCatch(error, info) {
this.setState({
hasError: true,
stackTrace: error.stack,
componentStack: info && info.componentStack,
});
}
render() {
const { hasError } = this.state;
if (!hasError) return this.props.children;
return (
<div className='error-boundary'>
<div className='error-boundary__container'>
<FormattedMessage id='alert.unexpected.message' defaultMessage='Error' />
<a className='error-boundary__link' href='/home'>Return Home</a>
</div>
</div>
);
}
}
export { default } from './error_boundary';

View File

@@ -0,0 +1,62 @@
import './extended_video_player.scss';
export default class ExtendedVideoPlayer extends PureComponent {
static propTypes = {
src: PropTypes.string.isRequired,
alt: PropTypes.string,
width: PropTypes.number,
height: PropTypes.number,
time: PropTypes.number,
controls: PropTypes.bool.isRequired,
muted: PropTypes.bool.isRequired,
onClick: PropTypes.func,
};
handleLoadedData = () => {
if (this.props.time) {
this.video.currentTime = this.props.time;
}
}
componentDidMount () {
this.video.addEventListener('loadeddata', this.handleLoadedData);
}
componentWillUnmount () {
this.video.removeEventListener('loadeddata', this.handleLoadedData);
}
setRef = (c) => {
this.video = c;
}
handleClick = e => {
e.stopPropagation();
const handler = this.props.onClick;
if (handler) handler();
}
render () {
const { src, muted, controls, alt } = this.props;
return (
<div className='extended-video-player'>
<video
ref={this.setRef}
src={src}
autoPlay
role='button'
tabIndex='0'
aria-label={alt}
title={alt}
muted={muted}
controls={controls}
loop={!controls}
onClick={this.handleClick}
/>
</div>
);
}
}

View File

@@ -1,62 +1 @@
import './index.scss';
export default class ExtendedVideoPlayer extends PureComponent {
static propTypes = {
src: PropTypes.string.isRequired,
alt: PropTypes.string,
width: PropTypes.number,
height: PropTypes.number,
time: PropTypes.number,
controls: PropTypes.bool.isRequired,
muted: PropTypes.bool.isRequired,
onClick: PropTypes.func,
};
handleLoadedData = () => {
if (this.props.time) {
this.video.currentTime = this.props.time;
}
}
componentDidMount () {
this.video.addEventListener('loadeddata', this.handleLoadedData);
}
componentWillUnmount () {
this.video.removeEventListener('loadeddata', this.handleLoadedData);
}
setRef = (c) => {
this.video = c;
}
handleClick = e => {
e.stopPropagation();
const handler = this.props.onClick;
if (handler) handler();
}
render () {
const { src, muted, controls, alt } = this.props;
return (
<div className='extended-video-player'>
<video
ref={this.setRef}
src={src}
autoPlay
role='button'
tabIndex='0'
aria-label={alt}
title={alt}
muted={muted}
controls={controls}
loop={!controls}
onClick={this.handleClick}
/>
</div>
);
}
}
export { default } from './extended_video_player';

View File

@@ -0,0 +1,24 @@
import './floating_action_button.scss';
export default class FloatingActionButton extends Component {
static propTypes = {
onClick: PropTypes.func.isRequired,
message: PropTypes.string.isRequired,
};
shouldComponentUpdate(nextProps, nextState) {
if (nextProps.message !== this.props.message) {
return true;
}
return false;
}
render() {
const { onClick, message } = this.props;
return (
<button onClick={onClick} className='floating-action-button' aria-label={message} />
)
}
}

View File

@@ -0,0 +1,21 @@
.floating-action-button {
display: none;
position: fixed;
z-index: 1000;
border: none;
background-color: transparent;
@include size(61px, 52px);
@include abs-position(auto, 14px, 14px, auto, false);
@include background-image('/assets/images/sprite-main-navigation.png', 161px 152px, -100px 0);
@media screen and (max-width: $nav-breakpoint-3) {
display: flex;
}
&:hover,
&:focus,
&:active {
background-position: -100px -100px;
}
}

View File

@@ -0,0 +1 @@
export { default } from './floating_action_button';

View File

@@ -0,0 +1,23 @@
import classNames from 'classnames';
export default class Icon extends PureComponent {
static propTypes = {
id: PropTypes.string.isRequired,
className: PropTypes.string,
fixedWidth: PropTypes.bool,
};
render () {
const { id, className, fixedWidth, ...other } = this.props;
const classes = classNames('fa', `fa-${id}`, className, {
'fa-fw': fixedWidth,
});
return (
<i role='img' alt={id} className={classes} {...other} />
);
}
}

View File

@@ -1,23 +1 @@
import classNames from 'classnames';
export default class Icon extends PureComponent {
static propTypes = {
id: PropTypes.string.isRequired,
className: PropTypes.string,
fixedWidth: PropTypes.bool,
};
render () {
const { id, className, fixedWidth, ...other } = this.props;
const classes = classNames('fa', `fa-${id}`, className, {
'fa-fw': fixedWidth,
});
return (
<i role='img' alt={id} className={classes} {...other} />
);
}
}
export { default } from './icon';

View File

@@ -0,0 +1,114 @@
import classNames from 'classnames';
import spring from 'react-motion/lib/spring';
import Motion from '../../features/ui/util/optional_motion';
import Icon from '../icon';
import './icon_button.scss';
export default class IconButton extends PureComponent {
static propTypes = {
className: PropTypes.string,
title: PropTypes.string.isRequired,
icon: PropTypes.string.isRequired,
onClick: PropTypes.func,
size: PropTypes.number,
active: PropTypes.bool,
pressed: PropTypes.bool,
expanded: PropTypes.bool,
style: PropTypes.object,
disabled: PropTypes.bool,
inverted: PropTypes.bool,
animate: PropTypes.bool,
overlay: PropTypes.bool,
tabIndex: PropTypes.string,
};
static defaultProps = {
size: 18,
active: false,
disabled: false,
animate: false,
overlay: false,
tabIndex: '0',
};
handleClick = (e) => {
e.preventDefault();
if (!this.props.disabled) {
this.props.onClick(e);
}
}
render () {
const style = {
fontSize: `${this.props.size}px`,
width: `${this.props.size * 1.28571429}px`,
height: `${this.props.size * 1.28571429}px`,
lineHeight: `${this.props.size}px`,
...this.props.style,
};
const {
active,
animate,
className,
disabled,
expanded,
icon,
inverted,
overlay,
pressed,
tabIndex,
title,
} = this.props;
const classes = classNames(className, 'icon-button', {
active,
disabled,
inverted,
overlayed: overlay,
});
// Perf optimization: avoid unnecessary <Motion> components unless we actually need to animate.
if (!animate) {
return (
<button
aria-label={title}
aria-pressed={pressed}
aria-expanded={expanded}
title={title}
className={classes}
onClick={this.handleClick}
style={style}
tabIndex={tabIndex}
disabled={disabled}
>
<Icon id={icon} fixedWidth aria-hidden='true' />
</button>
);
}
return (
<Motion defaultStyle={{ rotate: active ? -360 : 0 }} style={{ rotate: animate ? spring(active ? -360 : 0, { stiffness: 120, damping: 7 }) : 0 }}>
{({ rotate }) => (
<button
aria-label={title}
aria-pressed={pressed}
aria-expanded={expanded}
title={title}
className={classes}
onClick={this.handleClick}
style={style}
tabIndex={tabIndex}
disabled={disabled}
>
<Icon id={icon} style={{ transform: `rotate(${rotate}deg)` }} fixedWidth aria-hidden='true' />
</button>
)}
</Motion>
);
}
}

View File

@@ -1,114 +1 @@
import classNames from 'classnames';
import spring from 'react-motion/lib/spring';
import Motion from '../../features/ui/util/optional_motion';
import Icon from '../icon';
import './index.scss';
export default class IconButton extends PureComponent {
static propTypes = {
className: PropTypes.string,
title: PropTypes.string.isRequired,
icon: PropTypes.string.isRequired,
onClick: PropTypes.func,
size: PropTypes.number,
active: PropTypes.bool,
pressed: PropTypes.bool,
expanded: PropTypes.bool,
style: PropTypes.object,
disabled: PropTypes.bool,
inverted: PropTypes.bool,
animate: PropTypes.bool,
overlay: PropTypes.bool,
tabIndex: PropTypes.string,
};
static defaultProps = {
size: 18,
active: false,
disabled: false,
animate: false,
overlay: false,
tabIndex: '0',
};
handleClick = (e) => {
e.preventDefault();
if (!this.props.disabled) {
this.props.onClick(e);
}
}
render () {
const style = {
fontSize: `${this.props.size}px`,
width: `${this.props.size * 1.28571429}px`,
height: `${this.props.size * 1.28571429}px`,
lineHeight: `${this.props.size}px`,
...this.props.style,
};
const {
active,
animate,
className,
disabled,
expanded,
icon,
inverted,
overlay,
pressed,
tabIndex,
title,
} = this.props;
const classes = classNames(className, 'icon-button', {
active,
disabled,
inverted,
overlayed: overlay,
});
// Perf optimization: avoid unnecessary <Motion> components unless we actually need to animate.
if (!animate) {
return (
<button
aria-label={title}
aria-pressed={pressed}
aria-expanded={expanded}
title={title}
className={classes}
onClick={this.handleClick}
style={style}
tabIndex={tabIndex}
disabled={disabled}
>
<Icon id={icon} fixedWidth aria-hidden='true' />
</button>
);
}
return (
<Motion defaultStyle={{ rotate: active ? -360 : 0 }} style={{ rotate: animate ? spring(active ? -360 : 0, { stiffness: 120, damping: 7 }) : 0 }}>
{({ rotate }) => (
<button
aria-label={title}
aria-pressed={pressed}
aria-expanded={expanded}
title={title}
className={classes}
onClick={this.handleClick}
style={style}
tabIndex={tabIndex}
disabled={disabled}
>
<Icon id={icon} style={{ transform: `rotate(${rotate}deg)` }} fixedWidth aria-hidden='true' />
</button>
)}
</Motion>
);
}
}
export { default } from './icon_button';

View File

@@ -0,0 +1,161 @@
import classNames from 'classnames';
import { LoadingBar } from 'react-redux-loading-bar';
import ZoomableImage from '../zoomable_image';
import './image_loader.scss';
export default class ImageLoader extends PureComponent {
static propTypes = {
alt: PropTypes.string,
src: PropTypes.string.isRequired,
previewSrc: PropTypes.string,
width: PropTypes.number,
height: PropTypes.number,
onClick: PropTypes.func,
}
static defaultProps = {
alt: '',
width: null,
height: null,
};
state = {
loading: true,
error: false,
width: null,
}
removers = [];
canvas = null;
get canvasContext() {
if (!this.canvas) {
return null;
}
this._canvasContext = this._canvasContext || this.canvas.getContext('2d');
return this._canvasContext;
}
componentDidMount () {
this.loadImage(this.props);
}
componentWillReceiveProps (nextProps) {
if (this.props.src !== nextProps.src) {
this.loadImage(nextProps);
}
}
componentWillUnmount () {
this.removeEventListeners();
}
loadImage (props) {
this.removeEventListeners();
this.setState({ loading: true, error: false });
Promise.all([
props.previewSrc && this.loadPreviewCanvas(props),
this.hasSize() && this.loadOriginalImage(props),
].filter(Boolean))
.then(() => {
this.setState({ loading: false, error: false });
this.clearPreviewCanvas();
})
.catch(() => this.setState({ loading: false, error: true }));
}
loadPreviewCanvas = ({ previewSrc, width, height }) => new Promise((resolve, reject) => {
const image = new Image();
const removeEventListeners = () => {
image.removeEventListener('error', handleError);
image.removeEventListener('load', handleLoad);
};
const handleError = () => {
removeEventListeners();
reject();
};
const handleLoad = () => {
removeEventListeners();
this.canvasContext.drawImage(image, 0, 0, width, height);
resolve();
};
image.addEventListener('error', handleError);
image.addEventListener('load', handleLoad);
image.src = previewSrc;
this.removers.push(removeEventListeners);
})
clearPreviewCanvas () {
const { width, height } = this.canvas;
this.canvasContext.clearRect(0, 0, width, height);
}
loadOriginalImage = ({ src }) => new Promise((resolve, reject) => {
const image = new Image();
const removeEventListeners = () => {
image.removeEventListener('error', handleError);
image.removeEventListener('load', handleLoad);
};
const handleError = () => {
removeEventListeners();
reject();
};
const handleLoad = () => {
removeEventListeners();
resolve();
};
image.addEventListener('error', handleError);
image.addEventListener('load', handleLoad);
image.src = src;
this.removers.push(removeEventListeners);
});
removeEventListeners () {
this.removers.forEach(listeners => listeners());
this.removers = [];
}
hasSize () {
const { width, height } = this.props;
return typeof width === 'number' && typeof height === 'number';
}
setCanvasRef = c => {
this.canvas = c;
if (c) this.setState({ width: c.offsetWidth });
}
render () {
const { alt, src, width, height, onClick } = this.props;
const { loading } = this.state;
const className = classNames('image-loader', {
'image-loader--loading': loading,
'image-loader--amorphous': !this.hasSize(),
});
return (
<div className={className}>
<LoadingBar loading={loading ? 1 : 0} className='loading-bar' style={{ width: this.state.width || width }} />
{loading ? (
<canvas
className='image-loader__preview-canvas'
ref={this.setCanvasRef}
width={width}
height={height}
/>
) : (
<ZoomableImage
alt={alt}
src={src}
onClick={onClick}
/>
)}
</div>
);
}
}

View File

@@ -0,0 +1,23 @@
.image-loader {
position: relative;
@include flex(center, center, column);
@include size(100%);
&__preview-canvas {
object-fit: contain;
@include max-size($media-modal-media-max-width, $media-modal-media-max-height);
@include background-image('/assets/images/void.png', contain, center, repeat);
}
&--amorphous & {
&__preview-canvas {
display: none;
}
}
.loading-bar {
position: relative;
}
}

View File

@@ -0,0 +1 @@
export { default } from './image_loader';

View File

@@ -1,136 +1 @@
import { is } from 'immutable';
import scheduleIdleTask from '../../utils/schedule_idle_task';
import getRectFromEntry from '../../utils/get_rect_from_entry';
import './index.scss';
// Diff these props in the "rendered" state
const updateOnPropsForRendered = ['id', 'index', 'listLength'];
// Diff these props in the "unrendered" state
const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight'];
export default class IntersectionObserverArticle extends 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 we're going from rendered to unrendered (or vice versa) then update
if (!!isUnrendered !== !!willBeUnrendered) {
return true;
}
// Otherwise, diff based on props
const propsToDiff = isUnrendered ? updateOnPropsForUnrendered : updateOnPropsForRendered;
return !propsToDiff.every(prop => is(nextProps[prop], this.props[prop]));
}
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.
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;
if (!isIntersecting && (isHidden || cachedHeight)) {
return (
<article
ref={this.handleRef}
aria-posinset={index + 1}
aria-setsize={listLength}
style={{ height: `${this.height || cachedHeight}px`, opacity: 0, overflow: 'hidden' }}
data-id={id}
tabIndex='0'
>
{children && React.cloneElement(children, { hidden: true })}
</article>
);
}
return (
<article
ref={this.handleRef}
aria-posinset={index + 1}
aria-setsize={listLength}
data-id={id}
tabIndex='0'
>
{children && React.cloneElement(children, { hidden: false })}
</article>
);
}
}
export { default } from './intersection_observer_article';

View File

@@ -0,0 +1,136 @@
import { is } from 'immutable';
import scheduleIdleTask from '../../utils/schedule_idle_task';
import getRectFromEntry from '../../utils/get_rect_from_entry';
import './intersection_observer_article.scss';
// Diff these props in the "rendered" state
const updateOnPropsForRendered = ['id', 'index', 'listLength'];
// Diff these props in the "unrendered" state
const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight'];
export default class IntersectionObserverArticle extends 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 we're going from rendered to unrendered (or vice versa) then update
if (!!isUnrendered !== !!willBeUnrendered) {
return true;
}
// Otherwise, diff based on props
const propsToDiff = isUnrendered ? updateOnPropsForUnrendered : updateOnPropsForRendered;
return !propsToDiff.every(prop => is(nextProps[prop], this.props[prop]));
}
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.
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;
if (!isIntersecting && (isHidden || cachedHeight)) {
return (
<article
ref={this.handleRef}
aria-posinset={index + 1}
aria-setsize={listLength}
style={{ height: `${this.height || cachedHeight}px`, opacity: 0, overflow: 'hidden' }}
data-id={id}
tabIndex='0'
>
{children && React.cloneElement(children, { hidden: true })}
</article>
);
}
return (
<article
ref={this.handleRef}
aria-posinset={index + 1}
aria-setsize={listLength}
data-id={id}
tabIndex='0'
>
{children && React.cloneElement(children, { hidden: false })}
</article>
);
}
}

View File

@@ -0,0 +1 @@
export { default } from './link_footer';

View File

@@ -0,0 +1,64 @@
import { defineMessages, injectIntl } from 'react-intl';
import { invitesEnabled, version, repository, source_url, me } from '../../initial_state';
import { openModal } from '../../actions/modal';
import './link_footer.scss';
const messages = defineMessages({
invite: { id:'getting_started.invite', defaultMessage: 'Invite people' },
hotkeys: { id: 'navigation_bar.keyboard_shortcuts', defaultMessage: 'Hotkeys' },
security: { id: 'getting_started.security', defaultMessage: 'Security' },
about: { id: 'navigation_bar.info', defaultMessage: 'About' },
developers: { id: 'getting_started.developers', defaultMessage: 'Developers' },
terms: { id: 'getting_started.terms', defaultMessage: 'Terms of Service' },
dmca: { id: 'getting_started.dmca', defaultMessage: 'DMCA' },
terms: { id: 'getting_started.terms_of_sale', defaultMessage: 'Terms of Sale' },
privacy: { id: 'getting_started.privacy', defaultMessage: 'Privacy Policy' },
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
notice: { id: 'getting_started.open_source_notice', defaultMessage: 'Gab Social is open source software. You can contribute or report issues on our self-hosted GitLab at {gitlab}.' },
});
const mapDispatchToProps = (dispatch) => ({
onOpenHotkeys() {
dispatch(openModal('HOTKEYS'));
},
});
export default @connect(null, mapDispatchToProps)
@injectIntl
class LinkFooter extends PureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
onOpenHotkeys: PropTypes.func.isRequired,
};
render() {
const { onOpenHotkeys, intl } = this.props;
return (
<div className='link-footer'>
<ul>
{(invitesEnabled && me) && <li><a href='/invites'>{intl.formatMessage(messages.invite)}</a> · </li>}
{me && <li><a href='#' onClick={onOpenHotkeys}>{intl.formatMessage(messages.hotkeys)}</a> · </li>}
{me && <li><a href='/auth/edit'>{intl.formatMessage(messages.security)}</a> · </li>}
<li><a href='/about'>{intl.formatMessage(messages.about)}</a> · </li>
<li><a href='/settings/applications'>{intl.formatMessage(messages.developers)}</a> · </li>
<li><a href='/about/tos'>{intl.formatMessage(messages.terms)}</a> · </li>
<li><a href='/about/dmca'>{intl.formatMessage(messages.dmca)}</a> · </li>
<li><a href='/about/sales'>{intl.formatMessage(messages.terms)}</a> · </li>
<li><a href='/about/privacy'>{intl.formatMessage(messages.privacy)}</a></li>
{me && <li> · <a href='/auth/sign_out' data-method='delete'>{intl.formatMessage(messages.logout)}</a></li>}
</ul>
<p>
{intl.formatMessage(messages.invite, {
gitlab: <span><a href={source_url} rel='noopener' target='_blank'>{repository}</a> (v{version})</span>
})}
</p>
<p>© 2019 Gab AI Inc.</p>
</div>
);
}
};

View File

@@ -0,0 +1,34 @@
.link-footer {
flex: 0 0 auto;
padding: 10px;
padding-top: 20px;
ul {
margin-bottom: 10px;
li {
display: inline;
}
}
p {
color: $dark-text-color;
font-size: 13px;
margin-bottom: 20px;
a {
color: $gab-secondary-text;
text-decoration: underline;
}
}
a {
text-decoration: none;
color: $gab-secondary-text;
&:hover,
&:focus,
&:active {
text-decoration: underline;
}
}
}

View File

@@ -1,53 +1 @@
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
import classNames from 'classnames';
import Icon from '../icon';
import './index.scss';
const messages = defineMessages({
load_more: { id: 'status.load_more', defaultMessage: 'Load more' },
});
export default @injectIntl
class LoadMore extends PureComponent {
static propTypes = {
onClick: PropTypes.func,
disabled: PropTypes.bool,
visible: PropTypes.bool,
maxId: PropTypes.string,
gap: PropTypes.bool,
intl: PropTypes.object.isRequired,
}
static defaultProps = {
visible: true,
}
handleClick = () => {
const { gap, maxId } = this.props;
this.props.onClick(gap ? maxId : undefined);
}
render() {
const { disabled, visible, gap, intl } = this.props;
const btnClasses = classNames('load-more', {
'load-more--gap': gap,
});
return (
<button
className={btnClasses}
disabled={disabled || !visible}
style={{ visibility: visible ? 'visible' : 'hidden' }}
onClick={this.handleClick}
aria-label={intl.formatMessage(messages.load_more)}
>
{!gap && <FormattedMessage id='status.load_more' defaultMessage='Load more' />}
{gap && <Icon id='ellipsis-h' />}
</button>
);
}
}
export { default } from './load_more';

View File

@@ -0,0 +1,53 @@
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
import classNames from 'classnames';
import Icon from '../icon';
import './load_more.scss';
const messages = defineMessages({
load_more: { id: 'status.load_more', defaultMessage: 'Load more' },
});
export default @injectIntl
class LoadMore extends PureComponent {
static propTypes = {
onClick: PropTypes.func,
disabled: PropTypes.bool,
visible: PropTypes.bool,
maxId: PropTypes.string,
gap: PropTypes.bool,
intl: PropTypes.object.isRequired,
}
static defaultProps = {
visible: true,
}
handleClick = () => {
const { gap, maxId } = this.props;
this.props.onClick(gap ? maxId : undefined);
}
render() {
const { disabled, visible, gap, intl } = this.props;
const btnClasses = classNames('load-more', {
'load-more--gap': gap,
});
return (
<button
className={btnClasses}
disabled={disabled || !visible}
style={{ visibility: visible ? 'visible' : 'hidden' }}
onClick={this.handleClick}
aria-label={intl.formatMessage(messages.load_more)}
>
{!gap && <FormattedMessage id='status.load_more' defaultMessage='Load more' />}
{gap && <Icon id='ellipsis-h' />}
</button>
);
}
}

View File

@@ -1,332 +1 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
import { is } from 'immutable';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { decode } from 'blurhash';
import IconButton from '../icon_button';
import { isIOS } from '../../utils/is_mobile';
import { autoPlayGif, displayMedia } from '../../initial_state';
import './index.scss';
const messages = defineMessages({
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
});
class Item extends PureComponent {
static propTypes = {
attachment: ImmutablePropTypes.map.isRequired,
standalone: PropTypes.bool,
index: PropTypes.number.isRequired,
size: PropTypes.number.isRequired,
onClick: PropTypes.func.isRequired,
displayWidth: PropTypes.number,
visible: PropTypes.bool.isRequired,
};
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;
}
}
hoverToPlay () {
const { attachment } = this.props;
return !autoPlayGif && 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 () {
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, displayWidth, visible } = this.props;
const width = (size === 1) ? 100 : 50;
const height = (size === 4 || (size === 3 && index > 0)) ? 50 : 100;
let top = 'auto';
let left = 'auto';
let bottom = 'auto';
let right = 'auto';
switch(size) {
case 2:
if (index === 0) right = '2px';
else left = '2px';
break;
case 3:
if (index === 0) right = '2px';
else if (index > 0) left = '2px';
if (index === 1) bottom = '2px';
else if (index > 1) top = '2px';
break;
case 4:
if (index === 0 || index === 2) right = '2px';
if (index === 1 || index === 3) left = '2px';
if (index < 2) bottom = '2px';
else top = '2px';
break;
}
let thumbnail = '';
if (attachment.get('type') === 'unknown') {
return (
<div className={classNames('media-item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
<a className='media-item__thumbnail' href={attachment.get('remote_url')} target='_blank' style={{ cursor: 'pointer' }}>
<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-item__thumbnail'
href={attachment.get('remote_url') || originalUrl}
onClick={this.handleClick}
target='_blank'
>
<img
src={previewUrl}
srcSet={srcSet}
sizes={sizes}
alt={attachment.get('description')}
title={attachment.get('description')}
style={{ objectPosition: `${x}% ${y}%` }}
onLoad={this.handleImageLoad}
/>
</a>
);
} else if (attachment.get('type') === 'gifv') {
const autoPlay = !isIOS() && autoPlayGif;
thumbnail = (
<div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
<video
className='media-item__gifv'
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-item', { standalone })} 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 PureComponent {
static propTypes = {
sensitive: PropTypes.bool,
standalone: PropTypes.bool,
media: ImmutablePropTypes.list.isRequired,
size: PropTypes.object,
height: PropTypes.number.isRequired,
onOpenMedia: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
defaultWidth: PropTypes.number,
cacheWidth: PropTypes.func,
visible: 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 });
}
}
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) => {
if (node /*&& this.isStandaloneEligible()*/) {
// offsetWidth triggers a layout, so only calculate when we need to
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, height, defaultWidth } = this.props;
const { visible } = this.state;
const width = this.state.width || defaultWidth;
let children, spoilerButton;
const style = {};
if (this.isStandaloneEligible()) {
if (width) {
style.height = width / this.props.media.getIn([0, 'meta', 'small', 'aspect']);
}
} else if (width) {
style.height = width / (16/9);
} else {
style.height = height;
}
const size = media.take(4).size;
if (this.isStandaloneEligible()) {
children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} visible={visible} />;
} else {
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} displayWidth={width} visible={visible} />);
}
if (visible) {
spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible)} 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='media-gallery' style={style} ref={this.handleRef}>
<div className={classNames('spoiler-button', { 'spoiler-button--minified': visible })}>
{spoilerButton}
</div>
{children}
</div>
);
}
}
export { default } from './media_gallery';

View File

@@ -0,0 +1,332 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
import { is } from 'immutable';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { decode } from 'blurhash';
import IconButton from '../icon_button';
import { isIOS } from '../../utils/is_mobile';
import { autoPlayGif, displayMedia } from '../../initial_state';
import './media_gallery.scss';
const messages = defineMessages({
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
});
class Item extends PureComponent {
static propTypes = {
attachment: ImmutablePropTypes.map.isRequired,
standalone: PropTypes.bool,
index: PropTypes.number.isRequired,
size: PropTypes.number.isRequired,
onClick: PropTypes.func.isRequired,
displayWidth: PropTypes.number,
visible: PropTypes.bool.isRequired,
};
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;
}
}
hoverToPlay () {
const { attachment } = this.props;
return !autoPlayGif && 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 () {
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, displayWidth, visible } = this.props;
const width = (size === 1) ? 100 : 50;
const height = (size === 4 || (size === 3 && index > 0)) ? 50 : 100;
let top = 'auto';
let left = 'auto';
let bottom = 'auto';
let right = 'auto';
switch(size) {
case 2:
if (index === 0) right = '2px';
else left = '2px';
break;
case 3:
if (index === 0) right = '2px';
else if (index > 0) left = '2px';
if (index === 1) bottom = '2px';
else if (index > 1) top = '2px';
break;
case 4:
if (index === 0 || index === 2) right = '2px';
if (index === 1 || index === 3) left = '2px';
if (index < 2) bottom = '2px';
else top = '2px';
break;
}
let thumbnail = '';
if (attachment.get('type') === 'unknown') {
return (
<div className={classNames('media-item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
<a className='media-item__thumbnail' href={attachment.get('remote_url')} target='_blank' style={{ cursor: 'pointer' }}>
<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-item__thumbnail'
href={attachment.get('remote_url') || originalUrl}
onClick={this.handleClick}
target='_blank'
>
<img
src={previewUrl}
srcSet={srcSet}
sizes={sizes}
alt={attachment.get('description')}
title={attachment.get('description')}
style={{ objectPosition: `${x}% ${y}%` }}
onLoad={this.handleImageLoad}
/>
</a>
);
} else if (attachment.get('type') === 'gifv') {
const autoPlay = !isIOS() && autoPlayGif;
thumbnail = (
<div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
<video
className='media-item__gifv'
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-item', { standalone })} 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 PureComponent {
static propTypes = {
sensitive: PropTypes.bool,
standalone: PropTypes.bool,
media: ImmutablePropTypes.list.isRequired,
size: PropTypes.object,
height: PropTypes.number.isRequired,
onOpenMedia: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
defaultWidth: PropTypes.number,
cacheWidth: PropTypes.func,
visible: 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 });
}
}
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) => {
if (node /*&& this.isStandaloneEligible()*/) {
// offsetWidth triggers a layout, so only calculate when we need to
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, height, defaultWidth } = this.props;
const { visible } = this.state;
const width = this.state.width || defaultWidth;
let children, spoilerButton;
const style = {};
if (this.isStandaloneEligible()) {
if (width) {
style.height = width / this.props.media.getIn([0, 'meta', 'small', 'aspect']);
}
} else if (width) {
style.height = width / (16/9);
} else {
style.height = height;
}
const size = media.take(4).size;
if (this.isStandaloneEligible()) {
children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} visible={visible} />;
} else {
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} displayWidth={width} visible={visible} />);
}
if (visible) {
spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible)} 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='media-gallery' style={style} ref={this.handleRef}>
<div className={classNames('spoiler-button', { 'spoiler-button--minified': visible })}>
{spoilerButton}
</div>
{children}
</div>
);
}
}

Some files were not shown because too many files have changed in this diff Show More