Sync to 2.4.1

This commit is contained in:
Mike Barnes 2018-06-11 15:23:25 +10:00
commit eebf345440
791 changed files with 22017 additions and 7132 deletions

View file

@ -3,11 +3,13 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
import { Link } from 'react-router-dom';
import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { me } from '../../../initial_state';
import { shortNumberFormat } from '../../../utils/numbers';
const messages = defineMessages({
mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' },
direct: { id: 'account.direct', defaultMessage: 'Direct message @{name}' },
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
@ -22,6 +24,14 @@ const messages = defineMessages({
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' },
hideReblogs: { id: 'account.hide_reblogs', defaultMessage: 'Hide boosts from @{name}' },
showReblogs: { id: 'account.show_reblogs', defaultMessage: 'Show boosts from @{name}' },
pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned toots' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
});
@injectIntl
@ -32,6 +42,7 @@ export default class ActionBar extends React.PureComponent {
onFollow: PropTypes.func,
onBlock: PropTypes.func.isRequired,
onMention: PropTypes.func.isRequired,
onDirect: PropTypes.func.isRequired,
onReblogToggle: PropTypes.func.isRequired,
onReport: PropTypes.func.isRequired,
onMute: PropTypes.func.isRequired,
@ -52,16 +63,29 @@ export default class ActionBar extends React.PureComponent {
let menu = [];
let extraInfo = '';
menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention });
if (account.get('id') !== me) {
menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention });
menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.props.onDirect });
menu.push(null);
}
if ('share' in navigator) {
menu.push({ text: intl.formatMessage(messages.share, { name: account.get('username') }), action: this.handleShare });
menu.push(null);
}
menu.push(null);
if (account.get('id') === me) {
menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
menu.push({ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' });
menu.push({ text: intl.formatMessage(messages.pins), to: '/pinned' });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' });
menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' });
} else {
if (account.getIn(['relationship', 'following'])) {
if (account.getIn(['relationship', 'showing_reblogs'])) {
@ -123,17 +147,17 @@ export default class ActionBar extends React.PureComponent {
<div className='account__action-bar-links'>
<Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}`}>
<span><FormattedMessage id='account.posts' defaultMessage='Toots' /></span>
<strong><FormattedNumber value={account.get('statuses_count')} /></strong>
<strong>{shortNumberFormat(account.get('statuses_count'))}</strong>
</Link>
<Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/following`}>
<span><FormattedMessage id='account.follows' defaultMessage='Follows' /></span>
<strong><FormattedNumber value={account.get('following_count')} /></strong>
<strong>{shortNumberFormat(account.get('following_count'))}</strong>
</Link>
<Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/followers`}>
<span><FormattedMessage id='account.followers' defaultMessage='Followers' /></span>
<strong><FormattedNumber value={account.get('followers_count')} /></strong>
<strong>{shortNumberFormat(account.get('followers_count'))}</strong>
</Link>
</div>
</div>

View file

@ -14,6 +14,7 @@ const messages = defineMessages({
follow: { id: 'account.follow', defaultMessage: 'Follow' },
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' },
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
});
class Avatar extends ImmutablePureComponent {
@ -74,6 +75,10 @@ export default class Header extends ImmutablePureComponent {
intl: PropTypes.object.isRequired,
};
openEditProfile = () => {
window.open('/settings/profile', '_blank');
}
render () {
const { account, intl } = this.props;
@ -118,6 +123,12 @@ export default class Header extends ImmutablePureComponent {
</div>
);
}
} else {
actionBtn = (
<div className='account--action-button'>
<IconButton size={26} icon='pencil' title={intl.formatMessage(messages.edit_profile)} onClick={this.openEditProfile} />
</div>
);
}
if (account.get('moved') && !account.getIn(['relationship', 'following'])) {
@ -130,6 +141,8 @@ export default class Header extends ImmutablePureComponent {
const content = { __html: account.get('note_emojified') };
const displayNameHtml = { __html: account.get('display_name_html') };
const fields = account.get('fields');
const badge = account.get('bot') ? (<div className='roles'><div className='account-role bot'><FormattedMessage id='account.badges.bot' defaultMessage='Bot' /></div></div>) : null;
return (
<div className={classNames('account__header', { inactive: !!account.get('moved') })} style={{ backgroundImage: `url(${account.get('header')})` }}>
@ -138,8 +151,22 @@ export default class Header extends ImmutablePureComponent {
<span className='account__header__display-name' dangerouslySetInnerHTML={displayNameHtml} />
<span className='account__header__username'>@{account.get('acct')} {lockedIcon}</span>
{badge}
<div className='account__header__content' dangerouslySetInnerHTML={content} />
{fields.size > 0 && (
<div className='account__header__fields'>
{fields.map((pair, i) => (
<dl key={i}>
<dt dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} title={pair.get('name')} />
<dd dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }} title={pair.get('value_plain')} />
</dl>
))}
</div>
)}
{info}
{mutingInfo}
{actionBtn}

View file

@ -67,7 +67,7 @@ export default class AccountGallery extends ImmutablePureComponent {
handleScrollToBottom = () => {
if (this.props.hasMore) {
this.handleLoadMore(this.props.medias.last().get('id'));
this.handleLoadMore(this.props.medias.last().getIn(['status', 'id']));
}
}

View file

@ -16,6 +16,7 @@ export default class Header extends ImmutablePureComponent {
onFollow: PropTypes.func.isRequired,
onBlock: PropTypes.func.isRequired,
onMention: PropTypes.func.isRequired,
onDirect: PropTypes.func.isRequired,
onReblogToggle: PropTypes.func.isRequired,
onReport: PropTypes.func.isRequired,
onMute: PropTypes.func.isRequired,
@ -40,6 +41,10 @@ export default class Header extends ImmutablePureComponent {
this.props.onMention(this.props.account, this.context.router.history);
}
handleDirect = () => {
this.props.onDirect(this.props.account, this.context.router.history);
}
handleReport = () => {
this.props.onReport(this.props.account);
}
@ -57,7 +62,7 @@ export default class Header extends ImmutablePureComponent {
if (!domain) return;
this.props.onBlockDomain(domain, this.props.account.get('id'));
this.props.onBlockDomain(domain);
}
handleUnblockDomain = () => {
@ -65,7 +70,7 @@ export default class Header extends ImmutablePureComponent {
if (!domain) return;
this.props.onUnblockDomain(domain, this.props.account.get('id'));
this.props.onUnblockDomain(domain);
}
render () {
@ -89,6 +94,7 @@ export default class Header extends ImmutablePureComponent {
account={account}
onBlock={this.handleBlock}
onMention={this.handleMention}
onDirect={this.handleDirect}
onReblogToggle={this.handleReblogToggle}
onReport={this.handleReport}
onMute={this.handleMute}

View file

@ -9,7 +9,10 @@ import {
unblockAccount,
unmuteAccount,
} from '../../../actions/accounts';
import { mentionCompose } from '../../../actions/compose';
import {
mentionCompose,
directCompose,
} from '../../../actions/compose';
import { initMuteModal } from '../../../actions/mutes';
import { initReport } from '../../../actions/reports';
import { openModal } from '../../../actions/modal';
@ -67,6 +70,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch(mentionCompose(account, router));
},
onDirect (account, router) {
dispatch(directCompose(account, router));
},
onReblogToggle (account) {
if (account.getIn(['relationship', 'showing_reblogs'])) {
dispatch(followAccount(account.get('id'), false));
@ -87,16 +94,16 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
}
},
onBlockDomain (domain, accountId) {
onBlockDomain (domain) {
dispatch(openModal('CONFIRM', {
message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.' values={{ domain: <strong>{domain}</strong> }} />,
message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.' values={{ domain: <strong>{domain}</strong> }} />,
confirm: intl.formatMessage(messages.blockDomainConfirm),
onConfirm: () => dispatch(blockDomain(domain, accountId)),
onConfirm: () => dispatch(blockDomain(domain)),
}));
},
onUnblockDomain (domain, accountId) {
dispatch(unblockDomain(domain, accountId));
onUnblockDomain (domain) {
dispatch(unblockDomain(domain));
},
});

View file

@ -0,0 +1,59 @@
import PropTypes from 'prop-types';
import React, { Component, Fragment } from 'react';
import { FormattedMessage } from 'react-intl';
import { NavLink } from 'react-router-dom';
export default class SectionHeadline extends Component {
static propTypes = {
timelineId: PropTypes.string.isRequired,
to: PropTypes.string.isRequired,
pinned: PropTypes.bool.isRequired,
onlyMedia: PropTypes.bool.isRequired,
onClick: PropTypes.func,
};
shouldComponentUpdate (nextProps) {
return (
this.props.onlyMedia !== nextProps.onlyMedia ||
this.props.pinned !== nextProps.pinned ||
this.props.to !== nextProps.to ||
this.props.timelineId !== nextProps.timelineId
);
}
handleClick = e => {
const { onClick } = this.props;
if (typeof onClick === 'function') {
e.preventDefault();
onClick.call(this, e);
}
}
render () {
const { timelineId, to, pinned, onlyMedia } = this.props;
return (
<div className={`${timelineId}-timeline__section-headline`}>
{pinned ? (
<Fragment>
<a href={to} className={!onlyMedia ? 'active' : undefined} onClick={this.handleClick}>
<FormattedMessage id='timeline.posts' defaultMessage='Toots' />
</a>
<a href={`${to}/media`} className={onlyMedia ? 'active' : undefined} onClick={this.handleClick}>
<FormattedMessage id='timeline.media' defaultMessage='Media' />
</a>
</Fragment>
) : (
<Fragment>
<NavLink exact to={to} replace><FormattedMessage id='timeline.posts' defaultMessage='Toots' /></NavLink>
<NavLink exact to={`${to}/media`} replace><FormattedMessage id='timeline.media' defaultMessage='Media' /></NavLink>
</Fragment>
)}
</div>
);
}
}

View file

@ -1,42 +1,48 @@
import React from 'react';
import { connect } from 'react-redux';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import PropTypes from 'prop-types';
import StatusListContainer from '../ui/containers/status_list_container';
import Column from '../../components/column';
import ColumnHeader from '../../components/column_header';
import { expandCommunityTimeline } from '../../actions/timelines';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { addColumn, removeColumn, moveColumn, changeColumnParams } from '../../actions/columns';
import ColumnSettingsContainer from './containers/column_settings_container';
import SectionHeadline from './components/section_headline';
import { connectCommunityStream } from '../../actions/streaming';
const messages = defineMessages({
title: { id: 'column.community', defaultMessage: 'Local timeline' },
});
const mapStateToProps = state => ({
hasUnread: state.getIn(['timelines', 'community', 'unread']) > 0,
const mapStateToProps = (state, { onlyMedia }) => ({
hasUnread: state.getIn(['timelines', `community${onlyMedia ? ':media' : ''}`, 'unread']) > 0,
});
@connect(mapStateToProps)
@injectIntl
export default class CommunityTimeline extends React.PureComponent {
static defaultProps = {
onlyMedia: false,
};
static propTypes = {
dispatch: PropTypes.func.isRequired,
columnId: PropTypes.string,
intl: PropTypes.object.isRequired,
hasUnread: PropTypes.bool,
multiColumn: PropTypes.bool,
onlyMedia: PropTypes.bool,
};
handlePin = () => {
const { columnId, dispatch } = this.props;
const { columnId, dispatch, onlyMedia } = this.props;
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('COMMUNITY', {}));
dispatch(addColumn('COMMUNITY', { other: { onlyMedia } }));
}
}
@ -50,10 +56,20 @@ export default class CommunityTimeline extends React.PureComponent {
}
componentDidMount () {
const { dispatch } = this.props;
const { dispatch, onlyMedia } = this.props;
dispatch(expandCommunityTimeline());
this.disconnect = dispatch(connectCommunityStream());
dispatch(expandCommunityTimeline({ onlyMedia }));
this.disconnect = dispatch(connectCommunityStream({ onlyMedia }));
}
componentDidUpdate (prevProps) {
if (prevProps.onlyMedia !== this.props.onlyMedia) {
const { dispatch, onlyMedia } = this.props;
this.disconnect();
dispatch(expandCommunityTimeline({ onlyMedia }));
this.disconnect = dispatch(connectCommunityStream({ onlyMedia }));
}
}
componentWillUnmount () {
@ -68,13 +84,32 @@ export default class CommunityTimeline extends React.PureComponent {
}
handleLoadMore = maxId => {
this.props.dispatch(expandCommunityTimeline({ maxId }));
const { dispatch, onlyMedia } = this.props;
dispatch(expandCommunityTimeline({ maxId, onlyMedia }));
}
handleHeadlineLinkClick = e => {
const { columnId, dispatch } = this.props;
const onlyMedia = /\/media$/.test(e.currentTarget.href);
dispatch(changeColumnParams(columnId, { other: { onlyMedia } }));
}
render () {
const { intl, hasUnread, columnId, multiColumn } = this.props;
const { intl, hasUnread, columnId, multiColumn, onlyMedia } = this.props;
const pinned = !!columnId;
const headline = (
<SectionHeadline
timelineId='community'
to='/timelines/public/local'
pinned={pinned}
onlyMedia={onlyMedia}
onClick={this.handleHeadlineLinkClick}
/>
);
return (
<Column ref={this.setRef}>
<ColumnHeader
@ -91,9 +126,11 @@ export default class CommunityTimeline extends React.PureComponent {
</ColumnHeader>
<StatusListContainer
prepend={headline}
alwaysPrepend
trackScroll={!pinned}
scrollKey={`community_timeline-${columnId}`}
timelineId='community'
timelineId={`community${onlyMedia ? ':media' : ''}`}
onLoadMore={this.handleLoadMore}
emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />}
/>

View file

@ -7,7 +7,6 @@ import ReplyIndicatorContainer from '../containers/reply_indicator_container';
import AutosuggestTextarea from '../../../components/autosuggest_textarea';
import UploadButtonContainer from '../containers/upload_button_container';
import { defineMessages, injectIntl } from 'react-intl';
import Collapsable from '../../../components/collapsable';
import SpoilerButtonContainer from '../containers/spoiler_button_container';
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
import SensitiveButtonContainer from '../containers/sensitive_button_container';
@ -19,6 +18,8 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { length } from 'stringz';
import { countableText } from '../util/counter';
const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d';
const messages = defineMessages({
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' },
@ -38,6 +39,7 @@ export default class ComposeForm extends ImmutablePureComponent {
privacy: PropTypes.string,
spoiler_text: PropTypes.string,
focusDate: PropTypes.instanceOf(Date),
caretPosition: PropTypes.number,
preselectDate: PropTypes.instanceOf(Date),
is_submitting: PropTypes.bool,
is_uploading: PropTypes.bool,
@ -74,6 +76,14 @@ export default class ComposeForm extends ImmutablePureComponent {
this.props.onChange(this.autosuggestTextarea.textarea.value);
}
// Submit disabled:
const { is_submitting, is_uploading, anyMedia } = this.props;
const fulltext = [this.props.spoiler_text, countableText(this.props.text)].join('');
if (is_submitting || is_uploading || length(fulltext) > 500 || (fulltext.length !== 0 && fulltext.trim().length === 0 && !anyMedia)) {
return;
}
this.props.onSubmit();
}
@ -86,7 +96,6 @@ export default class ComposeForm extends ImmutablePureComponent {
}
onSuggestionSelected = (tokenStart, token, value) => {
this._restoreCaret = null;
this.props.onSuggestionSelected(tokenStart, token, value);
}
@ -94,31 +103,21 @@ export default class ComposeForm extends ImmutablePureComponent {
this.props.onChangeSpoilerText(e.target.value);
}
componentWillReceiveProps (nextProps) {
// If this is the update where we've finished uploading,
// save the last caret position so we can restore it below!
if (!nextProps.is_uploading && this.props.is_uploading) {
this._restoreCaret = this.autosuggestTextarea.textarea.selectionStart;
}
}
componentDidUpdate (prevProps) {
// This statement does several things:
// - If we're beginning a reply, and,
// - Replying to zero or one users, places the cursor at the end of the textbox.
// - Replying to more than one user, selects any usernames past the first;
// this provides a convenient shortcut to drop everyone else from the conversation.
// - If we've just finished uploading an image, and have a saved caret position,
// restores the cursor to that position after the text changes!
if (this.props.focusDate !== prevProps.focusDate || (prevProps.is_uploading && !this.props.is_uploading && typeof this._restoreCaret === 'number')) {
if (this.props.focusDate !== prevProps.focusDate) {
let selectionEnd, selectionStart;
if (this.props.preselectDate !== prevProps.preselectDate) {
selectionEnd = this.props.text.length;
selectionStart = this.props.text.search(/\s/) + 1;
} else if (typeof this._restoreCaret === 'number') {
selectionStart = this._restoreCaret;
selectionEnd = this._restoreCaret;
} else if (typeof this.props.caretPosition === 'number') {
selectionStart = this.props.caretPosition;
selectionEnd = this.props.caretPosition;
} else {
selectionEnd = this.props.text.length;
selectionStart = selectionEnd;
@ -129,17 +128,30 @@ export default class ComposeForm extends ImmutablePureComponent {
} else if(prevProps.is_submitting && !this.props.is_submitting) {
this.autosuggestTextarea.textarea.focus();
}
if (this.props.spoiler !== prevProps.spoiler) {
if (this.props.spoiler) {
this.spoilerText.focus();
} else {
this.autosuggestTextarea.textarea.focus();
}
}
}
setAutosuggestTextarea = (c) => {
this.autosuggestTextarea = c;
}
setSpoilerText = (c) => {
this.spoilerText = c;
}
handleEmojiPick = (data) => {
const { text } = this.props;
const position = this.autosuggestTextarea.textarea.selectionStart;
const emojiChar = data.native;
this._restoreCaret = position + emojiChar.length + 1;
this.props.onPickEmoji(position, data);
const needsSpace = data.custom && position > 0 && !allowedAroundShortCode.includes(text[position - 1]);
this.props.onPickEmoji(position, data, needsSpace);
}
render () {
@ -159,17 +171,15 @@ export default class ComposeForm extends ImmutablePureComponent {
<div className='compose-form'>
<WarningContainer />
<Collapsable isVisible={this.props.spoiler} fullHeight={50}>
<div className='spoiler-input'>
<label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.spoiler_placeholder)}</span>
<input placeholder={intl.formatMessage(messages.spoiler_placeholder)} value={this.props.spoiler_text} onChange={this.handleChangeSpoilerText} onKeyDown={this.handleKeyDown} type='text' className='spoiler-input__input' id='cw-spoiler-input' />
</label>
</div>
</Collapsable>
<ReplyIndicatorContainer />
<div className={`spoiler-input ${this.props.spoiler ? 'spoiler-input--visible' : ''}`}>
<label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.spoiler_placeholder)}</span>
<input placeholder={intl.formatMessage(messages.spoiler_placeholder)} value={this.props.spoiler_text} onChange={this.handleChangeSpoilerText} onKeyDown={this.handleKeyDown} type='text' className='spoiler-input__input' id='cw-spoiler-input' ref={this.setSpoilerText} />
</label>
</div>
<div className='compose-form__autosuggest-wrapper'>
<AutosuggestTextarea
ref={this.setAutosuggestTextarea}

View file

@ -28,7 +28,7 @@ const messages = defineMessages({
const assetHost = process.env.CDN_HOST || '';
let EmojiPicker, Emoji; // load asynchronously
const backgroundImageFn = () => `${assetHost}/emoji/sheet.png`;
const backgroundImageFn = () => `${assetHost}/emoji/sheet_10.png`;
const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
const categoriesSort = [
@ -162,12 +162,12 @@ class EmojiPickerMenu extends React.PureComponent {
static defaultProps = {
style: {},
loading: true,
placement: 'bottom',
frequentlyUsedEmojis: [],
};
state = {
modifierOpen: false,
placement: null,
};
handleDocumentClick = e => {
@ -298,7 +298,7 @@ export default class EmojiPickerDropdown extends React.PureComponent {
this.dropdown = c;
}
onShowDropdown = () => {
onShowDropdown = ({ target }) => {
this.setState({ active: true });
if (!EmojiPicker) {
@ -313,6 +313,9 @@ export default class EmojiPickerDropdown extends React.PureComponent {
this.setState({ loading: false });
});
}
const { top } = target.getBoundingClientRect();
this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' });
}
onHideDropdown = () => {
@ -324,7 +327,7 @@ export default class EmojiPickerDropdown extends React.PureComponent {
if (this.state.active) {
this.onHideDropdown();
} else {
this.onShowDropdown();
this.onShowDropdown(e);
}
}
}
@ -346,7 +349,7 @@ export default class EmojiPickerDropdown extends React.PureComponent {
render () {
const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props;
const title = intl.formatMessage(messages.emoji);
const { active, loading } = this.state;
const { active, loading, placement } = this.state;
return (
<div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}>
@ -358,7 +361,7 @@ export default class EmojiPickerDropdown extends React.PureComponent {
/>
</div>
<Overlay show={active} placement='bottom' target={this.findTarget}>
<Overlay show={active} placement={placement} target={this.findTarget}>
<EmojiPickerMenu
custom_emojis={this.props.custom_emojis}
loading={loading}

View file

@ -32,28 +32,76 @@ class PrivacyDropdownMenu extends React.PureComponent {
onChange: PropTypes.func.isRequired,
};
state = {
mounted: false,
};
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();
handleKeyDown = e => {
const { items } = this.props;
const value = e.currentTarget.getAttribute('data-index');
const index = items.findIndex(item => {
return (item.value === value);
});
let element;
switch(e.key) {
case 'Escape':
this.props.onClose();
this.props.onChange(value);
break;
case 'Enter':
this.handleClick(e);
break;
case 'ArrowDown':
element = this.node.childNodes[index + 1];
if (element) {
element.focus();
this.props.onChange(element.getAttribute('data-index'));
}
break;
case 'ArrowUp':
element = this.node.childNodes[index - 1];
if (element) {
element.focus();
this.props.onChange(element.getAttribute('data-index'));
}
break;
case 'Home':
element = this.node.firstChild;
if (element) {
element.focus();
this.props.onChange(element.getAttribute('data-index'));
}
break;
case 'End':
element = this.node.lastChild;
if (element) {
element.focus();
this.props.onChange(element.getAttribute('data-index'));
}
break;
}
}
handleClick = e => {
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);
if (this.focusedItem) this.focusedItem.focus();
this.setState({ mounted: true });
}
componentWillUnmount () {
@ -65,15 +113,23 @@ class PrivacyDropdownMenu extends React.PureComponent {
this.node = c;
}
setFocusRef = c => {
this.focusedItem = c;
}
render () {
const { mounted } = this.state;
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}>
// It should not be transformed when mounting because the resulting
// size will be used to determine the coordinate of the menu by
// react-overlays
<div className='privacy-dropdown__dropdown' style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} role='listbox' 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 role='option' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleKeyDown} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })} aria-selected={item.value === value} ref={item.value === value ? this.setFocusRef : null}>
<div className='privacy-dropdown__option__icon'>
<i className={`fa fa-fw fa-${item.icon}`} />
</div>
@ -107,9 +163,10 @@ export default class PrivacyDropdown extends React.PureComponent {
state = {
open: false,
placement: null,
};
handleToggle = () => {
handleToggle = ({ target }) => {
if (this.props.isUserTouching()) {
if (this.state.open) {
this.props.onModalClose();
@ -120,6 +177,8 @@ export default class PrivacyDropdown extends React.PureComponent {
});
}
} else {
const { top } = target.getBoundingClientRect();
this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' });
this.setState({ open: !this.state.open });
}
}
@ -135,9 +194,6 @@ export default class PrivacyDropdown extends React.PureComponent {
handleKeyDown = e => {
switch(e.key) {
case 'Enter':
this.handleToggle();
break;
case 'Escape':
this.handleClose();
break;
@ -165,7 +221,7 @@ export default class PrivacyDropdown extends React.PureComponent {
render () {
const { value, intl } = this.props;
const { open } = this.state;
const { open, placement } = this.state;
const valueOption = this.options.find(item => item.value === value);
@ -185,7 +241,7 @@ export default class PrivacyDropdown extends React.PureComponent {
/>
</div>
<Overlay show={open} placement='bottom' target={this}>
<Overlay show={open} placement={placement} target={this}>
<PrivacyDropdownMenu
items={this.options}
value={value}

View file

@ -51,7 +51,7 @@ export default class ReplyIndicator extends ImmutablePureComponent {
return (
<div className='reply-indicator'>
<div className='reply-indicator__header'>
<div className='reply-indicator__cancel'><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} /></div>
<div className='reply-indicator__cancel'><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} inverted /></div>
<a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name'>
<div className='reply-indicator__display-avatar'><Avatar account={status.get('account')} size={24} /></div>

View file

@ -3,8 +3,8 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { FormattedMessage } from 'react-intl';
import AccountContainer from '../../../containers/account_container';
import StatusContainer from '../../../containers/status_container';
import { Link } from 'react-router-dom';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Hashtag from '../../../components/hashtag';
export default class SearchResults extends ImmutablePureComponent {
@ -22,7 +22,7 @@ export default class SearchResults extends ImmutablePureComponent {
count += results.get('accounts').size;
accounts = (
<div className='search-results__section'>
<h5><FormattedMessage id='search_results.accounts' defaultMessage='People' /></h5>
<h5><i className='fa fa-fw fa-users' /><FormattedMessage id='search_results.accounts' defaultMessage='People' /></h5>
{results.get('accounts').map(accountId => <AccountContainer key={accountId} id={accountId} />)}
</div>
@ -33,7 +33,7 @@ export default class SearchResults extends ImmutablePureComponent {
count += results.get('statuses').size;
statuses = (
<div className='search-results__section'>
<h5><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5>
<h5><i className='fa fa-fw fa-quote-right' /><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5>
{results.get('statuses').map(statusId => <StatusContainer key={statusId} id={statusId} />)}
</div>
@ -44,13 +44,9 @@ export default class SearchResults extends ImmutablePureComponent {
count += results.get('hashtags').size;
hashtags = (
<div className='search-results__section'>
<h5><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5>
<h5><i className='fa fa-fw fa-hashtag' /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5>
{results.get('hashtags').map(hashtag => (
<Link key={hashtag} className='search-results__hashtag' to={`/timelines/tag/${hashtag}`}>
#{hashtag}
</Link>
))}
{results.get('hashtags').map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
</div>
);
}
@ -58,6 +54,7 @@ export default class SearchResults extends ImmutablePureComponent {
return (
<div className='search-results'>
<div className='search-results__header'>
<i className='fa fa-search fa-fw' />
<FormattedMessage id='search_results.total' defaultMessage='{count, number} {count, plural, one {result} other {results}}' values={{ count }} />
</div>

View file

@ -77,7 +77,7 @@ export default class Upload extends ImmutablePureComponent {
{({ scale }) => (
<div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}>
<div className={classNames('compose-form__upload__actions', { active })}>
<button className='icon-button' onClick={this.handleUndoClick}><i className='fa fa-times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Undo' /></button>
<button className='icon-button' onClick={this.handleUndoClick}><i className='fa fa-times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button>
{media.get('type') === 'image' && <button className='icon-button' onClick={this.handleFocalPointClick}><i className='fa fa-crosshairs' /> <FormattedMessage id='upload_form.focus' defaultMessage='Crop' /></button>}
</div>

View file

@ -19,6 +19,7 @@ const mapStateToProps = state => ({
spoiler_text: state.getIn(['compose', 'spoiler_text']),
privacy: state.getIn(['compose', 'privacy']),
focusDate: state.getIn(['compose', 'focusDate']),
caretPosition: state.getIn(['compose', 'caretPosition']),
preselectDate: state.getIn(['compose', 'preselectDate']),
is_submitting: state.getIn(['compose', 'is_submitting']),
is_uploading: state.getIn(['compose', 'is_uploading']),
@ -56,8 +57,8 @@ const mapDispatchToProps = (dispatch) => ({
dispatch(uploadCompose(files));
},
onPickEmoji (position, data) {
dispatch(insertEmojiCompose(position, data));
onPickEmoji (position, data, needsSpace) {
dispatch(insertEmojiCompose(position, data, needsSpace));
},
});

View file

@ -38,7 +38,8 @@ const getFrequentlyUsedEmojis = createSelector([
.toArray();
if (emojis.length < DEFAULTS.length) {
emojis = emojis.concat(DEFAULTS.slice(0, DEFAULTS.length - emojis.length));
let uniqueDefaults = DEFAULTS.filter(emoji => !emojis.includes(emoji));
emojis = emojis.concat(uniqueDefaults.slice(0, DEFAULTS.length - emojis.length));
}
return emojis;

View file

@ -10,22 +10,35 @@ const APPROX_HASHTAG_RE = /(?:^|[^\/\)\w])#(\w*[a-zA-Z·]\w*)/i;
const mapStateToProps = state => ({
needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']),
hashtagWarning: state.getIn(['compose', 'privacy']) !== 'public' && APPROX_HASHTAG_RE.test(state.getIn(['compose', 'text'])),
directMessageWarning: state.getIn(['compose', 'privacy']) === 'direct',
});
const WarningWrapper = ({ needsLockWarning, hashtagWarning }) => {
const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning }) => {
if (needsLockWarning) {
return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <a href='/settings/profile'><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />;
}
if (hashtagWarning) {
return <Warning message={<FormattedMessage id='compose_form.hashtag_warning' defaultMessage="This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag." />} />;
}
if (directMessageWarning) {
const message = (
<span>
<FormattedMessage id='compose_form.direct_message_warning' defaultMessage='This toot will only be sent to all the mentioned users.' /> <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a>
</span>
);
return <Warning message={message} />;
}
return null;
};
WarningWrapper.propTypes = {
needsLockWarning: PropTypes.bool,
hashtagWarning: PropTypes.bool,
directMessageWarning: PropTypes.bool,
};
export default connect(mapStateToProps)(WarningWrapper);

View file

@ -24,9 +24,9 @@ const messages = defineMessages({
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
});
const mapStateToProps = state => ({
const mapStateToProps = (state, ownProps) => ({
columns: state.getIn(['settings', 'columns']),
showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
showSearch: ownProps.multiColumn ? state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']) : ownProps.isSearchPage,
});
@connect(mapStateToProps)
@ -38,15 +38,24 @@ export default class Compose extends React.PureComponent {
columns: ImmutablePropTypes.list.isRequired,
multiColumn: PropTypes.bool,
showSearch: PropTypes.bool,
isSearchPage: PropTypes.bool,
intl: PropTypes.object.isRequired,
};
componentDidMount () {
this.props.dispatch(mountCompose());
const { isSearchPage } = this.props;
if (!isSearchPage) {
this.props.dispatch(mountCompose());
}
}
componentWillUnmount () {
this.props.dispatch(unmountCompose());
const { isSearchPage } = this.props;
if (!isSearchPage) {
this.props.dispatch(unmountCompose());
}
}
onFocus = () => {
@ -58,7 +67,7 @@ export default class Compose extends React.PureComponent {
}
render () {
const { multiColumn, showSearch, intl } = this.props;
const { multiColumn, showSearch, isSearchPage, intl } = this.props;
let header = '';
@ -66,7 +75,7 @@ export default class Compose extends React.PureComponent {
const { columns } = this.props;
header = (
<nav className='drawer__header'>
<Link to='/getting-started' className='drawer__tab' title={intl.formatMessage(messages.start)} aria-label={intl.formatMessage(messages.start)}><i role='img' className='fa fa-fw fa-asterisk' /></Link>
<Link to='/getting-started' className='drawer__tab' title={intl.formatMessage(messages.start)} aria-label={intl.formatMessage(messages.start)}><i role='img' className='fa fa-fw fa-bars' /></Link>
{!columns.some(column => column.get('id') === 'HOME') && (
<Link to='/timelines/home' className='drawer__tab' title={intl.formatMessage(messages.home_timeline)} aria-label={intl.formatMessage(messages.home_timeline)}><i role='img' className='fa fa-fw fa-home' /></Link>
)}
@ -89,10 +98,10 @@ export default class Compose extends React.PureComponent {
<div className='drawer'>
{header}
<SearchContainer />
{(multiColumn || isSearchPage) && <SearchContainer /> }
<div className='drawer__pager'>
<div className='drawer__inner' onFocus={this.onFocus}>
{!isSearchPage && <div className='drawer__inner' onFocus={this.onFocus}>
<NavigationContainer onClose={this.onBlur} />
<ComposeFormContainer />
{multiColumn && (
@ -100,9 +109,9 @@ export default class Compose extends React.PureComponent {
<img alt='' draggable='false' src={elephantUIPlane} />
</div>
)}
</div>
</div>}
<Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
<Motion defaultStyle={{ x: isSearchPage ? 0 : -100 }} style={{ x: spring(showSearch || isSearchPage ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
{({ x }) => (
<div className='drawer__inner darker' style={{ transform: `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
<SearchResultsContainer />

View file

@ -0,0 +1,17 @@
import { connect } from 'react-redux';
import ColumnSettings from '../../community_timeline/components/column_settings';
import { changeSetting } from '../../../actions/settings';
const mapStateToProps = state => ({
settings: state.getIn(['settings', 'direct']),
});
const mapDispatchToProps = dispatch => ({
onChange (key, checked) {
dispatch(changeSetting(['direct', ...key], checked));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);

View file

@ -0,0 +1,104 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import StatusListContainer from '../ui/containers/status_list_container';
import Column from '../../components/column';
import ColumnHeader from '../../components/column_header';
import { expandDirectTimeline } from '../../actions/timelines';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ColumnSettingsContainer from './containers/column_settings_container';
import { connectDirectStream } from '../../actions/streaming';
const messages = defineMessages({
title: { id: 'column.direct', defaultMessage: 'Direct messages' },
});
const mapStateToProps = state => ({
hasUnread: state.getIn(['timelines', 'direct', 'unread']) > 0,
});
@connect(mapStateToProps)
@injectIntl
export default class DirectTimeline extends React.PureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
columnId: PropTypes.string,
intl: PropTypes.object.isRequired,
hasUnread: PropTypes.bool,
multiColumn: PropTypes.bool,
};
handlePin = () => {
const { columnId, dispatch } = this.props;
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('DIRECT', {}));
}
}
handleMove = (dir) => {
const { columnId, dispatch } = this.props;
dispatch(moveColumn(columnId, dir));
}
handleHeaderClick = () => {
this.column.scrollTop();
}
componentDidMount () {
const { dispatch } = this.props;
dispatch(expandDirectTimeline());
this.disconnect = dispatch(connectDirectStream());
}
componentWillUnmount () {
if (this.disconnect) {
this.disconnect();
this.disconnect = null;
}
}
setRef = c => {
this.column = c;
}
handleLoadMore = maxId => {
this.props.dispatch(expandDirectTimeline({ maxId }));
}
render () {
const { intl, hasUnread, columnId, multiColumn } = this.props;
const pinned = !!columnId;
return (
<Column ref={this.setRef}>
<ColumnHeader
icon='envelope'
active={hasUnread}
title={intl.formatMessage(messages.title)}
onPin={this.handlePin}
onMove={this.handleMove}
onClick={this.handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
>
<ColumnSettingsContainer />
</ColumnHeader>
<StatusListContainer
trackScroll={!pinned}
scrollKey={`direct_timeline-${columnId}`}
timelineId='direct'
onLoadMore={this.handleLoadMore}
emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any direct messages yet. When you send or receive one, it will show up here." />}
/>
</Column>
);
}
}

View file

@ -0,0 +1,66 @@
import React from 'react';
import { connect } from 'react-redux';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import LoadingIndicator from '../../components/loading_indicator';
import Column from '../ui/components/column';
import ColumnBackButtonSlim from '../../components/column_back_button_slim';
import DomainContainer from '../../containers/domain_container';
import { fetchDomainBlocks, expandDomainBlocks } from '../../actions/domain_blocks';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { debounce } from 'lodash';
import ScrollableList from '../../components/scrollable_list';
const messages = defineMessages({
heading: { id: 'column.domain_blocks', defaultMessage: 'Hidden domains' },
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' },
});
const mapStateToProps = state => ({
domains: state.getIn(['domain_lists', 'blocks', 'items']),
});
@connect(mapStateToProps)
@injectIntl
export default class Blocks extends ImmutablePureComponent {
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
domains: ImmutablePropTypes.orderedSet,
intl: PropTypes.object.isRequired,
};
componentWillMount () {
this.props.dispatch(fetchDomainBlocks());
}
handleLoadMore = debounce(() => {
this.props.dispatch(expandDomainBlocks());
}, 300, { leading: true });
render () {
const { intl, domains } = this.props;
if (!domains) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
return (
<Column icon='minus-circle' heading={intl.formatMessage(messages.heading)}>
<ColumnBackButtonSlim />
<ScrollableList scrollKey='domain_blocks' onLoadMore={this.handleLoadMore}>
{domains.map(domain =>
<DomainContainer key={domain} domain={domain} />
)}
</ScrollableList>
</Column>
);
}
}

View file

@ -51,7 +51,7 @@ describe('emoji', () => {
});
it('does an emoji that has no shortcode', () => {
expect(emojify('🕉️')).toEqual('<img draggable="false" class="emojione" alt="🕉️" title="" src="/emoji/1f549.svg" />');
expect(emojify('👁‍🗨')).toEqual('<img draggable="false" class="emojione" alt="👁‍🗨" title="" src="/emoji/1f441-200d-1f5e8.svg" />');
});
it('does an emoji whose filename is irregular', () => {

View file

@ -44,6 +44,57 @@ describe('emoji_index', () => {
expect(emojiIndex.search('apple').map(trimEmojis)).toEqual(expected);
});
it('can include/exclude categories', () => {
expect(search('flag', { include: ['people'] })).toEqual([]);
expect(emojiIndex.search('flag', { include: ['people'] })).toEqual([]);
});
it('(different behavior from emoji-mart) do not erases custom emoji if not passed again', () => {
const custom = [
{
id: 'mastodon',
name: 'mastodon',
short_names: ['mastodon'],
text: '',
emoticons: [],
keywords: ['mastodon'],
imageUrl: 'http://example.com',
custom: true,
},
];
search('', { custom });
emojiIndex.search('', { custom });
const expected = [];
const lightExpected = [
{
id: 'mastodon',
custom: true,
},
];
expect(search('masto').map(trimEmojis)).toEqual(lightExpected);
expect(emojiIndex.search('masto').map(trimEmojis)).toEqual(expected);
});
it('(different behavior from emoji-mart) erases custom emoji if another is passed', () => {
const custom = [
{
id: 'mastodon',
name: 'mastodon',
short_names: ['mastodon'],
text: '',
emoticons: [],
keywords: ['mastodon'],
imageUrl: 'http://example.com',
custom: true,
},
];
search('', { custom });
emojiIndex.search('', { custom });
const expected = [];
expect(search('masto', { custom: [] }).map(trimEmojis)).toEqual(expected);
expect(emojiIndex.search('masto').map(trimEmojis)).toEqual(expected);
});
it('handles custom emoji', () => {
const custom = [
{
@ -65,23 +116,18 @@ describe('emoji_index', () => {
custom: true,
},
];
expect(search('masto').map(trimEmojis)).toEqual(expected);
expect(emojiIndex.search('masto').map(trimEmojis)).toEqual(expected);
expect(search('masto', { custom }).map(trimEmojis)).toEqual(expected);
expect(emojiIndex.search('masto', { custom }).map(trimEmojis)).toEqual(expected);
});
it('should filter only emojis we care about, exclude pineapple', () => {
const emojisToShowFilter = unified => unified !== '1F34D';
const emojisToShowFilter = emoji => emoji.unified !== '1F34D';
expect(search('apple', { emojisToShowFilter }).map((obj) => obj.id))
.not.toContain('pineapple');
expect(emojiIndex.search('apple', { emojisToShowFilter }).map((obj) => obj.id))
.not.toContain('pineapple');
});
it('can include/exclude categories', () => {
expect(search('flag', { include: ['people'] })).toEqual([]);
expect(emojiIndex.search('flag', { include: ['people'] })).toEqual([]);
});
it('does an emoji whose unified name is irregular', () => {
const expected = [
{

View file

@ -9,7 +9,13 @@ 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 { default: emojiMartData } = require('emoji-mart/dist/data');
const { uncompress: emojiMartUncompress } = require('emoji-mart/dist/utils/data');
let data = require('emoji-mart/data/all.json');
if(data.compressed) {
data = emojiMartUncompress(data);
}
const emojiMartData = data;
const excluded = ['®', '©', '™'];
const skins = ['🏻', '🏼', '🏽', '🏾', '🏿'];
@ -88,6 +94,6 @@ module.exports = JSON.parse(JSON.stringify([
shortCodesToEmojiData,
emojiMartData.skins,
emojiMartData.categories,
emojiMartData.short_names,
emojiMartData.aliases,
emojisWithoutShortCodes,
]));

View file

@ -8,6 +8,7 @@ let originalPool = {};
let index = {};
let emojisList = {};
let emoticonsList = {};
let customEmojisList = [];
for (let emoji in data.emojis) {
let emojiData = data.emojis[emoji];
@ -28,7 +29,18 @@ for (let emoji in data.emojis) {
originalPool[id] = emojiData;
}
function clearCustomEmojis(pool) {
customEmojisList.forEach((emoji) => {
let emojiId = emoji.id || emoji.short_names[0];
delete pool[emojiId];
delete emojisList[emojiId];
});
}
function addCustomToPool(custom, pool) {
if (customEmojisList.length) clearCustomEmojis(pool);
custom.forEach((emoji) => {
let emojiId = emoji.id || emoji.short_names[0];
@ -37,10 +49,18 @@ function addCustomToPool(custom, pool) {
emojisList[emojiId] = getSanitizedData(emoji);
}
});
customEmojisList = custom;
index = {};
}
function search(value, { emojisToShowFilter, maxResults, include, exclude, custom = [] } = {}) {
addCustomToPool(custom, originalPool);
function search(value, { emojisToShowFilter, maxResults, include, exclude, custom } = {}) {
if (custom !== undefined) {
if (customEmojisList !== custom)
addCustomToPool(custom, originalPool);
} else {
custom = [];
}
maxResults = maxResults || 75;
include = include || [];
@ -143,7 +163,7 @@ function search(value, { emojisToShowFilter, maxResults, include, exclude, custo
if (results) {
if (emojisToShowFilter) {
results = results.filter((result) => emojisToShowFilter(data.emojis[result.id].unified));
results = results.filter((result) => emojisToShowFilter(data.emojis[result.id]));
}
if (results && results.length > maxResults) {

View file

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

View file

@ -10,32 +10,32 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { me } from '../../initial_state';
import { fetchFollowRequests } from '../../actions/accounts';
import { List as ImmutableList } from 'immutable';
import { Link } from 'react-router-dom';
import NavigationBar from '../compose/components/navigation_bar';
const messages = defineMessages({
heading: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' },
notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' },
public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' },
navigation_subheading: { id: 'column_subheading.navigation', defaultMessage: 'Navigation' },
settings_subheading: { id: 'column_subheading.settings', defaultMessage: 'Settings' },
community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
direct: { id: 'navigation_bar.direct', defaultMessage: 'Direct messages' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
sign_out: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
info: { id: 'navigation_bar.info', defaultMessage: 'Extended information' },
pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned toots' },
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
keyboard_shortcuts: { id: 'navigation_bar.keyboard_shortcuts', defaultMessage: 'Keyboard shortcuts' },
discover: { id: 'navigation_bar.discover', defaultMessage: 'Discover' },
personal: { id: 'navigation_bar.personal', defaultMessage: 'Personal' },
security: { id: 'navigation_bar.security', defaultMessage: 'Security' },
});
const mapStateToProps = state => ({
myAccount: state.getIn(['accounts', me]),
columns: state.getIn(['settings', 'columns']),
unreadFollowRequests: state.getIn(['user_lists', 'follow_requests', 'items'], ImmutableList()).size,
unreadNotifications: state.getIn(['notifications', 'unread']),
});
const mapDispatchToProps = dispatch => ({
@ -75,60 +75,73 @@ export default class GettingStarted extends ImmutablePureComponent {
}
render () {
const { intl, myAccount, columns, multiColumn, unreadFollowRequests, unreadNotifications } = this.props;
const { intl, myAccount, multiColumn, unreadFollowRequests } = this.props;
const navItems = [];
let i = 1;
let height = 0;
if (multiColumn) {
if (!columns.find(item => item.get('id') === 'HOME')) {
navItems.push(<ColumnLink key='0' icon='home' text={intl.formatMessage(messages.home_timeline)} to='/timelines/home' />);
}
navItems.push(
<ColumnSubheading key={i++} text={intl.formatMessage(messages.discover)} />,
<ColumnLink key={i++} icon='users' text={intl.formatMessage(messages.community_timeline)} to='/timelines/public/local' />,
<ColumnLink key={i++} icon='globe' text={intl.formatMessage(messages.public_timeline)} to='/timelines/public' />,
<ColumnSubheading key={i++} text={intl.formatMessage(messages.personal)} />
);
if (!columns.find(item => item.get('id') === 'NOTIFICATIONS')) {
navItems.push(<ColumnLink key='1' icon='bell' text={intl.formatMessage(messages.notifications)} badge={badgeDisplay(unreadNotifications)} to='/notifications' />);
}
if (!columns.find(item => item.get('id') === 'COMMUNITY')) {
navItems.push(<ColumnLink key='2' icon='users' text={intl.formatMessage(messages.community_timeline)} to='/timelines/public/local' />);
}
if (!columns.find(item => item.get('id') === 'PUBLIC')) {
navItems.push(<ColumnLink key='3' icon='globe' text={intl.formatMessage(messages.public_timeline)} to='/timelines/public' />);
}
height += 34*2 + 48*2;
}
navItems.push(
<ColumnLink key='4' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />,
<ColumnLink key='5' icon='bars' text={intl.formatMessage(messages.lists)} to='/lists' />
<ColumnLink key={i++} icon='envelope' text={intl.formatMessage(messages.direct)} to='/timelines/direct' />,
<ColumnLink key={i++} icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />,
<ColumnLink key={i++} icon='bars' text={intl.formatMessage(messages.lists)} to='/lists' />
);
height += 48*3;
if (myAccount.get('locked')) {
navItems.push(<ColumnLink key='6' icon='users' text={intl.formatMessage(messages.follow_requests)} badge={badgeDisplay(unreadFollowRequests, 40)} to='/follow_requests' />);
navItems.push(<ColumnLink key={i++} icon='users' text={intl.formatMessage(messages.follow_requests)} badge={badgeDisplay(unreadFollowRequests, 40)} to='/follow_requests' />);
height += 48;
}
if (multiColumn) {
navItems.push(<ColumnLink key='7' icon='question' text={intl.formatMessage(messages.keyboard_shortcuts)} to='/keyboard-shortcuts' />);
}
if (!multiColumn) {
navItems.push(
<ColumnSubheading key={i++} text={intl.formatMessage(messages.settings_subheading)} />,
<ColumnLink key={i++} icon='gears' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' />,
<ColumnLink key={i++} icon='lock' text={intl.formatMessage(messages.security)} href='/auth/edit' />
);
navItems.push(<ColumnLink key='8' icon='book' text={intl.formatMessage(messages.info)} href='/about/more' />);
height += 34 + 48*2;
}
return (
<Column icon='asterisk' heading={intl.formatMessage(messages.heading)} hideHeadingOnMobile>
<div className='getting-started__wrapper'>
<ColumnSubheading text={intl.formatMessage(messages.navigation_subheading)} />
<Column>
{multiColumn && <div className='column-header__wrapper'>
<h1 className='column-header'>
<button>
<i className='fa fa-bars fa-fw column-header__icon' />
<FormattedMessage id='getting_started.heading' defaultMessage='Getting started' />
</button>
</h1>
</div>}
<div className='getting-started__wrapper' style={{ height }}>
{!multiColumn && <NavigationBar account={myAccount} />}
{navItems}
<ColumnSubheading text={intl.formatMessage(messages.settings_subheading)} />
<ColumnLink icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />
<ColumnLink icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />
<ColumnLink icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />
<ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' />
<ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' />
</div>
<div className='static-content getting-started'>
<p>
<a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/FAQ.md' rel='noopener' target='_blank'><FormattedMessage id='getting_started.faq' defaultMessage='FAQ' /></a> • <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/User-guide.md' rel='noopener' target='_blank'><FormattedMessage id='getting_started.userguide' defaultMessage='User Guide' /></a> • <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md' rel='noopener' target='_blank'><FormattedMessage id='getting_started.appsshort' defaultMessage='Apps' /></a>
</p>
{!multiColumn && <div className='flex-spacer' />}
<div className='getting-started getting-started__footer'>
<ul>
{multiColumn && <li><Link to='/keyboard-shortcuts'><FormattedMessage id='navigation_bar.keyboard_shortcuts' defaultMessage='Hotkeys' /></Link> · </li>}
<li><a href='/about/more' target='_blank'><FormattedMessage id='navigation_bar.info' defaultMessage='About this instance' /></a> · </li>
<li><a href='/terms' target='_blank'><FormattedMessage id='getting_started.terms' defaultMessage='Terms of service' /></a> · </li>
<li><a href='https://github.com/tootsuite/documentation#documentation' target='_blank'><FormattedMessage id='getting_started.documentation' defaultMessage='Documentation' /></a> · </li>
<li><a href='/auth/sign_out' data-method='delete'><FormattedMessage id='navigation_bar.logout' defaultMessage='Logout' /></a></li>
</ul>
<p>
<FormattedMessage
id='getting_started.open_source_notice'

View file

@ -52,6 +52,10 @@ export default class KeyboardShortcuts extends ImmutablePureComponent {
<td><kbd>enter</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.enter' defaultMessage='to open status' /></td>
</tr>
<tr>
<td><kbd>x</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.toggle_hidden' defaultMessage='to show/hide text behind CW' /></td>
</tr>
<tr>
<td><kbd>up</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.up' defaultMessage='to move up in the list' /></td>

View file

@ -13,7 +13,7 @@ import { createSelector } from 'reselect';
import { List as ImmutableList } from 'immutable';
import { debounce } from 'lodash';
import ScrollableList from '../../components/scrollable_list';
import LoadMore from '../../components/load_more';
import LoadGap from '../../components/load_gap';
const messages = defineMessages({
title: { id: 'column.notifications', defaultMessage: 'Notifications' },
@ -24,24 +24,6 @@ const getNotifications = createSelector([
state => state.getIn(['notifications', 'items']),
], (excludedTypes, notifications) => notifications.filterNot(item => item !== null && excludedTypes.includes(item.get('type'))));
class LoadGap extends React.PureComponent {
static propTypes = {
disabled: PropTypes.bool,
maxId: PropTypes.string,
onClick: PropTypes.func.isRequired,
};
handleClick = () => {
this.props.onClick(this.props.maxId);
}
render () {
return <LoadMore onClick={this.handleClick} disabled={this.props.disabled} />;
}
}
const mapStateToProps = state => ({
notifications: getNotifications(state),
isLoading: state.getIn(['notifications', 'isLoading'], true),

View file

@ -1,42 +1,48 @@
import React from 'react';
import { connect } from 'react-redux';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import PropTypes from 'prop-types';
import StatusListContainer from '../ui/containers/status_list_container';
import Column from '../../components/column';
import ColumnHeader from '../../components/column_header';
import { expandPublicTimeline } from '../../actions/timelines';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { addColumn, removeColumn, moveColumn, changeColumnParams } from '../../actions/columns';
import ColumnSettingsContainer from './containers/column_settings_container';
import SectionHeadline from '../community_timeline/components/section_headline';
import { connectPublicStream } from '../../actions/streaming';
const messages = defineMessages({
title: { id: 'column.public', defaultMessage: 'Federated timeline' },
});
const mapStateToProps = state => ({
hasUnread: state.getIn(['timelines', 'public', 'unread']) > 0,
const mapStateToProps = (state, { onlyMedia }) => ({
hasUnread: state.getIn(['timelines', `public${onlyMedia ? ':media' : ''}`, 'unread']) > 0,
});
@connect(mapStateToProps)
@injectIntl
export default class PublicTimeline extends React.PureComponent {
static defaultProps = {
onlyMedia: false,
};
static propTypes = {
dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
columnId: PropTypes.string,
multiColumn: PropTypes.bool,
hasUnread: PropTypes.bool,
onlyMedia: PropTypes.bool,
};
handlePin = () => {
const { columnId, dispatch } = this.props;
const { columnId, dispatch, onlyMedia } = this.props;
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('PUBLIC', {}));
dispatch(addColumn('PUBLIC', { other: { onlyMedia } }));
}
}
@ -50,10 +56,20 @@ export default class PublicTimeline extends React.PureComponent {
}
componentDidMount () {
const { dispatch } = this.props;
const { dispatch, onlyMedia } = this.props;
dispatch(expandPublicTimeline());
this.disconnect = dispatch(connectPublicStream());
dispatch(expandPublicTimeline({ onlyMedia }));
this.disconnect = dispatch(connectPublicStream({ onlyMedia }));
}
componentDidUpdate (prevProps) {
if (prevProps.onlyMedia !== this.props.onlyMedia) {
const { dispatch, onlyMedia } = this.props;
this.disconnect();
dispatch(expandPublicTimeline({ onlyMedia }));
this.disconnect = dispatch(connectPublicStream({ onlyMedia }));
}
}
componentWillUnmount () {
@ -68,13 +84,32 @@ export default class PublicTimeline extends React.PureComponent {
}
handleLoadMore = maxId => {
this.props.dispatch(expandPublicTimeline({ maxId }));
const { dispatch, onlyMedia } = this.props;
dispatch(expandPublicTimeline({ maxId, onlyMedia }));
}
handleHeadlineLinkClick = e => {
const { columnId, dispatch } = this.props;
const onlyMedia = /\/media$/.test(e.currentTarget.href);
dispatch(changeColumnParams(columnId, { other: { onlyMedia } }));
}
render () {
const { intl, columnId, hasUnread, multiColumn } = this.props;
const { intl, columnId, hasUnread, multiColumn, onlyMedia } = this.props;
const pinned = !!columnId;
const headline = (
<SectionHeadline
timelineId='public'
to='/timelines/public'
pinned={pinned}
onlyMedia={onlyMedia}
onClick={this.handleHeadlineLinkClick}
/>
);
return (
<Column ref={this.setRef}>
<ColumnHeader
@ -91,7 +126,9 @@ export default class PublicTimeline extends React.PureComponent {
</ColumnHeader>
<StatusListContainer
timelineId='public'
prepend={headline}
alwaysPrepend
timelineId={`public${onlyMedia ? ':media' : ''}`}
onLoadMore={this.handleLoadMore}
trackScroll={!pinned}
scrollKey={`public_timeline-${columnId}`}

View file

@ -8,9 +8,13 @@ import { me } from '../../../initial_state';
const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' },
redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' },
direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' },
mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
reply: { id: 'status.reply', defaultMessage: 'Reply' },
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost to original audience' },
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
mute: { id: 'status.mute', defaultMessage: 'Mute @{name}' },
@ -37,6 +41,7 @@ export default class ActionBar extends React.PureComponent {
onReblog: PropTypes.func.isRequired,
onFavourite: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onDirect: PropTypes.func.isRequired,
onMention: PropTypes.func.isRequired,
onMute: PropTypes.func,
onMuteConversation: PropTypes.func,
@ -63,6 +68,14 @@ export default class ActionBar extends React.PureComponent {
this.props.onDelete(this.props.status);
}
handleRedraftClick = () => {
this.props.onDelete(this.props.status, true);
}
handleDirectClick = () => {
this.props.onDirect(this.props.status.get('account'), this.context.router.history);
}
handleMentionClick = () => {
this.props.onMention(this.props.status.get('account'), this.context.router.history);
}
@ -108,19 +121,26 @@ export default class ActionBar extends React.PureComponent {
if (publicStatus) {
menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
menu.push(null);
}
if (me === status.getIn(['account', 'id'])) {
if (publicStatus) {
menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
} else {
if (status.get('visibility') === 'private') {
menu.push({ text: intl.formatMessage(status.get('reblogged') ? messages.cancel_reblog_private : messages.reblog_private), action: this.handleReblogClick });
}
}
menu.push(null);
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick });
} else {
menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick });
menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick });
@ -141,7 +161,7 @@ export default class ActionBar extends React.PureComponent {
<div className='detailed-status__action-bar'>
<div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_id', null) === null ? 'reply' : 'reply-all'} onClick={this.handleReplyClick} /></div>
<div className='detailed-status__button'><IconButton disabled={reblog_disabled} active={status.get('reblogged')} title={reblog_disabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /></div>
<div className='detailed-status__button'><IconButton animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div>
<div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /></div>
{shareButton}
<div className='detailed-status__action-bar-dropdown'>

View file

@ -34,8 +34,8 @@ export default class DetailedStatus extends ImmutablePureComponent {
e.stopPropagation();
}
handleOpenVideo = startTime => {
this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime);
handleOpenVideo = (media, startTime) => {
this.props.onOpenVideo(media, startTime);
}
handleExpandedToggle = () => {

View file

@ -1,3 +1,4 @@
import Immutable from 'immutable';
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
@ -19,6 +20,7 @@ import {
import {
replyCompose,
mentionCompose,
directCompose,
} from '../../actions/compose';
import { blockAccount } from '../../actions/accounts';
import {
@ -45,6 +47,8 @@ import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from
const messages = defineMessages({
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' },
redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? You will lose all replies, boosts and favourites to it.' },
blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' },
revealAll: { id: 'status.show_more_all', defaultMessage: 'Show more for all' },
hideAll: { id: 'status.show_less_all', defaultMessage: 'Show less for all' },
@ -53,11 +57,47 @@ const messages = defineMessages({
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const mapStateToProps = (state, props) => ({
status: getStatus(state, props.params.statusId),
ancestorsIds: state.getIn(['contexts', 'ancestors', props.params.statusId]),
descendantsIds: state.getIn(['contexts', 'descendants', props.params.statusId]),
});
const mapStateToProps = (state, props) => {
const status = getStatus(state, props.params.statusId);
let ancestorsIds = Immutable.List();
let descendantsIds = Immutable.List();
if (status) {
ancestorsIds = ancestorsIds.withMutations(mutable => {
let id = status.get('in_reply_to_id');
while (id) {
mutable.unshift(id);
id = state.getIn(['contexts', 'inReplyTos', id]);
}
});
descendantsIds = descendantsIds.withMutations(mutable => {
const ids = [status.get('id')];
while (ids.length > 0) {
let id = ids.shift();
const replies = state.getIn(['contexts', 'replies', id]);
if (status.get('id') !== id) {
mutable.push(id);
}
if (replies) {
replies.reverse().forEach(reply => {
ids.unshift(reply);
});
}
}
});
}
return {
status,
ancestorsIds,
descendantsIds,
};
};
return mapStateToProps;
};
@ -134,20 +174,24 @@ export default class Status extends ImmutablePureComponent {
}
}
handleDeleteClick = (status) => {
handleDeleteClick = (status, withRedraft = false) => {
const { dispatch, intl } = this.props;
if (!deleteModal) {
dispatch(deleteStatus(status.get('id')));
dispatch(deleteStatus(status.get('id'), withRedraft));
} else {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.deleteMessage),
confirm: intl.formatMessage(messages.deleteConfirm),
onConfirm: () => dispatch(deleteStatus(status.get('id'))),
message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage),
confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm),
onConfirm: () => dispatch(deleteStatus(status.get('id'), withRedraft)),
}));
}
}
handleDirectClick = (account, router) => {
this.props.dispatch(directCompose(account, router));
}
handleMentionClick = (account, router) => {
this.props.dispatch(mentionCompose(account, router));
}
@ -239,6 +283,10 @@ export default class Status extends ImmutablePureComponent {
this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
}
handleHotkeyToggleHidden = () => {
this.handleToggleHidden(this.props.status);
}
handleMoveUp = id => {
const { status, ancestorsIds, descendantsIds } = this.props;
@ -349,6 +397,7 @@ export default class Status extends ImmutablePureComponent {
boost: this.handleHotkeyBoost,
mention: this.handleHotkeyMention,
openProfile: this.handleHotkeyOpenProfile,
toggleHidden: this.handleHotkeyToggleHidden,
};
return (
@ -379,6 +428,7 @@ export default class Status extends ImmutablePureComponent {
onFavourite={this.handleFavouriteClick}
onReblog={this.handleReblogClick}
onDelete={this.handleDeleteClick}
onDirect={this.handleDirectClick}
onMention={this.handleMentionClick}
onMute={this.handleMuteClick}
onMuteConversation={this.handleConversationMuteClick}

View file

@ -0,0 +1,66 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { injectIntl, defineMessages } from 'react-intl';
import Column from '../ui/components/column';
import ColumnHeader from '../../components/column_header';
import Hashtag from '../../components/hashtag';
import classNames from 'classnames';
import { fetchTrends } from '../../actions/trends';
const messages = defineMessages({
title: { id: 'trends.header', defaultMessage: 'Trending now' },
refreshTrends: { id: 'trends.refresh', defaultMessage: 'Refresh trends' },
});
const mapStateToProps = state => ({
trends: state.getIn(['trends', 'items']),
loading: state.getIn(['trends', 'isLoading']),
});
const mapDispatchToProps = dispatch => ({
fetchTrends: () => dispatch(fetchTrends()),
});
@connect(mapStateToProps, mapDispatchToProps)
@injectIntl
export default class Trends extends ImmutablePureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
trends: ImmutablePropTypes.list,
fetchTrends: PropTypes.func.isRequired,
loading: PropTypes.bool,
};
componentDidMount () {
this.props.fetchTrends();
}
handleRefresh = () => {
this.props.fetchTrends();
}
render () {
const { trends, loading, intl } = this.props;
return (
<Column>
<ColumnHeader
icon='fire'
title={intl.formatMessage(messages.title)}
extraButton={(
<button className='column-header__button' title={intl.formatMessage(messages.refreshTrends)} aria-label={intl.formatMessage(messages.refreshTrends)} onClick={this.handleRefresh}><i className={classNames('fa', 'fa-refresh', { 'fa-spin': loading })} /></button>
)}
/>
<div className='scrollable'>
{trends && trends.map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
</div>
</Column>
);
}
}

View file

@ -27,7 +27,7 @@ export default class ActionsModal extends ImmutablePureComponent {
return (
<li key={`${text}-${i}`}>
<a href={href} target='_blank' rel='noopener' onClick={this.props.onClick} data-index={i} className={classNames({ active })}>
{icon && <IconButton title={text} icon={icon} role='presentation' tabIndex='-1' />}
{icon && <IconButton title={text} icon={icon} role='presentation' tabIndex='-1' inverted />}
<div>
<div className={classNames({ 'actions-modal__item-label': !!meta })}>{text}</div>
<div>{meta}</div>

View file

@ -12,7 +12,7 @@ import BundleContainer from '../containers/bundle_container';
import ColumnLoading from './column_loading';
import DrawerLoading from './drawer_loading';
import BundleColumnError from './bundle_column_error';
import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, FavouritedStatuses, ListTimeline } from '../../ui/util/async-components';
import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, DirectTimeline, FavouritedStatuses, ListTimeline } from '../../ui/util/async-components';
import detectPassiveEvents from 'detect-passive-events';
import { scrollRight } from '../../../scroll';
@ -24,6 +24,7 @@ const componentMap = {
'PUBLIC': PublicTimeline,
'COMMUNITY': CommunityTimeline,
'HASHTAG': HashtagTimeline,
'DIRECT': DirectTimeline,
'FAVOURITES': FavouritedStatuses,
'LIST': ListTimeline,
};
@ -174,10 +175,11 @@ export default class ColumnsArea extends ImmutablePureComponent {
<div className={`columns-area ${ isModalOpen ? 'unscrollable' : '' }`} ref={this.setRef}>
{columns.map(column => {
const params = column.get('params', null) === null ? null : column.get('params').toJS();
const other = params && params.other ? params.other : {};
return (
<BundleContainer key={column.get('uuid')} fetchComponent={componentMap[column.get('id')]} loading={this.renderLoading(column.get('id'))} error={this.renderError}>
{SpecificComponent => <SpecificComponent columnId={column.get('uuid')} params={params} multiColumn />}
{SpecificComponent => <SpecificComponent columnId={column.get('uuid')} params={params} multiColumn {...other} />}
</BundleContainer>
);
})}

View file

@ -10,6 +10,7 @@ export default class EmbedModal extends ImmutablePureComponent {
static propTypes = {
url: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
onError: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
}
@ -35,6 +36,8 @@ export default class EmbedModal extends ImmutablePureComponent {
iframeDocument.body.style.margin = 0;
this.iframe.width = iframeDocument.body.scrollWidth;
this.iframe.height = iframeDocument.body.scrollHeight;
}).catch(error => {
this.props.onError(error);
});
}

View file

@ -2,6 +2,7 @@ import React from 'react';
import ReactSwipeableViews from 'react-swipeable-views';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import Video from '../../video';
import ExtendedVideoPlayer from '../../../components/extended_video_player';
import classNames from 'classnames';
import { defineMessages, injectIntl } from 'react-intl';
@ -112,6 +113,22 @@ export default class MediaModal extends ImmutablePureComponent {
onClick={this.toggleNavigation}
/>
);
} else if (image.get('type') === 'video') {
const { time } = this.props;
return (
<Video
preview={image.get('preview_url')}
src={image.get('url')}
width={image.get('width')}
height={image.get('height')}
startTime={time || 0}
onCloseVideo={onClose}
detailed
description={image.get('description')}
key={image.get('url')}
/>
);
} else if (image.get('type') === 'gifv') {
return (
<ExtendedVideoPlayer

View file

@ -40,6 +40,17 @@ export default class ModalRoot extends React.PureComponent {
onClose: PropTypes.func.isRequired,
};
getSnapshotBeforeUpdate () {
const visible = !!this.props.type;
return {
overflowY: visible ? 'hidden' : null,
};
}
componentDidUpdate (prevProps, prevState, { overflowY }) {
document.body.style.overflowY = overflowY;
}
renderLoading = modalId => () => {
return ['MEDIA', 'VIDEO', 'BOOST', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? <ModalLoading /> : null;
}

View file

@ -30,7 +30,7 @@ const makeMapStateToProps = () => {
account: getAccount(state, accountId),
comment: state.getIn(['reports', 'new', 'comment']),
forward: state.getIn(['reports', 'new', 'forward']),
statusIds: OrderedSet(state.getIn(['timelines', `account:${accountId}`, 'items'])).union(state.getIn(['reports', 'new', 'status_ids'])),
statusIds: OrderedSet(state.getIn(['timelines', `account:${accountId}:with_replies`, 'items'])).union(state.getIn(['reports', 'new', 'status_ids'])),
};
};
@ -63,13 +63,19 @@ export default class ReportModal extends ImmutablePureComponent {
this.props.dispatch(submitReport());
}
handleKeyDown = e => {
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
this.handleSubmit();
}
}
componentDidMount () {
this.props.dispatch(expandAccountTimeline(this.props.account.get('id')));
this.props.dispatch(expandAccountTimeline(this.props.account.get('id'), { withReplies: true }));
}
componentWillReceiveProps (nextProps) {
if (this.props.account !== nextProps.account && nextProps.account) {
this.props.dispatch(expandAccountTimeline(nextProps.account.get('id')));
this.props.dispatch(expandAccountTimeline(nextProps.account.get('id'), { withReplies: true }));
}
}
@ -98,6 +104,7 @@ export default class ReportModal extends ImmutablePureComponent {
placeholder={intl.formatMessage(messages.placeholder)}
value={comment}
onChange={this.handleCommentChange}
onKeyDown={this.handleKeyDown}
disabled={isSubmitting}
/>

View file

@ -8,6 +8,7 @@ import { isUserTouching } from '../../../is_mobile';
export const links = [
<NavLink className='tabs-bar__link primary' to='/timelines/home' data-preview-title-id='column.home' data-preview-icon='home' ><i className='fa fa-fw fa-home' /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>,
<NavLink className='tabs-bar__link primary' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><i className='fa fa-fw fa-bell' /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></NavLink>,
<NavLink className='tabs-bar__link primary' to='/search' data-preview-title-id='tabs_bar.search' data-preview-icon='bell' ><i className='fa fa-fw fa-search' /><FormattedMessage id='tabs_bar.search' defaultMessage='Search' /></NavLink>,
<NavLink className='tabs-bar__link secondary' to='/timelines/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><i className='fa fa-fw fa-users' /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>,
<NavLink className='tabs-bar__link secondary' exact to='/timelines/public' data-preview-title-id='column.public' data-preview-icon='globe' ><i className='fa fa-fw fa-globe' /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>,

View file

@ -21,6 +21,8 @@ const makeGetStatusIds = () => createSelector([
}
return statusIds.filter(id => {
if (id === null) return true;
const statusForId = statuses.get(id);
let showStatus = true;

View file

@ -30,6 +30,7 @@ import {
Following,
Reblogs,
Favourites,
DirectTimeline,
HashtagTimeline,
Notifications,
FollowRequests,
@ -37,6 +38,7 @@ import {
FavouritedStatuses,
ListTimeline,
Blocks,
DomainBlocks,
Mutes,
PinnedStatuses,
Lists,
@ -78,12 +80,14 @@ const keyMap = {
goToNotifications: 'g n',
goToLocal: 'g l',
goToFederated: 'g t',
goToDirect: 'g d',
goToStart: 'g s',
goToFavourites: 'g f',
goToPinned: 'g p',
goToProfile: 'g u',
goToBlocked: 'g b',
goToMuted: 'g m',
toggleHidden: 'x',
};
class SwitchingColumnsArea extends React.PureComponent {
@ -128,16 +132,20 @@ class SwitchingColumnsArea extends React.PureComponent {
render () {
const { children } = this.props;
const { mobile } = this.state;
const redirect = mobile ? <Redirect from='/' to='/timelines/home' exact /> : <Redirect from='/' to='/getting-started' exact />;
return (
<ColumnsAreaContainer ref={this.setRef} singleColumn={mobile}>
<WrappedSwitch>
<Redirect from='/' to='/getting-started' exact />
{redirect}
<WrappedRoute path='/getting-started' component={GettingStarted} content={children} />
<WrappedRoute path='/keyboard-shortcuts' component={KeyboardShortcuts} 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/public/media' component={PublicTimeline} content={children} componentParams={{ onlyMedia: true }} />
<WrappedRoute path='/timelines/public/local' exact component={CommunityTimeline} content={children} />
<WrappedRoute path='/timelines/public/local/media' component={CommunityTimeline} content={children} componentParams={{ onlyMedia: true }} />
<WrappedRoute path='/timelines/direct' component={DirectTimeline} content={children} />
<WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} />
<WrappedRoute path='/timelines/list/:id' component={ListTimeline} content={children} />
@ -145,6 +153,8 @@ class SwitchingColumnsArea extends React.PureComponent {
<WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
<WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
<WrappedRoute path='/search' component={Compose} content={children} componentParams={{ isSearchPage: true }} />
<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} />
@ -158,6 +168,7 @@ class SwitchingColumnsArea extends React.PureComponent {
<WrappedRoute path='/follow_requests' component={FollowRequests} content={children} />
<WrappedRoute path='/blocks' component={Blocks} content={children} />
<WrappedRoute path='/domain_blocks' component={DomainBlocks} content={children} />
<WrappedRoute path='/mutes' component={Mutes} content={children} />
<WrappedRoute path='/lists' component={Lists} content={children} />
@ -381,6 +392,10 @@ export default class UI extends React.PureComponent {
this.context.router.history.push('/timelines/public');
}
handleHotkeyGoToDirect = () => {
this.context.router.history.push('/timelines/direct');
}
handleHotkeyGoToStart = () => {
this.context.router.history.push('/getting-started');
}
@ -420,6 +435,7 @@ export default class UI extends React.PureComponent {
goToNotifications: this.handleHotkeyGoToNotifications,
goToLocal: this.handleHotkeyGoToLocal,
goToFederated: this.handleHotkeyGoToFederated,
goToDirect: this.handleHotkeyGoToDirect,
goToStart: this.handleHotkeyGoToStart,
goToFavourites: this.handleHotkeyGoToFavourites,
goToPinned: this.handleHotkeyGoToPinned,

View file

@ -26,6 +26,10 @@ export function HashtagTimeline () {
return import(/* webpackChunkName: "features/hashtag_timeline" */'../../hashtag_timeline');
}
export function DirectTimeline() {
return import(/* webpackChunkName: "features/direct_timeline" */'../../direct_timeline');
}
export function ListTimeline () {
return import(/* webpackChunkName: "features/list_timeline" */'../../list_timeline');
}
@ -90,6 +94,10 @@ export function Blocks () {
return import(/* webpackChunkName: "features/blocks" */'../../blocks');
}
export function DomainBlocks () {
return import(/* webpackChunkName: "features/domain_blocks" */'../../domain_blocks');
}
export function Mutes () {
return import(/* webpackChunkName: "features/mutes" */'../../mutes');
}

View file

@ -1,6 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { fromJS } from 'immutable';
import { throttle } from 'lodash';
import classNames from 'classnames';
import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen';
@ -131,6 +132,8 @@ export default class Video extends React.PureComponent {
this.seek = c;
}
handleClickRoot = e => e.stopPropagation();
handlePlay = () => {
this.setState({ paused: false });
}
@ -244,8 +247,17 @@ export default class Video extends React.PureComponent {
}
handleOpenVideo = () => {
const { src, preview, width, height } = this.props;
const media = fromJS({
type: 'video',
url: src,
preview_url: preview,
width,
height,
});
this.video.pause();
this.props.onOpenVideo(this.video.currentTime);
this.props.onOpenVideo(media, this.video.currentTime);
}
handleCloseVideo = () => {
@ -270,7 +282,16 @@ export default class Video extends React.PureComponent {
}
return (
<div className={classNames('video-player', { inactive: !revealed, detailed, inline: inline && !fullscreen, fullscreen })} style={playerStyle} ref={this.setPlayerRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
<div
role='menuitem'
className={classNames('video-player', { inactive: !revealed, detailed, inline: inline && !fullscreen, fullscreen })}
style={playerStyle}
ref={this.setPlayerRef}
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
onClick={this.handleClickRoot}
tabIndex={0}
>
<video
ref={this.setVideoRef}
src={src}