Merge upstream 2.0ish #165
This commit is contained in:
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
|
||||
import Link from 'react-router-dom/Link';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
|
@@ -5,7 +5,6 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import ReplyIndicatorContainer from '../containers/reply_indicator_container';
|
||||
import AutosuggestTextarea from '../../../components/autosuggest_textarea';
|
||||
import { debounce } from 'lodash';
|
||||
import UploadButtonContainer from '../containers/upload_button_container';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import Collapsable from '../../../components/collapsable';
|
||||
@@ -94,9 +93,9 @@ export default class ComposeForm extends ImmutablePureComponent {
|
||||
this.props.onClearSuggestions();
|
||||
}
|
||||
|
||||
onSuggestionsFetchRequested = debounce((token) => {
|
||||
onSuggestionsFetchRequested = (token) => {
|
||||
this.props.onFetchSuggestions(token);
|
||||
}, 500, { trailing: true })
|
||||
}
|
||||
|
||||
onSuggestionSelected = (tokenStart, token, value) => {
|
||||
this._restoreCaret = null;
|
||||
|
@@ -1,11 +1,12 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { Picker, Emoji } from 'emoji-mart';
|
||||
import { Overlay } from 'react-overlays';
|
||||
import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components';
|
||||
import Overlay from 'react-overlays/lib/Overlay';
|
||||
import classNames from 'classnames';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import detectPassiveEvents from 'detect-passive-events';
|
||||
import { buildCustomEmojis } from '../../emoji/emoji';
|
||||
|
||||
const messages = defineMessages({
|
||||
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
|
||||
@@ -25,9 +26,24 @@ const messages = defineMessages({
|
||||
});
|
||||
|
||||
const assetHost = process.env.CDN_HOST || '';
|
||||
let EmojiPicker, Emoji; // load asynchronously
|
||||
|
||||
const backgroundImageFn = () => `${assetHost}/emoji/sheet.png`;
|
||||
const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
|
||||
|
||||
const categoriesSort = [
|
||||
'recent',
|
||||
'custom',
|
||||
'people',
|
||||
'nature',
|
||||
'foods',
|
||||
'activity',
|
||||
'places',
|
||||
'objects',
|
||||
'symbols',
|
||||
'flags',
|
||||
];
|
||||
|
||||
class ModifierPickerMenu extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
@@ -36,9 +52,8 @@ class ModifierPickerMenu extends React.PureComponent {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
handleClick = (e) => {
|
||||
const modifier = [].slice.call(e.currentTarget.parentNode.children).indexOf(e.target) + 1;
|
||||
this.props.onSelect(modifier);
|
||||
handleClick = e => {
|
||||
this.props.onSelect(e.currentTarget.getAttribute('data-index') * 1);
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
@@ -78,12 +93,12 @@ class ModifierPickerMenu extends React.PureComponent {
|
||||
|
||||
return (
|
||||
<div className='emoji-picker-dropdown__modifiers__menu' style={{ display: active ? 'block' : 'none' }} ref={this.setRef}>
|
||||
<button onClick={this.handleClick}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} /></button>
|
||||
<button onClick={this.handleClick}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} /></button>
|
||||
<button onClick={this.handleClick}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} /></button>
|
||||
<button onClick={this.handleClick}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} /></button>
|
||||
<button onClick={this.handleClick}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} /></button>
|
||||
<button onClick={this.handleClick}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} /></button>
|
||||
<button onClick={this.handleClick} data-index={1}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} /></button>
|
||||
<button onClick={this.handleClick} data-index={2}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} /></button>
|
||||
<button onClick={this.handleClick} data-index={3}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} /></button>
|
||||
<button onClick={this.handleClick} data-index={4}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} /></button>
|
||||
<button onClick={this.handleClick} data-index={5}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} /></button>
|
||||
<button onClick={this.handleClick} data-index={6}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} /></button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -131,6 +146,8 @@ class EmojiPickerMenu extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
custom_emojis: ImmutablePropTypes.list,
|
||||
frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
|
||||
loading: PropTypes.bool,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onPick: PropTypes.func.isRequired,
|
||||
style: PropTypes.object,
|
||||
@@ -138,16 +155,20 @@ class EmojiPickerMenu extends React.PureComponent {
|
||||
arrowOffsetLeft: PropTypes.string,
|
||||
arrowOffsetTop: PropTypes.string,
|
||||
intl: PropTypes.object.isRequired,
|
||||
skinTone: PropTypes.number.isRequired,
|
||||
onSkinTone: PropTypes.func.isRequired,
|
||||
autoPlay: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
style: {},
|
||||
loading: true,
|
||||
placement: 'bottom',
|
||||
frequentlyUsedEmojis: [],
|
||||
};
|
||||
|
||||
state = {
|
||||
modifierOpen: false,
|
||||
modifier: 1,
|
||||
};
|
||||
|
||||
handleDocumentClick = e => {
|
||||
@@ -210,35 +231,43 @@ class EmojiPickerMenu extends React.PureComponent {
|
||||
}
|
||||
|
||||
handleModifierChange = modifier => {
|
||||
if (modifier !== this.state.modifier) {
|
||||
this.setState({ modifier });
|
||||
}
|
||||
this.props.onSkinTone(modifier);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { style, intl } = this.props;
|
||||
const { loading, style, intl, custom_emojis, autoPlay, skinTone, frequentlyUsedEmojis } = this.props;
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ width: 299 }} />;
|
||||
}
|
||||
|
||||
const title = intl.formatMessage(messages.emoji);
|
||||
const { modifierOpen, modifier } = this.state;
|
||||
const { modifierOpen } = this.state;
|
||||
|
||||
return (
|
||||
<div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}>
|
||||
<Picker
|
||||
<EmojiPicker
|
||||
perLine={8}
|
||||
emojiSize={22}
|
||||
sheetSize={32}
|
||||
custom={buildCustomEmojis(custom_emojis, autoPlay)}
|
||||
color=''
|
||||
emoji=''
|
||||
set='twitter'
|
||||
title={title}
|
||||
i18n={this.getI18n()}
|
||||
onClick={this.handleClick}
|
||||
skin={modifier}
|
||||
include={categoriesSort}
|
||||
recent={frequentlyUsedEmojis}
|
||||
skin={skinTone}
|
||||
showPreview={false}
|
||||
backgroundImageFn={backgroundImageFn}
|
||||
emojiTooltip
|
||||
/>
|
||||
|
||||
<ModifierPicker
|
||||
active={modifierOpen}
|
||||
modifier={modifier}
|
||||
modifier={skinTone}
|
||||
onOpen={this.handleModifierOpen}
|
||||
onClose={this.handleModifierClose}
|
||||
onChange={this.handleModifierChange}
|
||||
@@ -254,12 +283,17 @@ export default class EmojiPickerDropdown extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
custom_emojis: ImmutablePropTypes.list,
|
||||
frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
|
||||
autoPlay: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
onPickEmoji: PropTypes.func.isRequired,
|
||||
onSkinTone: PropTypes.func.isRequired,
|
||||
skinTone: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
active: false,
|
||||
loading: false,
|
||||
};
|
||||
|
||||
setRef = (c) => {
|
||||
@@ -268,6 +302,19 @@ export default class EmojiPickerDropdown extends React.PureComponent {
|
||||
|
||||
onShowDropdown = () => {
|
||||
this.setState({ active: true });
|
||||
|
||||
if (!EmojiPicker) {
|
||||
this.setState({ loading: true });
|
||||
|
||||
EmojiPickerAsync().then(EmojiMart => {
|
||||
EmojiPicker = EmojiMart.Picker;
|
||||
Emoji = EmojiMart.Emoji;
|
||||
|
||||
this.setState({ loading: false });
|
||||
}).catch(() => {
|
||||
this.setState({ loading: false });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onHideDropdown = () => {
|
||||
@@ -275,7 +322,7 @@ export default class EmojiPickerDropdown extends React.PureComponent {
|
||||
}
|
||||
|
||||
onToggle = (e) => {
|
||||
if (!e.key || e.key === 'Enter') {
|
||||
if (!this.state.loading && (!e.key || e.key === 'Enter')) {
|
||||
if (this.state.active) {
|
||||
this.onHideDropdown();
|
||||
} else {
|
||||
@@ -299,15 +346,15 @@ export default class EmojiPickerDropdown extends React.PureComponent {
|
||||
}
|
||||
|
||||
render () {
|
||||
const { intl, onPickEmoji } = this.props;
|
||||
const { intl, onPickEmoji, autoPlay, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props;
|
||||
const title = intl.formatMessage(messages.emoji);
|
||||
const { active } = this.state;
|
||||
const { active, loading } = this.state;
|
||||
|
||||
return (
|
||||
<div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}>
|
||||
<div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}>
|
||||
<img
|
||||
className='emojione'
|
||||
className={classNames('emojione', { 'pulse-loading': active && loading })}
|
||||
alt='🙂'
|
||||
src={`${assetHost}/emoji/1f602.svg`}
|
||||
/>
|
||||
@@ -316,8 +363,13 @@ export default class EmojiPickerDropdown extends React.PureComponent {
|
||||
<Overlay show={active} placement='bottom' target={this.findTarget}>
|
||||
<EmojiPickerMenu
|
||||
custom_emojis={this.props.custom_emojis}
|
||||
loading={loading}
|
||||
onClose={this.onHideDropdown}
|
||||
onPick={onPickEmoji}
|
||||
autoPlay={autoPlay}
|
||||
onSkinTone={onSkinTone}
|
||||
skinTone={skinTone}
|
||||
frequentlyUsedEmojis={frequentlyUsedEmojis}
|
||||
/>
|
||||
</Overlay>
|
||||
</div>
|
||||
|
@@ -2,7 +2,11 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, defineMessages } from 'react-intl';
|
||||
import IconButton from '../../../components/icon_button';
|
||||
import Overlay from 'react-overlays/lib/Overlay';
|
||||
import Motion from 'react-motion/lib/Motion';
|
||||
import spring from 'react-motion/lib/spring';
|
||||
import detectPassiveEvents from 'detect-passive-events';
|
||||
import classNames from 'classnames';
|
||||
|
||||
const messages = defineMessages({
|
||||
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
||||
@@ -16,10 +20,77 @@ const messages = defineMessages({
|
||||
change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' },
|
||||
});
|
||||
|
||||
const iconStyle = {
|
||||
height: null,
|
||||
lineHeight: '27px',
|
||||
};
|
||||
const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
|
||||
|
||||
class PrivacyDropdownMenu extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
style: PropTypes.object,
|
||||
items: PropTypes.array.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
handleDocumentClick = e => {
|
||||
if (this.node && !this.node.contains(e.target)) {
|
||||
this.props.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
handleClick = e => {
|
||||
if (e.key === 'Escape') {
|
||||
this.props.onClose();
|
||||
} else if (!e.key || e.key === 'Enter') {
|
||||
const value = e.currentTarget.getAttribute('data-index');
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
this.props.onClose();
|
||||
this.props.onChange(value);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
document.addEventListener('click', this.handleDocumentClick, false);
|
||||
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
document.removeEventListener('click', this.handleDocumentClick, false);
|
||||
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
render () {
|
||||
const { style, items, value } = this.props;
|
||||
|
||||
return (
|
||||
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
|
||||
{({ opacity, scaleX, scaleY }) => (
|
||||
<div className='privacy-dropdown__dropdown' style={{ ...style, opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }} ref={this.setRef}>
|
||||
{items.map(item =>
|
||||
<div role='button' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleClick} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })}>
|
||||
<div className='privacy-dropdown__option__icon'>
|
||||
<i className={`fa fa-fw fa-${item.icon}`} />
|
||||
</div>
|
||||
|
||||
<div className='privacy-dropdown__option__content'>
|
||||
<strong>{item.text}</strong>
|
||||
{item.meta}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Motion>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@injectIntl
|
||||
export default class PrivacyDropdown extends React.PureComponent {
|
||||
@@ -55,26 +126,30 @@ export default class PrivacyDropdown extends React.PureComponent {
|
||||
|
||||
handleModalActionClick = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const { value } = this.options[e.currentTarget.getAttribute('data-index')];
|
||||
|
||||
this.props.onModalClose();
|
||||
this.props.onChange(value);
|
||||
}
|
||||
|
||||
handleClick = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
this.setState({ open: false });
|
||||
} else if (!e.key || e.key === 'Enter') {
|
||||
const value = e.currentTarget.getAttribute('data-index');
|
||||
e.preventDefault();
|
||||
this.setState({ open: false });
|
||||
this.props.onChange(value);
|
||||
handleKeyDown = e => {
|
||||
switch(e.key) {
|
||||
case 'Enter':
|
||||
this.handleToggle();
|
||||
break;
|
||||
case 'Escape':
|
||||
this.handleClose();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
onGlobalClick = (e) => {
|
||||
if (e.target !== this.node && !this.node.contains(e.target) && this.state.open) {
|
||||
this.setState({ open: false });
|
||||
}
|
||||
handleClose = () => {
|
||||
this.setState({ open: false });
|
||||
}
|
||||
|
||||
handleChange = value => {
|
||||
this.props.onChange(value);
|
||||
}
|
||||
|
||||
componentWillMount () {
|
||||
@@ -88,20 +163,6 @@ export default class PrivacyDropdown extends React.PureComponent {
|
||||
];
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
window.addEventListener('click', this.onGlobalClick);
|
||||
window.addEventListener('touchstart', this.onGlobalClick, detectPassiveEvents.hasSupport ? { passive: true } : false);
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
window.removeEventListener('click', this.onGlobalClick);
|
||||
window.removeEventListener('touchstart', this.onGlobalClick, detectPassiveEvents.hasSupport ? { passive: true } : false);
|
||||
}
|
||||
|
||||
setRef = (c) => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
render () {
|
||||
const { value, intl } = this.props;
|
||||
const { open } = this.state;
|
||||
@@ -109,19 +170,29 @@ export default class PrivacyDropdown extends React.PureComponent {
|
||||
const valueOption = this.options.find(item => item.value === value);
|
||||
|
||||
return (
|
||||
<div ref={this.setRef} className={`privacy-dropdown ${open ? 'active' : ''}`}>
|
||||
<div className='privacy-dropdown__value'><IconButton className='privacy-dropdown__value-icon' icon={valueOption.icon} title={intl.formatMessage(messages.change_privacy)} size={18} expanded={open} active={open} inverted onClick={this.handleToggle} style={iconStyle} /></div>
|
||||
<div className='privacy-dropdown__dropdown'>
|
||||
{open && this.options.map(item =>
|
||||
<div role='button' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleClick} onClick={this.handleClick} className={`privacy-dropdown__option ${item.value === value ? 'active' : ''}`}>
|
||||
<div className='privacy-dropdown__option__icon'><i className={`fa fa-fw fa-${item.icon}`} /></div>
|
||||
<div className='privacy-dropdown__option__content'>
|
||||
<strong>{item.text}</strong>
|
||||
{item.meta}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={classNames('privacy-dropdown', { active: open })} onKeyDown={this.handleKeyDown}>
|
||||
<div className={classNames('privacy-dropdown__value', { active: this.options.indexOf(valueOption) === 0 })}>
|
||||
<IconButton
|
||||
className='privacy-dropdown__value-icon'
|
||||
icon={valueOption.icon}
|
||||
title={intl.formatMessage(messages.change_privacy)}
|
||||
size={18}
|
||||
expanded={open}
|
||||
active={open}
|
||||
inverted
|
||||
onClick={this.handleToggle}
|
||||
style={{ height: null, lineHeight: '27px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Overlay show={open} placement='bottom' target={this}>
|
||||
<PrivacyDropdownMenu
|
||||
items={this.options}
|
||||
value={value}
|
||||
onClose={this.handleClose}
|
||||
onChange={this.handleChange}
|
||||
/>
|
||||
</Overlay>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -1,11 +1,47 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import Overlay from 'react-overlays/lib/Overlay';
|
||||
import Motion from 'react-motion/lib/Motion';
|
||||
import spring from 'react-motion/lib/spring';
|
||||
|
||||
const messages = defineMessages({
|
||||
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
|
||||
});
|
||||
|
||||
class SearchPopout extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
style: PropTypes.object,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { style } = this.props;
|
||||
|
||||
return (
|
||||
<div style={{ ...style, position: 'absolute', width: 285 }}>
|
||||
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
|
||||
{({ opacity, scaleX, scaleY }) => (
|
||||
<div className='search-popout' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
|
||||
<h4><FormattedMessage id='search_popout.search_format' defaultMessage='Advanced search format' /></h4>
|
||||
|
||||
<ul>
|
||||
<li><em>#example</em> <FormattedMessage id='search_popout.tips.hashtag' defaultMessage='hashtag' /></li>
|
||||
<li><em>@username@domain</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li>
|
||||
<li><em>URL</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li>
|
||||
<li><em>URL</em> <FormattedMessage id='search_popout.tips.status' defaultMessage='status' /></li>
|
||||
</ul>
|
||||
|
||||
<FormattedMessage id='search_popout.tips.text' defaultMessage='Simple text returns matching display names, usernames and hashtags' />
|
||||
</div>
|
||||
)}
|
||||
</Motion>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@injectIntl
|
||||
export default class Search extends React.PureComponent {
|
||||
|
||||
@@ -19,6 +55,10 @@ export default class Search extends React.PureComponent {
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
expanded: false,
|
||||
};
|
||||
|
||||
handleChange = (e) => {
|
||||
this.props.onChange(e.target.value);
|
||||
}
|
||||
@@ -35,6 +75,8 @@ export default class Search extends React.PureComponent {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.props.onSubmit();
|
||||
} else if (e.key === 'Escape') {
|
||||
document.querySelector('.ui').parentElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,11 +85,17 @@ export default class Search extends React.PureComponent {
|
||||
}
|
||||
|
||||
handleFocus = () => {
|
||||
this.setState({ expanded: true });
|
||||
this.props.onShow();
|
||||
}
|
||||
|
||||
handleBlur = () => {
|
||||
this.setState({ expanded: false });
|
||||
}
|
||||
|
||||
render () {
|
||||
const { intl, value, submitted } = this.props;
|
||||
const { expanded } = this.state;
|
||||
const hasValue = value.length > 0 || submitted;
|
||||
|
||||
return (
|
||||
@@ -62,6 +110,7 @@ export default class Search extends React.PureComponent {
|
||||
onChange={this.handleChange}
|
||||
onKeyUp={this.handleKeyDown}
|
||||
onFocus={this.handleFocus}
|
||||
onBlur={this.handleBlur}
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -69,6 +118,10 @@ export default class Search extends React.PureComponent {
|
||||
<i className={`fa fa-search ${hasValue ? '' : 'active'}`} />
|
||||
<i aria-label={intl.formatMessage(messages.placeholder)} className={`fa fa-times-circle ${hasValue ? 'active' : ''}`} />
|
||||
</div>
|
||||
|
||||
<Overlay show={expanded && !hasValue} placement='bottom' target={this}>
|
||||
<SearchPopout />
|
||||
</Overlay>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -3,7 +3,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import AccountContainer from '../../../containers/account_container';
|
||||
import StatusContainer from '../../../../glitch/components/status/container';
|
||||
import Link from 'react-router-dom/Link';
|
||||
import { Link } from 'react-router-dom';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
export default class SearchResults extends ImmutablePureComponent {
|
||||
|
@@ -0,0 +1,96 @@
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import IconButton from '../../../components/icon_button';
|
||||
import Motion from 'react-motion/lib/Motion';
|
||||
import spring from 'react-motion/lib/spring';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import classNames from 'classnames';
|
||||
|
||||
const messages = defineMessages({
|
||||
undo: { id: 'upload_form.undo', defaultMessage: 'Undo' },
|
||||
description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
|
||||
});
|
||||
|
||||
@injectIntl
|
||||
export default class Upload extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
media: ImmutablePropTypes.map.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
onUndo: PropTypes.func.isRequired,
|
||||
onDescriptionChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
hovered: false,
|
||||
focused: false,
|
||||
dirtyDescription: null,
|
||||
};
|
||||
|
||||
handleUndoClick = () => {
|
||||
this.props.onUndo(this.props.media.get('id'));
|
||||
}
|
||||
|
||||
handleInputChange = e => {
|
||||
this.setState({ dirtyDescription: e.target.value });
|
||||
}
|
||||
|
||||
handleMouseEnter = () => {
|
||||
this.setState({ hovered: true });
|
||||
}
|
||||
|
||||
handleMouseLeave = () => {
|
||||
this.setState({ hovered: false });
|
||||
}
|
||||
|
||||
handleInputFocus = () => {
|
||||
this.setState({ focused: true });
|
||||
}
|
||||
|
||||
handleInputBlur = () => {
|
||||
const { dirtyDescription } = this.state;
|
||||
|
||||
this.setState({ focused: false, dirtyDescription: null });
|
||||
|
||||
if (dirtyDescription !== null) {
|
||||
this.props.onDescriptionChange(this.props.media.get('id'), dirtyDescription);
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const { intl, media } = this.props;
|
||||
const active = this.state.hovered || this.state.focused;
|
||||
const description = this.state.dirtyDescription || media.get('description') || '';
|
||||
|
||||
return (
|
||||
<div className='compose-form__upload' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
||||
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
|
||||
{({ scale }) => (
|
||||
<div className='compose-form__upload-thumbnail' style={{ transform: `translateZ(0) scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})` }}>
|
||||
<IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.handleUndoClick} />
|
||||
|
||||
<div className={classNames('compose-form__upload-description', { active })}>
|
||||
<label>
|
||||
<span style={{ display: 'none' }}>{intl.formatMessage(messages.description)}</span>
|
||||
|
||||
<input
|
||||
placeholder={intl.formatMessage(messages.description)}
|
||||
type='text'
|
||||
value={description}
|
||||
maxLength={420}
|
||||
onFocus={this.handleInputFocus}
|
||||
onChange={this.handleInputChange}
|
||||
onBlur={this.handleInputBlur}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Motion>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@@ -1,49 +1,27 @@
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import IconButton from '../../../components/icon_button';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import UploadProgressContainer from '../containers/upload_progress_container';
|
||||
import Motion from 'react-motion/lib/Motion';
|
||||
import spring from 'react-motion/lib/spring';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import UploadContainer from '../containers/upload_container';
|
||||
|
||||
const messages = defineMessages({
|
||||
undo: { id: 'upload_form.undo', defaultMessage: 'Undo' },
|
||||
});
|
||||
|
||||
@injectIntl
|
||||
export default class UploadForm extends React.PureComponent {
|
||||
export default class UploadForm extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
media: ImmutablePropTypes.list.isRequired,
|
||||
onRemoveFile: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
mediaIds: ImmutablePropTypes.list.isRequired,
|
||||
};
|
||||
|
||||
onRemoveFile = (e) => {
|
||||
const id = e.currentTarget.parentElement.getAttribute('data-id');
|
||||
this.props.onRemoveFile(id);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { intl, media } = this.props;
|
||||
|
||||
const uploads = media.map(attachment =>
|
||||
<div className='compose-form__upload' key={attachment.get('id')}>
|
||||
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
|
||||
{({ scale }) =>
|
||||
<div className='compose-form__upload-thumbnail' data-id={attachment.get('id')} style={{ transform: `translateZ(0) scale(${scale})`, backgroundImage: `url(${attachment.get('preview_url')})` }}>
|
||||
<IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.onRemoveFile} />
|
||||
</div>
|
||||
}
|
||||
</Motion>
|
||||
</div>
|
||||
);
|
||||
const { mediaIds } = this.props;
|
||||
|
||||
return (
|
||||
<div className='compose-form__upload-wrapper'>
|
||||
<UploadProgressContainer />
|
||||
<div className='compose-form__uploads-wrapper'>{uploads}</div>
|
||||
|
||||
<div className='compose-form__uploads-wrapper'>
|
||||
{mediaIds.map(id => (
|
||||
<UploadContainer id={id} key={id} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -1,5 +1,7 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Motion from 'react-motion/lib/Motion';
|
||||
import spring from 'react-motion/lib/spring';
|
||||
|
||||
export default class Warning extends React.PureComponent {
|
||||
|
||||
@@ -11,9 +13,13 @@ export default class Warning extends React.PureComponent {
|
||||
const { message } = this.props;
|
||||
|
||||
return (
|
||||
<div className='compose-form__warning'>
|
||||
{message}
|
||||
</div>
|
||||
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
|
||||
{({ opacity, scaleX, scaleY }) => (
|
||||
<div className='compose-form__warning' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
</Motion>
|
||||
);
|
||||
}
|
||||
|
||||
|
@@ -1,8 +1,83 @@
|
||||
import { connect } from 'react-redux';
|
||||
import EmojiPickerDropdown from '../components/emoji_picker_dropdown';
|
||||
import { changeSetting } from '../../../actions/settings';
|
||||
import { createSelector } from 'reselect';
|
||||
import { Map as ImmutableMap } from 'immutable';
|
||||
import { useEmoji } from '../../../actions/emojis';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
custom_emojis: state.get('custom_emojis'),
|
||||
const perLine = 8;
|
||||
const lines = 2;
|
||||
|
||||
const DEFAULTS = [
|
||||
'+1',
|
||||
'grinning',
|
||||
'kissing_heart',
|
||||
'heart_eyes',
|
||||
'laughing',
|
||||
'stuck_out_tongue_winking_eye',
|
||||
'sweat_smile',
|
||||
'joy',
|
||||
'yum',
|
||||
'disappointed',
|
||||
'thinking_face',
|
||||
'weary',
|
||||
'sob',
|
||||
'sunglasses',
|
||||
'heart',
|
||||
'ok_hand',
|
||||
];
|
||||
|
||||
const getFrequentlyUsedEmojis = createSelector([
|
||||
state => state.getIn(['settings', 'frequentlyUsedEmojis'], ImmutableMap()),
|
||||
], emojiCounters => {
|
||||
let emojis = emojiCounters
|
||||
.keySeq()
|
||||
.sort((a, b) => emojiCounters.get(a) - emojiCounters.get(b))
|
||||
.reverse()
|
||||
.slice(0, perLine * lines)
|
||||
.toArray();
|
||||
|
||||
if (emojis.length < DEFAULTS.length) {
|
||||
emojis = emojis.concat(DEFAULTS.slice(0, DEFAULTS.length - emojis.length));
|
||||
}
|
||||
|
||||
return emojis;
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(EmojiPickerDropdown);
|
||||
const getCustomEmojis = createSelector([
|
||||
state => state.get('custom_emojis'),
|
||||
], emojis => emojis.sort((a, b) => {
|
||||
const aShort = a.get('shortcode').toLowerCase();
|
||||
const bShort = b.get('shortcode').toLowerCase();
|
||||
|
||||
if (aShort < bShort) {
|
||||
return -1;
|
||||
} else if (aShort > bShort ) {
|
||||
return 1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}));
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
custom_emojis: getCustomEmojis(state),
|
||||
autoPlay: state.getIn(['meta', 'auto_play_gif']),
|
||||
skinTone: state.getIn(['settings', 'skinTone']),
|
||||
frequentlyUsedEmojis: getFrequentlyUsedEmojis(state),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({
|
||||
onSkinTone: skinTone => {
|
||||
dispatch(changeSetting(['skinTone'], skinTone));
|
||||
},
|
||||
|
||||
onPickEmoji: emoji => {
|
||||
dispatch(useEmoji(emoji));
|
||||
|
||||
if (onPickEmoji) {
|
||||
onPickEmoji(emoji);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(EmojiPickerDropdown);
|
||||
|
@@ -0,0 +1,21 @@
|
||||
import { connect } from 'react-redux';
|
||||
import Upload from '../components/upload';
|
||||
import { undoUploadCompose, changeUploadCompose } from '../../../actions/compose';
|
||||
|
||||
const mapStateToProps = (state, { id }) => ({
|
||||
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
|
||||
onUndo: id => {
|
||||
dispatch(undoUploadCompose(id));
|
||||
},
|
||||
|
||||
onDescriptionChange: (id, description) => {
|
||||
dispatch(changeUploadCompose(id, description));
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Upload);
|
@@ -1,17 +1,8 @@
|
||||
import { connect } from 'react-redux';
|
||||
import UploadForm from '../components/upload_form';
|
||||
import { undoUploadCompose } from '../../../actions/compose';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
media: state.getIn(['compose', 'media_attachments']),
|
||||
mediaIds: state.getIn(['compose', 'media_attachments']).map(item => item.get('id')),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
|
||||
onRemoveFile (media_id) {
|
||||
dispatch(undoUploadCompose(media_id));
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(UploadForm);
|
||||
export default connect(mapStateToProps)(UploadForm);
|
||||
|
@@ -7,7 +7,7 @@ import { connect } from 'react-redux';
|
||||
import { mountCompose, unmountCompose } from '../../actions/compose';
|
||||
import { openModal } from '../../actions/modal';
|
||||
import { changeLocalSetting } from '../../../glitch/actions/local_settings';
|
||||
import Link from 'react-router-dom/Link';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { injectIntl, defineMessages } from 'react-intl';
|
||||
import SearchContainer from './containers/search_container';
|
||||
import Motion from 'react-motion/lib/Motion';
|
||||
|
77
app/javascript/mastodon/features/emoji/emoji.js
Normal file
77
app/javascript/mastodon/features/emoji/emoji.js
Normal file
@@ -0,0 +1,77 @@
|
||||
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 || '';
|
||||
|
||||
let allowAnimations = false;
|
||||
|
||||
const emojify = (str, customEmojis = {}) => {
|
||||
let rtn = '';
|
||||
for (;;) {
|
||||
let match, i = 0, tag;
|
||||
while (i < str.length && (tag = '<&:'.indexOf(str[i])) === -1 && !(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 = allowAnimations ? 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;
|
||||
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;
|
||||
}
|
||||
rtn += str.slice(0, i) + replacement;
|
||||
str = str.slice(rend);
|
||||
}
|
||||
return rtn + str;
|
||||
};
|
||||
|
||||
export default emojify;
|
||||
|
||||
export const buildCustomEmojis = (customEmojis, overrideAllowAnimations = false) => {
|
||||
const emojis = [];
|
||||
|
||||
allowAnimations = overrideAllowAnimations;
|
||||
|
||||
customEmojis.forEach(emoji => {
|
||||
const shortcode = emoji.get('shortcode');
|
||||
const url = allowAnimations ? 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;
|
||||
};
|
92
app/javascript/mastodon/features/emoji/emoji_compressed.js
Normal file
92
app/javascript/mastodon/features/emoji/emoji_compressed.js
Normal file
@@ -0,0 +1,92 @@
|
||||
// @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 emojiMartData = require('emoji-mart/dist/data').default;
|
||||
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];
|
||||
const { 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.splice(0, 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.short_names,
|
||||
emojisWithoutShortCodes,
|
||||
]));
|
1
app/javascript/mastodon/features/emoji/emoji_map.json
Normal file
1
app/javascript/mastodon/features/emoji/emoji_map.json
Normal file
File diff suppressed because one or more lines are too long
@@ -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,
|
||||
};
|
@@ -0,0 +1,157 @@
|
||||
// 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, intersect } from './emoji_utils';
|
||||
|
||||
let originalPool = {};
|
||||
let index = {};
|
||||
let emojisList = {};
|
||||
let emoticonsList = {};
|
||||
|
||||
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 addCustomToPool(custom, pool) {
|
||||
custom.forEach((emoji) => {
|
||||
let emojiId = emoji.id || emoji.short_names[0];
|
||||
|
||||
if (emojiId && !pool[emojiId]) {
|
||||
pool[emojiId] = getData(emoji);
|
||||
emojisList[emojiId] = getSanitizedData(emoji);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function search(value, { emojisToShowFilter, maxResults, include, exclude, custom = [] } = {}) {
|
||||
addCustomToPool(custom, originalPool);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
allResults = values.map((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;
|
||||
}).filter(a => a);
|
||||
|
||||
if (allResults.length > 1) {
|
||||
results = intersect.apply(null, allResults);
|
||||
} else if (allResults.length) {
|
||||
results = allResults[0];
|
||||
} else {
|
||||
results = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (results) {
|
||||
if (emojisToShowFilter) {
|
||||
results = results.filter((result) => emojisToShowFilter(data.emojis[result.id].unified));
|
||||
}
|
||||
|
||||
if (results && results.length > maxResults) {
|
||||
results = results.slice(0, maxResults);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export { search };
|
7
app/javascript/mastodon/features/emoji/emoji_picker.js
Normal file
7
app/javascript/mastodon/features/emoji/emoji_picker.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import Picker from 'emoji-mart/dist-es/components/picker';
|
||||
import Emoji from 'emoji-mart/dist-es/components/emoji';
|
||||
|
||||
export {
|
||||
Picker,
|
||||
Emoji,
|
||||
};
|
@@ -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;
|
258
app/javascript/mastodon/features/emoji/emoji_utils.js
Normal file
258
app/javascript/mastodon/features/emoji/emoji_utils.js
Normal 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,
|
||||
};
|
@@ -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;
|
||||
};
|
@@ -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;
|
||||
};
|
@@ -8,7 +8,7 @@ import ColumnHeader from '../../components/column_header';
|
||||
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import ColumnSettingsContainer from './containers/column_settings_container';
|
||||
import Link from 'react-router-dom/Link';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'column.home', defaultMessage: 'Home' },
|
||||
|
@@ -9,61 +9,126 @@ import AccountContainer from '../../../containers/account_container';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import Permalink from '../../../components/permalink';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
|
||||
export default class Notification extends ImmutablePureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
notification: ImmutablePropTypes.map.isRequired,
|
||||
hidden: PropTypes.bool,
|
||||
onMoveUp: PropTypes.func.isRequired,
|
||||
onMoveDown: PropTypes.func.isRequired,
|
||||
onMention: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
handleMoveUp = () => {
|
||||
const { notification, onMoveUp } = this.props;
|
||||
onMoveUp(notification.get('id'));
|
||||
}
|
||||
|
||||
handleMoveDown = () => {
|
||||
const { notification, onMoveDown } = this.props;
|
||||
onMoveDown(notification.get('id'));
|
||||
}
|
||||
|
||||
handleOpen = () => {
|
||||
const { notification } = this.props;
|
||||
|
||||
if (notification.get('status')) {
|
||||
this.context.router.history.push(`/statuses/${notification.get('status')}`);
|
||||
} else {
|
||||
this.handleOpenProfile();
|
||||
}
|
||||
}
|
||||
|
||||
handleOpenProfile = () => {
|
||||
const { notification } = this.props;
|
||||
this.context.router.history.push(`/accounts/${notification.getIn(['account', 'id'])}`);
|
||||
}
|
||||
|
||||
handleMention = e => {
|
||||
e.preventDefault();
|
||||
|
||||
const { notification, onMention } = this.props;
|
||||
onMention(notification.get('account'), this.context.router.history);
|
||||
}
|
||||
|
||||
getHandlers () {
|
||||
return {
|
||||
moveUp: this.handleMoveUp,
|
||||
moveDown: this.handleMoveDown,
|
||||
open: this.handleOpen,
|
||||
openProfile: this.handleOpenProfile,
|
||||
mention: this.handleMention,
|
||||
reply: this.handleMention,
|
||||
};
|
||||
}
|
||||
|
||||
renderFollow (account, link) {
|
||||
return (
|
||||
<div className='notification notification-follow'>
|
||||
<div className='notification__message'>
|
||||
<div className='notification__favourite-icon-wrapper'>
|
||||
<i className='fa fa-fw fa-user-plus' />
|
||||
<HotKeys handlers={this.getHandlers()}>
|
||||
<div className='notification notification-follow focusable' tabIndex='0'>
|
||||
<div className='notification__message'>
|
||||
<div className='notification__favourite-icon-wrapper'>
|
||||
<i className='fa fa-fw fa-user-plus' />
|
||||
</div>
|
||||
|
||||
<FormattedMessage id='notification.follow' defaultMessage='{name} followed you' values={{ name: link }} />
|
||||
</div>
|
||||
|
||||
<FormattedMessage id='notification.follow' defaultMessage='{name} followed you' values={{ name: link }} />
|
||||
<AccountContainer id={account.get('id')} withNote={false} hidden={this.props.hidden} />
|
||||
</div>
|
||||
|
||||
<AccountContainer id={account.get('id')} withNote={false} hidden={this.props.hidden} />
|
||||
</div>
|
||||
</HotKeys>
|
||||
);
|
||||
}
|
||||
|
||||
renderMention (notification) {
|
||||
return <StatusContainer id={notification.get('status')} withDismiss hidden={this.props.hidden} />;
|
||||
return (
|
||||
<StatusContainer
|
||||
id={notification.get('status')}
|
||||
withDismiss
|
||||
hidden={this.props.hidden}
|
||||
onMoveDown={this.handleMoveDown}
|
||||
onMoveUp={this.handleMoveUp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderFavourite (notification, link) {
|
||||
return (
|
||||
<div className='notification notification-favourite'>
|
||||
<div className='notification__message'>
|
||||
<div className='notification__favourite-icon-wrapper'>
|
||||
<i className='fa fa-fw fa-star star-icon' />
|
||||
<HotKeys handlers={this.getHandlers()}>
|
||||
<div className='notification notification-favourite focusable' tabIndex='0'>
|
||||
<div className='notification__message'>
|
||||
<div className='notification__favourite-icon-wrapper'>
|
||||
<i className='fa fa-fw fa-star star-icon' />
|
||||
</div>
|
||||
<FormattedMessage id='notification.favourite' defaultMessage='{name} favourited your status' values={{ name: link }} />
|
||||
</div>
|
||||
<FormattedMessage id='notification.favourite' defaultMessage='{name} favourited your status' values={{ name: link }} />
|
||||
</div>
|
||||
|
||||
<StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={!!this.props.hidden} />
|
||||
</div>
|
||||
<StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={!!this.props.hidden} />
|
||||
</div>
|
||||
</HotKeys>
|
||||
);
|
||||
}
|
||||
|
||||
renderReblog (notification, link) {
|
||||
return (
|
||||
<div className='notification notification-reblog'>
|
||||
<div className='notification__message'>
|
||||
<div className='notification__favourite-icon-wrapper'>
|
||||
<i className='fa fa-fw fa-retweet' />
|
||||
<HotKeys handlers={this.getHandlers()}>
|
||||
<div className='notification notification-reblog focusable' tabIndex='0'>
|
||||
<div className='notification__message'>
|
||||
<div className='notification__favourite-icon-wrapper'>
|
||||
<i className='fa fa-fw fa-retweet' />
|
||||
</div>
|
||||
<FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} />
|
||||
</div>
|
||||
<FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} />
|
||||
</div>
|
||||
|
||||
<StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={this.props.hidden} />
|
||||
</div>
|
||||
<StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={this.props.hidden} />
|
||||
</div>
|
||||
</HotKeys>
|
||||
);
|
||||
}
|
||||
|
||||
|
@@ -4,6 +4,7 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { makeGetNotification } from '../../../selectors';
|
||||
import Notification from '../components/notification';
|
||||
import { mentionCompose } from '../../../actions/compose';
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getNotification = makeGetNotification();
|
||||
@@ -15,4 +16,10 @@ const makeMapStateToProps = () => {
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
export default connect(makeMapStateToProps)(Notification);
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onMention: (account, router) => {
|
||||
dispatch(mentionCompose(account, router));
|
||||
},
|
||||
});
|
||||
|
||||
export default connect(makeMapStateToProps, mapDispatchToProps)(Notification);
|
||||
|
@@ -103,6 +103,24 @@ export default class Notifications extends React.PureComponent {
|
||||
this.column = c;
|
||||
}
|
||||
|
||||
handleMoveUp = id => {
|
||||
const elementIndex = this.props.notifications.findIndex(item => item.get('id') === id) - 1;
|
||||
this._selectChild(elementIndex);
|
||||
}
|
||||
|
||||
handleMoveDown = id => {
|
||||
const elementIndex = this.props.notifications.findIndex(item => item.get('id') === id) + 1;
|
||||
this._selectChild(elementIndex);
|
||||
}
|
||||
|
||||
_selectChild (index) {
|
||||
const element = this.column.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
|
||||
|
||||
if (element) {
|
||||
element.focus();
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore } = this.props;
|
||||
const pinned = !!columnId;
|
||||
@@ -113,7 +131,15 @@ export default class Notifications extends React.PureComponent {
|
||||
if (isLoading && this.scrollableContent) {
|
||||
scrollableContent = this.scrollableContent;
|
||||
} else if (notifications.size > 0 || hasMore) {
|
||||
scrollableContent = notifications.map((item) => <NotificationContainer key={item.get('id')} notification={item} accountId={item.get('account')} />);
|
||||
scrollableContent = notifications.map((item) => (
|
||||
<NotificationContainer
|
||||
key={item.get('id')}
|
||||
notification={item}
|
||||
accountId={item.get('account')}
|
||||
onMoveUp={this.handleMoveUp}
|
||||
onMoveDown={this.handleMoveDown}
|
||||
/>
|
||||
));
|
||||
} else {
|
||||
scrollableContent = null;
|
||||
}
|
||||
|
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import StatusListContainer from '../../ui/containers/status_list_container';
|
||||
import {
|
||||
refreshHashtagTimeline,
|
||||
expandHashtagTimeline,
|
||||
} from '../../../actions/timelines';
|
||||
import Column from '../../../components/column';
|
||||
import ColumnHeader from '../../../components/column_header';
|
||||
|
||||
@connect()
|
||||
export default class HashtagTimeline extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
hashtag: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
handleHeaderClick = () => {
|
||||
this.column.scrollTop();
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
this.column = c;
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
const { dispatch, hashtag } = this.props;
|
||||
|
||||
dispatch(refreshHashtagTimeline(hashtag));
|
||||
|
||||
this.polling = setInterval(() => {
|
||||
dispatch(refreshHashtagTimeline(hashtag));
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
if (typeof this.polling !== 'undefined') {
|
||||
clearInterval(this.polling);
|
||||
this.polling = null;
|
||||
}
|
||||
}
|
||||
|
||||
handleLoadMore = () => {
|
||||
this.props.dispatch(expandHashtagTimeline(this.props.hashtag));
|
||||
}
|
||||
|
||||
render () {
|
||||
const { hashtag } = this.props;
|
||||
|
||||
return (
|
||||
<Column ref={this.setRef}>
|
||||
<ColumnHeader
|
||||
icon='hashtag'
|
||||
title={hashtag}
|
||||
onClick={this.handleHeaderClick}
|
||||
/>
|
||||
|
||||
<StatusListContainer
|
||||
trackScroll={false}
|
||||
scrollKey='standalone_hashtag_timeline'
|
||||
timelineId={`hashtag:${hashtag}`}
|
||||
loadMore={this.handleLoadMore}
|
||||
/>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@@ -30,6 +30,10 @@ export default class Card extends React.PureComponent {
|
||||
maxDescription: 50,
|
||||
};
|
||||
|
||||
state = {
|
||||
width: 0,
|
||||
};
|
||||
|
||||
renderLink () {
|
||||
const { card, maxDescription } = this.props;
|
||||
|
||||
@@ -75,14 +79,25 @@ export default class Card extends React.PureComponent {
|
||||
);
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
if (c) {
|
||||
this.setState({ width: c.offsetWidth });
|
||||
}
|
||||
}
|
||||
|
||||
renderVideo () {
|
||||
const { card } = this.props;
|
||||
const content = { __html: card.get('html') };
|
||||
const { card } = this.props;
|
||||
const content = { __html: card.get('html') };
|
||||
const { width } = this.state;
|
||||
const ratio = card.get('width') / card.get('height');
|
||||
const height = card.get('width') > card.get('height') ? (width / ratio) : (width * ratio);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={this.setRef}
|
||||
className='status-card-video'
|
||||
dangerouslySetInnerHTML={content}
|
||||
style={{ height }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@@ -7,7 +7,7 @@ import StatusContent from '../../../../glitch/components/status/content';
|
||||
import StatusGallery from '../../../../glitch/components/status/gallery';
|
||||
import StatusPlayer from '../../../../glitch/components/status/player';
|
||||
import AttachmentList from '../../../components/attachment_list';
|
||||
import Link from 'react-router-dom/Link';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { FormattedDate, FormattedNumber } from 'react-intl';
|
||||
import CardContainer from '../containers/card_container';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
@@ -28,6 +28,7 @@ import StatusContainer from '../../../glitch/components/status/container';
|
||||
import { openModal } from '../../actions/modal';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
|
||||
const messages = defineMessages({
|
||||
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
|
||||
@@ -153,8 +154,100 @@ export default class Status extends ImmutablePureComponent {
|
||||
this.props.dispatch(openModal('EMBED', { url: status.get('url') }));
|
||||
}
|
||||
|
||||
handleHotkeyMoveUp = () => {
|
||||
this.handleMoveUp(this.props.status.get('id'));
|
||||
}
|
||||
|
||||
handleHotkeyMoveDown = () => {
|
||||
this.handleMoveDown(this.props.status.get('id'));
|
||||
}
|
||||
|
||||
handleHotkeyReply = e => {
|
||||
e.preventDefault();
|
||||
this.handleReplyClick(this.props.status);
|
||||
}
|
||||
|
||||
handleHotkeyFavourite = () => {
|
||||
this.handleFavouriteClick(this.props.status);
|
||||
}
|
||||
|
||||
handleHotkeyBoost = () => {
|
||||
this.handleReblogClick(this.props.status);
|
||||
}
|
||||
|
||||
handleHotkeyMention = e => {
|
||||
e.preventDefault();
|
||||
this.handleMentionClick(this.props.status);
|
||||
}
|
||||
|
||||
handleHotkeyOpenProfile = () => {
|
||||
this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
|
||||
}
|
||||
|
||||
handleMoveUp = id => {
|
||||
const { status, ancestorsIds, descendantsIds } = this.props;
|
||||
|
||||
if (id === status.get('id')) {
|
||||
this._selectChild(ancestorsIds.size - 1);
|
||||
} else {
|
||||
let index = ancestorsIds.indexOf(id);
|
||||
|
||||
if (index === -1) {
|
||||
index = descendantsIds.indexOf(id);
|
||||
this._selectChild(ancestorsIds.size + index);
|
||||
} else {
|
||||
this._selectChild(index - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleMoveDown = id => {
|
||||
const { status, ancestorsIds, descendantsIds } = this.props;
|
||||
|
||||
if (id === status.get('id')) {
|
||||
this._selectChild(ancestorsIds.size + 1);
|
||||
} else {
|
||||
let index = ancestorsIds.indexOf(id);
|
||||
|
||||
if (index === -1) {
|
||||
index = descendantsIds.indexOf(id);
|
||||
this._selectChild(ancestorsIds.size + index + 2);
|
||||
} else {
|
||||
this._selectChild(index + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_selectChild (index) {
|
||||
const element = this.node.querySelectorAll('.focusable')[index];
|
||||
|
||||
if (element) {
|
||||
element.focus();
|
||||
}
|
||||
}
|
||||
|
||||
renderChildren (list) {
|
||||
return list.map(id => <StatusContainer key={id} id={id} />);
|
||||
return list.map(id => (
|
||||
<StatusContainer
|
||||
key={id}
|
||||
id={id}
|
||||
onMoveUp={this.handleMoveUp}
|
||||
onMoveDown={this.handleMoveDown}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
componentDidUpdate () {
|
||||
const { status, ancestorsIds } = this.props;
|
||||
|
||||
if (status && ancestorsIds && ancestorsIds.size > 0) {
|
||||
const element = this.node.querySelectorAll('.focusable')[ancestorsIds.size];
|
||||
element.scrollIntoView();
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
@@ -178,35 +271,49 @@ export default class Status extends ImmutablePureComponent {
|
||||
descendants = <div>{this.renderChildren(descendantsIds)}</div>;
|
||||
}
|
||||
|
||||
const handlers = {
|
||||
moveUp: this.handleHotkeyMoveUp,
|
||||
moveDown: this.handleHotkeyMoveDown,
|
||||
reply: this.handleHotkeyReply,
|
||||
favourite: this.handleHotkeyFavourite,
|
||||
boost: this.handleHotkeyBoost,
|
||||
mention: this.handleHotkeyMention,
|
||||
openProfile: this.handleHotkeyOpenProfile,
|
||||
};
|
||||
|
||||
return (
|
||||
<Column>
|
||||
<ColumnBackButton />
|
||||
|
||||
<ScrollContainer scrollKey='thread'>
|
||||
<div className='scrollable detailed-status__wrapper'>
|
||||
<div className='scrollable detailed-status__wrapper' ref={this.setRef}>
|
||||
{ancestors}
|
||||
|
||||
<DetailedStatus
|
||||
status={status}
|
||||
settings={settings}
|
||||
autoPlayGif={autoPlayGif}
|
||||
me={me}
|
||||
onOpenVideo={this.handleOpenVideo}
|
||||
onOpenMedia={this.handleOpenMedia}
|
||||
/>
|
||||
<HotKeys handlers={handlers}>
|
||||
<div className='focusable' tabIndex='0'>
|
||||
<DetailedStatus
|
||||
status={status}
|
||||
settings={settings}
|
||||
autoPlayGif={autoPlayGif}
|
||||
me={me}
|
||||
onOpenVideo={this.handleOpenVideo}
|
||||
onOpenMedia={this.handleOpenMedia}
|
||||
/>
|
||||
|
||||
<ActionBar
|
||||
status={status}
|
||||
me={me}
|
||||
onReply={this.handleReplyClick}
|
||||
onFavourite={this.handleFavouriteClick}
|
||||
onReblog={this.handleReblogClick}
|
||||
onDelete={this.handleDeleteClick}
|
||||
onMention={this.handleMentionClick}
|
||||
onReport={this.handleReport}
|
||||
onPin={this.handlePin}
|
||||
onEmbed={this.handleEmbed}
|
||||
/>
|
||||
<ActionBar
|
||||
status={status}
|
||||
me={me}
|
||||
onReply={this.handleReplyClick}
|
||||
onFavourite={this.handleFavouriteClick}
|
||||
onReblog={this.handleReblogClick}
|
||||
onDelete={this.handleDeleteClick}
|
||||
onMention={this.handleMentionClick}
|
||||
onReport={this.handleReport}
|
||||
onPin={this.handlePin}
|
||||
onEmbed={this.handleEmbed}
|
||||
/>
|
||||
</div>
|
||||
</HotKeys>
|
||||
|
||||
{descendants}
|
||||
</div>
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Link from 'react-router-dom/Link';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const ColumnLink = ({ icon, text, to, onClick, href, method }) => {
|
||||
if (href) {
|
||||
|
@@ -29,7 +29,7 @@ export default class MediaModal extends ImmutablePureComponent {
|
||||
};
|
||||
|
||||
handleSwipe = (index) => {
|
||||
this.setState({ index: (index) % this.props.media.size });
|
||||
this.setState({ index: index % this.props.media.size });
|
||||
}
|
||||
|
||||
handleNextClick = () => {
|
||||
@@ -40,6 +40,11 @@ export default class MediaModal extends ImmutablePureComponent {
|
||||
this.setState({ index: (this.props.media.size + this.getIndex() - 1) % this.props.media.size });
|
||||
}
|
||||
|
||||
handleChangeIndex = (e) => {
|
||||
const index = Number(e.currentTarget.getAttribute('data-index'));
|
||||
this.setState({ index: index % this.props.media.size });
|
||||
}
|
||||
|
||||
handleKeyUp = (e) => {
|
||||
switch(e.key) {
|
||||
case 'ArrowLeft':
|
||||
@@ -67,33 +72,51 @@ export default class MediaModal extends ImmutablePureComponent {
|
||||
const { media, intl, onClose } = this.props;
|
||||
|
||||
const index = this.getIndex();
|
||||
let pagination = [];
|
||||
|
||||
const leftNav = media.size > 1 && <button tabIndex='0' className='modal-container__nav modal-container__nav--left' onClick={this.handlePrevClick} aria-label={intl.formatMessage(messages.previous)}><i className='fa fa-fw fa-chevron-left' /></button>;
|
||||
const rightNav = media.size > 1 && <button tabIndex='0' className='modal-container__nav modal-container__nav--right' onClick={this.handleNextClick} aria-label={intl.formatMessage(messages.next)}><i className='fa fa-fw fa-chevron-right' /></button>;
|
||||
|
||||
if (media.size > 1) {
|
||||
pagination = media.map((item, i) => {
|
||||
const classes = ['media-modal__button'];
|
||||
if (i === index) {
|
||||
classes.push('media-modal__button--active');
|
||||
}
|
||||
return (<li className='media-modal__page-dot' key={i}><button tabIndex='0' className={classes.join(' ')} onClick={this.handleChangeIndex} data-index={i}>{i + 1}</button></li>);
|
||||
});
|
||||
}
|
||||
|
||||
const content = media.map((image) => {
|
||||
const width = image.getIn(['meta', 'original', 'width']) || null;
|
||||
const height = image.getIn(['meta', 'original', 'height']) || null;
|
||||
|
||||
if (image.get('type') === 'image') {
|
||||
return <ImageLoader previewSrc={image.get('preview_url')} src={image.get('url')} width={width} height={height} key={image.get('preview_url')} />;
|
||||
return <ImageLoader previewSrc={image.get('preview_url')} src={image.get('url')} width={width} height={height} alt={image.get('description')} key={image.get('preview_url')} />;
|
||||
} else if (image.get('type') === 'gifv') {
|
||||
return <ExtendedVideoPlayer src={image.get('url')} muted controls={false} width={width} height={height} key={image.get('preview_url')} />;
|
||||
return <ExtendedVideoPlayer src={image.get('url')} muted controls={false} width={width} height={height} key={image.get('preview_url')} alt={image.get('description')} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}).toArray();
|
||||
|
||||
const containerStyle = {
|
||||
alignItems: 'center', // center vertically
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal media-modal'>
|
||||
{leftNav}
|
||||
|
||||
<div className='media-modal__content'>
|
||||
<IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} />
|
||||
<ReactSwipeableViews onChangeIndex={this.handleSwipe} index={index} animateHeight>
|
||||
<ReactSwipeableViews containerStyle={containerStyle} onChangeIndex={this.handleSwipe} index={index}>
|
||||
{content}
|
||||
</ReactSwipeableViews>
|
||||
</div>
|
||||
<ul className='media-modal__pagination'>
|
||||
{pagination}
|
||||
</ul>
|
||||
|
||||
{rightNav}
|
||||
</div>
|
||||
|
@@ -1,7 +1,5 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import TransitionMotion from 'react-motion/lib/TransitionMotion';
|
||||
import spring from 'react-motion/lib/spring';
|
||||
import BundleContainer from '../containers/bundle_container';
|
||||
import BundleModalError from './bundle_modal_error';
|
||||
import ModalLoading from './modal_loading';
|
||||
@@ -39,6 +37,10 @@ export default class ModalRoot extends React.PureComponent {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
revealed: false,
|
||||
};
|
||||
|
||||
handleKeyUp = (e) => {
|
||||
if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27)
|
||||
&& !!this.props.type) {
|
||||
@@ -55,6 +57,8 @@ export default class ModalRoot extends React.PureComponent {
|
||||
this.activeElement = document.activeElement;
|
||||
|
||||
this.getSiblings().forEach(sibling => sibling.setAttribute('inert', true));
|
||||
} else if (!nextProps.type) {
|
||||
this.setState({ revealed: false });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +68,11 @@ export default class ModalRoot extends React.PureComponent {
|
||||
this.activeElement.focus();
|
||||
this.activeElement = null;
|
||||
}
|
||||
if (this.props.type) {
|
||||
requestAnimationFrame(() => {
|
||||
this.setState({ revealed: true });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
@@ -78,14 +87,6 @@ export default class ModalRoot extends React.PureComponent {
|
||||
this.node = ref;
|
||||
}
|
||||
|
||||
willEnter () {
|
||||
return { opacity: 0, scale: 0.98 };
|
||||
}
|
||||
|
||||
willLeave () {
|
||||
return { opacity: spring(0), scale: spring(0.98) };
|
||||
}
|
||||
|
||||
renderLoading = modalId => () => {
|
||||
return ['MEDIA', 'VIDEO', 'BOOST', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? <ModalLoading /> : null;
|
||||
}
|
||||
@@ -98,38 +99,30 @@ export default class ModalRoot extends React.PureComponent {
|
||||
|
||||
render () {
|
||||
const { type, props, onClose } = this.props;
|
||||
const { revealed } = this.state;
|
||||
const visible = !!type;
|
||||
const items = [];
|
||||
|
||||
if (visible) {
|
||||
items.push({
|
||||
key: type,
|
||||
data: { type, props },
|
||||
style: { opacity: spring(1), scale: spring(1, { stiffness: 120, damping: 14 }) },
|
||||
});
|
||||
if (!visible) {
|
||||
return (
|
||||
<div className='modal-root' ref={this.setRef} style={{ opacity: 0 }} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TransitionMotion
|
||||
styles={items}
|
||||
willEnter={this.willEnter}
|
||||
willLeave={this.willLeave}
|
||||
>
|
||||
{interpolatedStyles =>
|
||||
<div className='modal-root' ref={this.setRef}>
|
||||
{interpolatedStyles.map(({ key, data: { type, props }, style }) => (
|
||||
<div key={key} style={{ pointerEvents: visible ? 'auto' : 'none' }}>
|
||||
<div role='presentation' className='modal-root__overlay' style={{ opacity: style.opacity }} onClick={onClose} />
|
||||
<div role='dialog' className='modal-root__container' style={{ opacity: style.opacity, transform: `translateZ(0px) scale(${style.scale})` }}>
|
||||
<BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}>
|
||||
{(SpecificComponent) => <SpecificComponent {...props} onClose={onClose} />}
|
||||
</BundleContainer>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className='modal-root' ref={this.setRef} style={{ opacity: revealed ? 1 : 0 }}>
|
||||
<div style={{ pointerEvents: visible ? 'auto' : 'none' }}>
|
||||
<div role='presentation' className='modal-root__overlay' onClick={onClose} />
|
||||
<div role='dialog' className='modal-root__container'>
|
||||
{
|
||||
visible ?
|
||||
(<BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}>
|
||||
{(SpecificComponent) => <SpecificComponent {...props} onClose={onClose} />}
|
||||
</BundleContainer>) :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</TransitionMotion>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import NavLink from 'react-router-dom/NavLink';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { FormattedMessage, injectIntl } from 'react-intl';
|
||||
import { debounce } from 'lodash';
|
||||
import { isUserTouching } from '../../../is_mobile';
|
||||
|
@@ -23,6 +23,7 @@ export default class VideoModal extends ImmutablePureComponent {
|
||||
src={media.get('url')}
|
||||
startTime={time}
|
||||
onCloseVideo={onClose}
|
||||
description={media.get('description')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -8,7 +8,7 @@ import { connect } from 'react-redux';
|
||||
import { Redirect, withRouter } from 'react-router-dom';
|
||||
import { isMobile } from '../../is_mobile';
|
||||
import { debounce } from 'lodash';
|
||||
import { uploadCompose } from '../../actions/compose';
|
||||
import { uploadCompose, resetCompose } from '../../actions/compose';
|
||||
import { refreshHomeTimeline } from '../../actions/timelines';
|
||||
import { refreshNotifications } from '../../actions/notifications';
|
||||
import { clearHeight } from '../../actions/height_cache';
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
Mutes,
|
||||
PinnedStatuses,
|
||||
} from './util/async-components';
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
|
||||
// Dummy import, to make sure that <Status /> ends up in the application bundle.
|
||||
// Without this it ends up in ~8 very commonly used bundles.
|
||||
@@ -48,12 +49,39 @@ const mapStateToProps = state => ({
|
||||
layout: state.getIn(['local_settings', 'layout']),
|
||||
isWide: state.getIn(['local_settings', 'stretch']),
|
||||
navbarUnder: state.getIn(['local_settings', 'navbar_under']),
|
||||
me: state.getIn(['meta', 'me']),
|
||||
isComposing: state.getIn(['compose', 'is_composing']),
|
||||
});
|
||||
|
||||
const keyMap = {
|
||||
new: 'n',
|
||||
search: 's',
|
||||
forceNew: 'option+n',
|
||||
focusColumn: ['1', '2', '3', '4', '5', '6', '7', '8', '9'],
|
||||
reply: 'r',
|
||||
favourite: 'f',
|
||||
boost: 'b',
|
||||
mention: 'm',
|
||||
open: ['enter', 'o'],
|
||||
openProfile: 'p',
|
||||
moveDown: ['down', 'j'],
|
||||
moveUp: ['up', 'k'],
|
||||
back: 'backspace',
|
||||
goToHome: 'g h',
|
||||
goToNotifications: 'g n',
|
||||
goToLocal: 'g l',
|
||||
goToFederated: 'g t',
|
||||
goToStart: 'g s',
|
||||
goToFavourites: 'g f',
|
||||
goToPinned: 'g p',
|
||||
goToProfile: 'g u',
|
||||
goToBlocked: 'g b',
|
||||
goToMuted: 'g m',
|
||||
};
|
||||
|
||||
@connect(mapStateToProps)
|
||||
@withRouter
|
||||
export default class UI extends React.PureComponent {
|
||||
export default class UI extends React.Component {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object.isRequired,
|
||||
@@ -67,6 +95,7 @@ export default class UI extends React.PureComponent {
|
||||
systemFontUi: PropTypes.bool,
|
||||
navbarUnder: PropTypes.bool,
|
||||
isComposing: PropTypes.bool,
|
||||
me: PropTypes.string,
|
||||
location: PropTypes.object,
|
||||
};
|
||||
|
||||
@@ -164,6 +193,12 @@ export default class UI extends React.PureComponent {
|
||||
this.props.dispatch(refreshNotifications());
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
this.hotkeys.__mousetrap__.stopCallback = (e, element) => {
|
||||
return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName);
|
||||
};
|
||||
}
|
||||
|
||||
shouldComponentUpdate (nextProps) {
|
||||
if (nextProps.isComposing !== this.props.isComposing) {
|
||||
// Avoid expensive update just to toggle a class
|
||||
@@ -201,8 +236,94 @@ export default class UI extends React.PureComponent {
|
||||
this.columnsAreaNode = c.getWrappedInstance().getWrappedInstance();
|
||||
}
|
||||
|
||||
setOverlayRef = c => {
|
||||
this.overlay = c;
|
||||
handleHotkeyNew = e => {
|
||||
e.preventDefault();
|
||||
|
||||
const element = this.node.querySelector('.compose-form__autosuggest-wrapper textarea');
|
||||
|
||||
if (element) {
|
||||
element.focus();
|
||||
}
|
||||
}
|
||||
|
||||
handleHotkeySearch = e => {
|
||||
e.preventDefault();
|
||||
|
||||
const element = this.node.querySelector('.search__input');
|
||||
|
||||
if (element) {
|
||||
element.focus();
|
||||
}
|
||||
}
|
||||
|
||||
handleHotkeyForceNew = e => {
|
||||
this.handleHotkeyNew(e);
|
||||
this.props.dispatch(resetCompose());
|
||||
}
|
||||
|
||||
handleHotkeyFocusColumn = e => {
|
||||
const index = (e.key * 1) + 1; // First child is drawer, skip that
|
||||
const column = this.node.querySelector(`.column:nth-child(${index})`);
|
||||
|
||||
if (column) {
|
||||
const status = column.querySelector('.focusable');
|
||||
|
||||
if (status) {
|
||||
status.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleHotkeyBack = () => {
|
||||
if (window.history && window.history.length === 1) {
|
||||
this.context.router.history.push('/');
|
||||
} else {
|
||||
this.context.router.history.goBack();
|
||||
}
|
||||
}
|
||||
|
||||
setHotkeysRef = c => {
|
||||
this.hotkeys = c;
|
||||
}
|
||||
|
||||
handleHotkeyGoToHome = () => {
|
||||
this.context.router.history.push('/timelines/home');
|
||||
}
|
||||
|
||||
handleHotkeyGoToNotifications = () => {
|
||||
this.context.router.history.push('/notifications');
|
||||
}
|
||||
|
||||
handleHotkeyGoToLocal = () => {
|
||||
this.context.router.history.push('/timelines/public/local');
|
||||
}
|
||||
|
||||
handleHotkeyGoToFederated = () => {
|
||||
this.context.router.history.push('/timelines/public');
|
||||
}
|
||||
|
||||
handleHotkeyGoToStart = () => {
|
||||
this.context.router.history.push('/getting-started');
|
||||
}
|
||||
|
||||
handleHotkeyGoToFavourites = () => {
|
||||
this.context.router.history.push('/favourites');
|
||||
}
|
||||
|
||||
handleHotkeyGoToPinned = () => {
|
||||
this.context.router.history.push('/pinned');
|
||||
}
|
||||
|
||||
handleHotkeyGoToProfile = () => {
|
||||
this.context.router.history.push(`/accounts/${this.props.me}`);
|
||||
}
|
||||
|
||||
handleHotkeyGoToBlocked = () => {
|
||||
this.context.router.history.push('/blocks');
|
||||
}
|
||||
|
||||
handleHotkeyGoToMuted = () => {
|
||||
this.context.router.history.push('/mutes');
|
||||
}
|
||||
|
||||
render () {
|
||||
@@ -226,45 +347,67 @@ export default class UI extends React.PureComponent {
|
||||
'navbar-under': navbarUnder,
|
||||
});
|
||||
|
||||
const handlers = {
|
||||
new: this.handleHotkeyNew,
|
||||
search: this.handleHotkeySearch,
|
||||
forceNew: this.handleHotkeyForceNew,
|
||||
focusColumn: this.handleHotkeyFocusColumn,
|
||||
back: this.handleHotkeyBack,
|
||||
goToHome: this.handleHotkeyGoToHome,
|
||||
goToNotifications: this.handleHotkeyGoToNotifications,
|
||||
goToLocal: this.handleHotkeyGoToLocal,
|
||||
goToFederated: this.handleHotkeyGoToFederated,
|
||||
goToStart: this.handleHotkeyGoToStart,
|
||||
goToFavourites: this.handleHotkeyGoToFavourites,
|
||||
goToPinned: this.handleHotkeyGoToPinned,
|
||||
goToProfile: this.handleHotkeyGoToProfile,
|
||||
goToBlocked: this.handleHotkeyGoToBlocked,
|
||||
goToMuted: this.handleHotkeyGoToMuted,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className} ref={this.setRef}>
|
||||
{navbarUnder ? null : (<TabsBar />)}
|
||||
<ColumnsAreaContainer ref={this.setColumnsAreaRef} singleColumn={isMobile(width, layout)}>
|
||||
<WrappedSwitch>
|
||||
<Redirect from='/' to='/getting-started' exact />
|
||||
<WrappedRoute path='/getting-started' component={GettingStarted} content={children} />
|
||||
<WrappedRoute path='/timelines/home' component={HomeTimeline} content={children} />
|
||||
<WrappedRoute path='/timelines/public' exact component={PublicTimeline} content={children} />
|
||||
<WrappedRoute path='/timelines/public/local' component={CommunityTimeline} content={children} />
|
||||
<WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} />
|
||||
<HotKeys keyMap={keyMap} handlers={handlers} ref={this.setHotkeysRef}>
|
||||
<div className={className} ref={this.setRef}>
|
||||
{navbarUnder ? null : (<TabsBar />)}
|
||||
|
||||
<WrappedRoute path='/notifications' component={Notifications} content={children} />
|
||||
<WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
|
||||
<WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
|
||||
<ColumnsAreaContainer ref={this.setColumnsAreaRef} singleColumn={isMobile(width, layout)}>
|
||||
<WrappedSwitch>
|
||||
<Redirect from='/' to='/getting-started' exact />
|
||||
<WrappedRoute path='/getting-started' component={GettingStarted} content={children} />
|
||||
<WrappedRoute path='/timelines/home' component={HomeTimeline} content={children} />
|
||||
<WrappedRoute path='/timelines/public' exact component={PublicTimeline} content={children} />
|
||||
<WrappedRoute path='/timelines/public/local' component={CommunityTimeline} content={children} />
|
||||
<WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} />
|
||||
|
||||
<WrappedRoute path='/statuses/new' component={Compose} content={children} />
|
||||
<WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} />
|
||||
<WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} />
|
||||
<WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} />
|
||||
<WrappedRoute path='/notifications' component={Notifications} content={children} />
|
||||
<WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
|
||||
<WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
|
||||
|
||||
<WrappedRoute path='/accounts/:accountId' exact component={AccountTimeline} content={children} />
|
||||
<WrappedRoute path='/accounts/:accountId/followers' component={Followers} content={children} />
|
||||
<WrappedRoute path='/accounts/:accountId/following' component={Following} content={children} />
|
||||
<WrappedRoute path='/accounts/:accountId/media' component={AccountGallery} content={children} />
|
||||
<WrappedRoute path='/statuses/new' component={Compose} content={children} />
|
||||
<WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} />
|
||||
<WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} />
|
||||
<WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} />
|
||||
|
||||
<WrappedRoute path='/follow_requests' component={FollowRequests} content={children} />
|
||||
<WrappedRoute path='/blocks' component={Blocks} content={children} />
|
||||
<WrappedRoute path='/mutes' component={Mutes} content={children} />
|
||||
<WrappedRoute path='/accounts/:accountId' exact component={AccountTimeline} content={children} />
|
||||
<WrappedRoute path='/accounts/:accountId/followers' component={Followers} content={children} />
|
||||
<WrappedRoute path='/accounts/:accountId/following' component={Following} content={children} />
|
||||
<WrappedRoute path='/accounts/:accountId/media' component={AccountGallery} content={children} />
|
||||
|
||||
<WrappedRoute component={GenericNotFound} content={children} />
|
||||
</WrappedSwitch>
|
||||
</ColumnsAreaContainer>
|
||||
<NotificationsContainer />
|
||||
{navbarUnder ? (<TabsBar />) : null}
|
||||
<LoadingBarContainer className='loading-bar' />
|
||||
<ModalContainer />
|
||||
<UploadArea active={draggingOver} onClose={this.closeUploadModal} />
|
||||
</div>
|
||||
<WrappedRoute path='/follow_requests' component={FollowRequests} content={children} />
|
||||
<WrappedRoute path='/blocks' component={Blocks} content={children} />
|
||||
<WrappedRoute path='/mutes' component={Mutes} content={children} />
|
||||
|
||||
<WrappedRoute component={GenericNotFound} content={children} />
|
||||
</WrappedSwitch>
|
||||
</ColumnsAreaContainer>
|
||||
|
||||
<NotificationsContainer />
|
||||
{navbarUnder ? (<TabsBar />) : null}
|
||||
<LoadingBarContainer className='loading-bar' />
|
||||
<ModalContainer />
|
||||
<UploadArea active={draggingOver} onClose={this.closeUploadModal} />
|
||||
</div>
|
||||
</HotKeys>
|
||||
);
|
||||
}
|
||||
|
||||
|
@@ -1,3 +1,7 @@
|
||||
export function EmojiPicker () {
|
||||
return import(/* webpackChunkName: "emoji_picker" */'../../emoji/emoji_picker');
|
||||
}
|
||||
|
||||
export function Compose () {
|
||||
return import(/* webpackChunkName: "features/compose" */'../../compose');
|
||||
}
|
||||
@@ -101,10 +105,6 @@ export function MediaGallery () {
|
||||
return import(/* webpackChunkName: "status/media_gallery" */'../../../components/media_gallery');
|
||||
}
|
||||
|
||||
export function VideoPlayer () {
|
||||
return import(/* webpackChunkName: "status/video_player" */'../../../components/video_player');
|
||||
}
|
||||
|
||||
export function Video () {
|
||||
return import(/* webpackChunkName: "features/video" */'../../video');
|
||||
}
|
||||
|
46
app/javascript/mastodon/features/ui/util/fullscreen.js
Normal file
46
app/javascript/mastodon/features/ui/util/fullscreen.js
Normal file
@@ -0,0 +1,46 @@
|
||||
// APIs for normalizing fullscreen operations. Note that Edge uses
|
||||
// the WebKit-prefixed APIs currently (as of Edge 16).
|
||||
|
||||
export const isFullscreen = () => document.fullscreenElement ||
|
||||
document.webkitFullscreenElement ||
|
||||
document.mozFullScreenElement;
|
||||
|
||||
export const exitFullscreen = () => {
|
||||
if (document.exitFullscreen) {
|
||||
document.exitFullscreen();
|
||||
} else if (document.webkitExitFullscreen) {
|
||||
document.webkitExitFullscreen();
|
||||
} else if (document.mozCancelFullScreen) {
|
||||
document.mozCancelFullScreen();
|
||||
}
|
||||
};
|
||||
|
||||
export const requestFullscreen = el => {
|
||||
if (el.requestFullscreen) {
|
||||
el.requestFullscreen();
|
||||
} else if (el.webkitRequestFullscreen) {
|
||||
el.webkitRequestFullscreen();
|
||||
} else if (el.mozRequestFullScreen) {
|
||||
el.mozRequestFullScreen();
|
||||
}
|
||||
};
|
||||
|
||||
export const attachFullscreenListener = (listener) => {
|
||||
if ('onfullscreenchange' in document) {
|
||||
document.addEventListener('fullscreenchange', listener);
|
||||
} else if ('onwebkitfullscreenchange' in document) {
|
||||
document.addEventListener('webkitfullscreenchange', listener);
|
||||
} else if ('onmozfullscreenchange' in document) {
|
||||
document.addEventListener('mozfullscreenchange', listener);
|
||||
}
|
||||
};
|
||||
|
||||
export const detachFullscreenListener = (listener) => {
|
||||
if ('onfullscreenchange' in document) {
|
||||
document.removeEventListener('fullscreenchange', listener);
|
||||
} else if ('onwebkitfullscreenchange' in document) {
|
||||
document.removeEventListener('webkitfullscreenchange', listener);
|
||||
} else if ('onmozfullscreenchange' in document) {
|
||||
document.removeEventListener('mozfullscreenchange', listener);
|
||||
}
|
||||
};
|
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Switch from 'react-router-dom/Switch';
|
||||
import Route from 'react-router-dom/Route';
|
||||
import { Switch, Route } from 'react-router-dom';
|
||||
|
||||
import ColumnLoading from '../components/column_loading';
|
||||
import BundleColumnError from '../components/bundle_column_error';
|
||||
|
@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { throttle } from 'lodash';
|
||||
import classNames from 'classnames';
|
||||
import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen';
|
||||
|
||||
const messages = defineMessages({
|
||||
play: { id: 'video.play', defaultMessage: 'Play' },
|
||||
@@ -69,41 +70,13 @@ const getPointerPosition = (el, event) => {
|
||||
return position;
|
||||
};
|
||||
|
||||
const isFullscreen = () => document.fullscreenElement ||
|
||||
document.webkitFullscreenElement ||
|
||||
document.mozFullScreenElement ||
|
||||
document.msFullscreenElement;
|
||||
|
||||
const exitFullscreen = () => {
|
||||
if (document.exitFullscreen) {
|
||||
document.exitFullscreen();
|
||||
} else if (document.webkitExitFullscreen) {
|
||||
document.webkitExitFullscreen();
|
||||
} else if (document.mozCancelFullScreen) {
|
||||
document.mozCancelFullScreen();
|
||||
} else if (document.msExitFullscreen) {
|
||||
document.msExitFullscreen();
|
||||
}
|
||||
};
|
||||
|
||||
const requestFullscreen = el => {
|
||||
if (el.requestFullscreen) {
|
||||
el.requestFullscreen();
|
||||
} else if (el.webkitRequestFullscreen) {
|
||||
el.webkitRequestFullscreen();
|
||||
} else if (el.mozRequestFullScreen) {
|
||||
el.mozRequestFullScreen();
|
||||
} else if (el.msRequestFullscreen) {
|
||||
el.msRequestFullscreen();
|
||||
}
|
||||
};
|
||||
|
||||
@injectIntl
|
||||
export default class Video extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
preview: PropTypes.string,
|
||||
src: PropTypes.string.isRequired,
|
||||
alt: PropTypes.string,
|
||||
width: PropTypes.number,
|
||||
height: PropTypes.number,
|
||||
sensitive: PropTypes.bool,
|
||||
@@ -236,6 +209,12 @@ export default class Video extends React.PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
handleProgress = () => {
|
||||
if (this.video.buffered.length > 0) {
|
||||
this.setState({ buffer: this.video.buffered.end(0) / this.video.duration * 100 });
|
||||
}
|
||||
}
|
||||
|
||||
handleOpenVideo = () => {
|
||||
this.video.pause();
|
||||
this.props.onOpenVideo(this.video.currentTime);
|
||||
@@ -247,8 +226,8 @@ export default class Video extends React.PureComponent {
|
||||
}
|
||||
|
||||
render () {
|
||||
const { preview, src, width, height, startTime, onOpenVideo, onCloseVideo, intl } = this.props;
|
||||
const { progress, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
|
||||
const { preview, src, width, height, startTime, onOpenVideo, onCloseVideo, intl, alt } = this.props;
|
||||
const { progress, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
|
||||
|
||||
return (
|
||||
<div className={classNames('video-player', { inactive: !revealed, inline: width && height && !fullscreen, fullscreen })} style={{ width, height }} ref={this.setPlayerRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
||||
@@ -256,10 +235,11 @@ export default class Video extends React.PureComponent {
|
||||
ref={this.setVideoRef}
|
||||
src={src}
|
||||
poster={preview}
|
||||
preload={!!startTime}
|
||||
preload={startTime ? 'auto' : 'none'}
|
||||
loop
|
||||
role='button'
|
||||
tabIndex='0'
|
||||
aria-label={alt}
|
||||
width={width}
|
||||
height={height}
|
||||
onClick={this.togglePlay}
|
||||
@@ -267,6 +247,7 @@ export default class Video extends React.PureComponent {
|
||||
onPause={this.handlePause}
|
||||
onTimeUpdate={this.handleTimeUpdate}
|
||||
onLoadedData={this.handleLoadedData}
|
||||
onProgress={this.handleProgress}
|
||||
/>
|
||||
|
||||
<button className={classNames('video-player__spoiler', { active: !revealed })} onClick={this.toggleReveal}>
|
||||
@@ -276,6 +257,7 @@ export default class Video extends React.PureComponent {
|
||||
|
||||
<div className={classNames('video-player__controls', { active: paused || hovered })}>
|
||||
<div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
|
||||
<div className='video-player__seek__buffer' style={{ width: `${buffer}%` }} />
|
||||
<div className='video-player__seek__progress' style={{ width: `${progress}%` }} />
|
||||
|
||||
<span
|
||||
|
Reference in New Issue
Block a user