WIP <Compose> Refactor; <Drawer> ed.

main
kibigo! 2017-12-26 16:54:28 -08:00
parent 924ffe81d4
commit 3c29f57404
18 changed files with 723 additions and 455 deletions

View File

@ -2,9 +2,6 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { withRouter } from 'react-router';
// Actions.
import {
@ -43,7 +40,7 @@ import { countableText } from 'flavours/glitch/util/counter';
import { me } from 'flavours/glitch/util/initial_state';
import { isMobile } from 'flavours/glitch/util/is_mobile';
import { assignHandlers } from 'flavours/glitch/util/react_helpers';
import { mergeProps } from 'flavours/glitch/util/redux_helpers';
import { wrap } from 'flavours/glitch/util/redux_helpers';
// State mapping.
function mapStateToProps (state) {
@ -204,9 +201,7 @@ const handlers = {
};
// The component.
@injectIntl
@connect(mapStateToProps, mapDispatchToProps, mergeProps)
export default class Composer extends React.Component {
class Composer extends React.Component {
// Constructor.
constructor (props) {
@ -408,7 +403,7 @@ export default class Composer extends React.Component {
// Context
Composer.contextTypes = {
history: PropTypes.object,
}
};
// Props.
Composer.propTypes = {
@ -438,3 +433,7 @@ Composer.propTypes = {
text: PropTypes.string,
}).isRequired,
};
// Connecting and export.
export { Composer as WrappedComponent };
export default wrap(Composer, mapStateToProps, mapDispatchToProps, true);

View File

@ -70,7 +70,7 @@ const handlers = {
// dropdown.
if (onModalClose && isUserTouching()) {
if (open) {
onModalClose()
onModalClose();
} else if (onChange && onModalOpen) {
onModalOpen({
actions: items.map(

View File

@ -1,38 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Avatar from 'flavours/glitch/components/avatar';
import IconButton from 'flavours/glitch/components/icon_button';
import Permalink from 'flavours/glitch/components/permalink';
import { FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
export default class NavigationBar extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
onClose: PropTypes.func.isRequired,
};
render () {
return (
<div className='navigation-bar'>
<Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
<span style={{ display: 'none' }}>{this.props.account.get('acct')}</span>
<Avatar account={this.props.account} size={40} />
</Permalink>
<div className='navigation-bar__profile'>
<Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
<strong className='navigation-bar__profile-account'>@{this.props.account.get('acct')}</strong>
</Permalink>
<a href='/settings/profile' className='navigation-bar__profile-edit'><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a>
</div>
<IconButton title='' icon='close' onClick={this.props.onClose} />
</div>
);
}
}

View File

@ -1,129 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import Overlay from 'react-overlays/lib/Overlay';
import Motion from 'flavours/glitch/util/optional_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 {
static propTypes = {
value: PropTypes.string.isRequired,
submitted: PropTypes.bool,
onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
onClear: PropTypes.func.isRequired,
onShow: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
state = {
expanded: false,
};
handleChange = (e) => {
this.props.onChange(e.target.value);
}
handleClear = (e) => {
e.preventDefault();
if (this.props.value.length > 0 || this.props.submitted) {
this.props.onClear();
}
}
handleKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.props.onSubmit();
} else if (e.key === 'Escape') {
document.querySelector('.ui').parentElement.focus();
}
}
noop () {
}
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 (
<div className='search'>
<label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.placeholder)}</span>
<input
className='search__input'
type='text'
placeholder={intl.formatMessage(messages.placeholder)}
value={value}
onChange={this.handleChange}
onKeyUp={this.handleKeyDown}
onFocus={this.handleFocus}
onBlur={this.handleBlur}
/>
</label>
<div role='button' tabIndex='0' className='search__icon' onClick={this.handleClear}>
<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>
);
}
}

View File

@ -1,65 +0,0 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { FormattedMessage } from 'react-intl';
import AccountContainer from 'flavours/glitch/containers/account_container';
import StatusContainer from 'flavours/glitch/containers/status_container';
import { Link } from 'react-router-dom';
import ImmutablePureComponent from 'react-immutable-pure-component';
export default class SearchResults extends ImmutablePureComponent {
static propTypes = {
results: ImmutablePropTypes.map.isRequired,
};
render () {
const { results } = this.props;
let accounts, statuses, hashtags;
let count = 0;
if (results.get('accounts') && results.get('accounts').size > 0) {
count += results.get('accounts').size;
accounts = (
<div className='search-results__section'>
{results.get('accounts').map(accountId => <AccountContainer key={accountId} id={accountId} />)}
</div>
);
}
if (results.get('statuses') && results.get('statuses').size > 0) {
count += results.get('statuses').size;
statuses = (
<div className='search-results__section'>
{results.get('statuses').map(statusId => <StatusContainer key={statusId} id={statusId} />)}
</div>
);
}
if (results.get('hashtags') && results.get('hashtags').size > 0) {
count += results.get('hashtags').size;
hashtags = (
<div className='search-results__section'>
{results.get('hashtags').map(hashtag =>
<Link key={hashtag} className='search-results__hashtag' to={`/timelines/tag/${hashtag}`}>
#{hashtag}
</Link>
)}
</div>
);
}
return (
<div className='search-results'>
<div className='search-results__header'>
<FormattedMessage id='search_results.total' defaultMessage='{count, number} {count, plural, one {result} other {results}}' values={{ count }} />
</div>
{accounts}
{statuses}
{hashtags}
</div>
);
}
}

View File

@ -1,11 +0,0 @@
import { connect } from 'react-redux';
import NavigationBar from '../components/navigation_bar';
import { me } from 'flavours/glitch/util/initial_state';
const mapStateToProps = state => {
return {
account: state.getIn(['accounts', me]),
};
};
export default connect(mapStateToProps)(NavigationBar);

View File

@ -1,35 +0,0 @@
import { connect } from 'react-redux';
import {
changeSearch,
clearSearch,
submitSearch,
showSearch,
} from 'flavours/glitch/actions/search';
import Search from '../components/search';
const mapStateToProps = state => ({
value: state.getIn(['search', 'value']),
submitted: state.getIn(['search', 'submitted']),
});
const mapDispatchToProps = dispatch => ({
onChange (value) {
dispatch(changeSearch(value));
},
onClear () {
dispatch(clearSearch());
},
onSubmit () {
dispatch(submitSearch());
},
onShow () {
dispatch(showSearch());
},
});
export default connect(mapStateToProps, mapDispatchToProps)(Search);

View File

@ -1,8 +0,0 @@
import { connect } from 'react-redux';
import SearchResults from '../components/search_results';
const mapStateToProps = state => ({
results: state.getIn(['search', 'results']),
});
export default connect(mapStateToProps)(SearchResults);

View File

@ -0,0 +1,117 @@
// Package imports.
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages } from 'react-intl';
import { Link } from 'react-router-dom';
// Components.
import Icon from 'flavours/glitch/components/icon';
// Utils.
import { conditionalRender } from 'flavours/glitch/util/react_helpers';
// Messages.
const messages = defineMessages({
community: {
defaultMessage: 'Local timeline',
id: 'navigation_bar.community_timeline',
},
home_timeline: {
defaultMessage: 'Home',
id: 'tabs_bar.home',
},
logout: {
defaultMessage: 'Logout',
id: 'navigation_bar.logout',
},
notifications: {
defaultMessage: 'Notifications',
id: 'tabs_bar.notifications',
},
public: {
defaultMessage: 'Federated timeline',
id: 'navigation_bar.public_timeline',
},
settings: {
defaultMessage: 'App settings',
id: 'navigation_bar.app_settings',
},
start: {
defaultMessage: 'Getting started',
id: 'getting_started.heading',
},
});
// The component.
export default function DrawerHeader ({
columns,
intl,
onSettingsClick,
}) {
// Only renders the component if the column isn't being shown.
const renderForColumn = conditionalRender.bind(
columnId => !columns || !columns.some(
column => column.get('id') === columnId
)
);
// The result.
return (
<nav className='drawer--header'>
<Link
aria-label={intl.formatMessage(messages.start)}
title={intl.formatMessage(messages.start)}
to='/getting-started'
><Icon icon='asterisk' /></Link>
{renderForColumn('HOME', (
<Link
aria-label={intl.formatMessage(messages.home_timeline)}
title={intl.formatMessage(messages.home_timeline)}
to='/timelines/home'
><Icon icon='home' /></Link>
))}
{renderForColumn('NOTIFICATIONS', (
<Link
aria-label={intl.formatMessage(messages.notifications)}
title={intl.formatMessage(messages.notifications)}
to='/notifications'
><Icon icon='bell' /></Link>
))}
{renderForColumn('COMMUNITY', (
<Link
aria-label={intl.formatMessage(messages.community)}
title={intl.formatMessage(messages.community)}
to='/timelines/public/local'
><Icon icon='users' /></Link>
))}
{renderForColumn('PUBLIC', (
<Link
aria-label={intl.formatMessage(messages.public)}
title={intl.formatMessage(messages.public)}
to='/timelines/public'
><Icon icon='globe' /></Link>
))}
<a
aria-label={intl.formatMessage(messages.settings)}
onClick={onSettingsClick}
role='button'
title={intl.formatMessage(messages.settings)}
tabIndex='0'
><Icon icon='cogs' /></a>
<a
aria-label={intl.formatMessage(messages.logout)}
data-method='delete'
href='/auth/sign_out'
title={intl.formatMessage(messages.logout)}
><Icon icon='sign-out' /></a>
</nav>
);
}
DrawerHeader.propTypes = {
columns: ImmutablePropTypes.list,
intl: PropTypes.object,
onSettingsClick: PropTypes.func,
};

View File

@ -2,197 +2,147 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { injectIntl, defineMessages } from 'react-intl';
import spring from 'react-motion/lib/spring';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
// Actions.
import { changeComposing } from 'flavours/glitch/actions/compose';
import { changeLocalSetting } from 'flavours/glitch/actions/local_settings';
import { openModal } from 'flavours/glitch/actions/modal';
import {
changeSearch,
clearSearch,
showSearch,
submitSearch,
} from 'flavours/glitch/actions/search';
// Components.
import Icon from 'flavours/glitch/components/icon';
import Compose from 'flavours/glitch/features/compose';
import NavigationContainer from './containers/navigation_container';
import SearchContainer from './containers/search_container';
import SearchResultsContainer from './containers/search_results_container';
import DrawerHeader from './header';
import DrawerPager from './pager';
import DrawerResults from './results';
import DrawerSearch from './search';
// Utils.
import Motion from 'flavours/glitch/util/optional_motion';
import {
assignHandlers,
conditionalRender,
} from 'flavours/glitch/util/react_helpers';
// Messages.
const messages = defineMessages({
community: {
defaultMessage: 'Local timeline',
id: 'navigation_bar.community_timeline',
},
home_timeline: {
defaultMessage: 'Home',
id: 'tabs_bar.home',
},
logout: {
defaultMessage: 'Logout',
id: 'navigation_bar.logout',
},
notifications: {
defaultMessage: 'Notifications',
id: 'tabs_bar.notifications',
},
public: {
defaultMessage: 'Federated timeline',
id: 'navigation_bar.public_timeline',
},
settings: {
defaultMessage: 'App settings',
id: 'navigation_bar.app_settings',
},
start: {
defaultMessage: 'Getting started',
id: 'getting_started.heading',
},
});
import { me } from 'flavours/glitch/util/initial_state';
import { wrap } from 'flavours/glitch/util/redux_helpers';
// State mapping.
const mapStateToProps = state => ({
account: state.getIn(['accounts', me]),
columns: state.getIn(['settings', 'columns']),
showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
isComposing: state.getIn(['compose', 'is_composing']),
results: state.getIn(['search', 'results']),
searchHidden: state.getIn(['search', 'hidden']),
searchValue: state.getIn(['search', 'value']),
submitted: state.getIn(['search', 'submitted']),
});
// Dispatch mapping.
const mapDispatchToProps = dispatch => ({
onBlur () {
change (value) {
dispatch(changeSearch(value));
},
changeComposingOff () {
dispatch(changeComposing(false));
},
onFocus () {
changeComposingOn () {
dispatch(changeComposing(true));
},
onSettingsOpen () {
clear () {
dispatch(clearSearch());
},
show () {
dispatch(showSearch());
},
submit () {
dispatch(submitSearch());
},
openSettings () {
dispatch(openModal('SETTINGS', {}));
},
});
// The component.
@connect(mapStateToProps, mapDispatchToProps)
@injectIntl
export default function Drawer ({
columns,
class Drawer extends React.Component {
// Constructor.
constructor (props) {
super(props);
}
// Rendering.
render () {
const {
dispatch: {
change,
changeComposingOff,
changeComposingOn,
clear,
openSettings,
show,
submit,
},
intl,
multiColumn,
onBlur,
onFocus,
onSettingsOpen,
showSearch,
}) {
// Only renders the component if the column isn't being shown.
const renderForColumn = conditionalRender.bind(
columnId => !columns.some(column => column.get('id') === columnId)
);
state: {
account,
columns,
isComposing,
results,
searchHidden,
searchValue,
submitted,
},
} = this.props;
// The result.
return (
<div className='drawer'>
{multiColumn ? (
<nav className='drawer__header'>
<Link
aria-label={intl.formatMessage(messages.start)}
className='drawer__tab'
title={intl.formatMessage(messages.start)}
to='/getting-started'
><Icon icon='asterisk' /></Link>
{renderForColumn('HOME', (
<Link
aria-label={intl.formatMessage(messages.home_timeline)}
className='drawer__tab'
title={intl.formatMessage(messages.home_timeline)}
to='/timelines/home'
><Icon icon='home' /></Link>
))}
{renderForColumn('NOTIFICATIONS', (
<Link
aria-label={intl.formatMessage(messages.notifications)}
className='drawer__tab'
title={intl.formatMessage(messages.notifications)}
to='/notifications'
><Icon icon='bell' /></Link>
))}
{renderForColumn('COMMUNITY', (
<Link
aria-label={intl.formatMessage(messages.community)}
className='drawer__tab'
title={intl.formatMessage(messages.community)}
to='/timelines/public/local'
><Icon icon='users' /></Link>
))}
{renderForColumn('PUBLIC', (
<Link
aria-label={intl.formatMessage(messages.public)}
className='drawer__tab'
title={intl.formatMessage(messages.public)}
to='/timelines/public'
><Icon icon='globe' /></Link>
))}
<a
aria-label={intl.formatMessage(messages.settings)}
className='drawer__tab'
onClick={settings}
role='button'
title={intl.formatMessage(messages.settings)}
tabIndex='0'
><Icon icon='cogs' /></a>
<a
aria-label={intl.formatMessage(messages.logout)}
className='drawer__tab'
data-method='delete'
href='/auth/sign_out'
title={intl.formatMessage(messages.logout)}
><Icon icon='sign-out' /></a>
</nav>
<DrawerHeader
columns={columns}
intl={intl}
onSettingsClick={openSettings}
/>
) : null}
<SearchContainer />
<div className='drawer__pager'>
<div
className='drawer__inner scrollable optionally-scrollable'
onFocus={focus}
>
<NavigationContainer onClose={blur} />
<Compose />
</div>
<Motion
defaultStyle={{ x: -100 }}
style={{
x: spring(showSearch ? 0 : -100, {
stiffness: 210,
damping: 20,
})
}}
>
{({ x }) => (
<div
className='drawer__inner darker scrollable optionally-scrollable'
style={{
transform: `translateX(${x}%)`,
visibility: x === -100 ? 'hidden' : 'visible'
}}
><SearchResultsContainer /></div>
)}
</Motion>
</div>
<DrawerSearch
intl={intl}
onChange={change}
onClear={clear}
onShow={show}
onSubmit={submit}
submitted={submitted}
value={searchValue}
/>
<DrawerPager
account={account}
active={isComposing}
onBlur={changeComposingOff}
onFocus={changeComposingOn}
/>
<DrawerResults
results={results}
visible={submitted && !searchHidden}
/>
</div>
);
}
}
// Props.
Drawer.propTypes = {
dispatch: PropTypes.func.isRequired,
columns: ImmutablePropTypes.list.isRequired,
multiColumn: PropTypes.bool,
showSearch: PropTypes.bool,
intl: PropTypes.object.isRequired,
multiColumn: PropTypes.bool,
state: PropTypes.shape({
account: ImmutablePropTypes.map,
columns: ImmutablePropTypes.list,
isComposing: PropTypes.bool,
results: ImmutablePropTypes.map,
searchHidden: PropTypes.bool,
searchValue: PropTypes.string,
submitted: PropTypes.bool,
}).isRequired,
};
// Connecting and export.
export { Drawer as WrappedComponent };
export default wrap(Drawer, mapStateToProps, mapDispatchToProps, true);

View File

@ -0,0 +1,70 @@
// Package imports.
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import {
FormattedMessage,
defineMessages,
} from 'react-intl';
// Components.
import Avatar from 'flavours/glitch/components/avatar';
import Permalink from 'flavours/glitch/components/permalink';
// Utils.
import { hiddenComponent } from 'flavours/glitch/util/react_helpers';
// Messages.
const messages = defineMessages({
edit: {
defaultMessage: 'Edit profile',
id: 'navigation_bar.edit_profile',
},
});
// The component.
export default function DrawerPagerAccount ({ account }) {
// We need an account to render.
if (!account) {
return (
<div className='drawer--pager--account'>
<a
className='edit'
href='/settings/profile'
>
<FormattedMessage {...messages.edit} />
</a>
</div>
);
}
// The result.
return (
<div className='drawer--pager--account'>
<Permalink
className='avatar'
href={account.get('url')}
to={`/accounts/${account.get('id')}`}
>
<span {...hiddenComponent}>{account.get('acct')}</span>
<Avatar
account={account}
size={40}
/>
</Permalink>
<Permalink
className='acct'
href={account.get('url')}
to={`/accounts/${account.get('id')}`}
>
<strong>@{account.get('acct')}</strong>
</Permalink>
<a
className='edit'
href='/settings/profile'
><FormattedMessage {...messages.edit} /></a>
</div>
);
}
DrawerPagerAccount.propTypes = { account: ImmutablePropTypes.map };

View File

@ -0,0 +1,43 @@
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
// Components.
import IconButton from 'flavours/glitch/components/icon_button';
import Composer from 'flavours/glitch/features/composer';
import DrawerPagerAccount from './account';
// The component.
export default function DrawerPager ({
account,
active,
onClose,
onFocus,
}) {
const computedClass = classNames('drawer--pager', { active });
// The result.
return (
<div
className={computedClass}
onFocus={onFocus}
>
<DrawerPagerAccount account={account} />
<IconButton
icon='close'
onClick={onClose}
title=''
/>
<Composer />
</div>
);
}
DrawerPager.propTypes = {
account: ImmutablePropTypes.map,
active: PropTypes.bool,
onClose: PropTypes.func,
onFocus: PropTypes.func,
};

View File

@ -0,0 +1,114 @@
// Package imports.
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import {
FormattedMessage,
defineMessages,
} from 'react-intl';
import spring from 'react-motion/lib/spring';
import { Link } from 'react-router-dom';
// Components.
import AccountContainer from 'flavours/glitch/containers/account_container';
import StatusContainer from 'flavours/glitch/containers/status_container';
// Utils.
import Motion from 'flavours/glitch/util/optional_motion';
// Messages.
const messages = defineMessages({
total: {
defaultMessage: '{count, number} {count, plural, one {result} other {results}}',
id: 'search_results.total',
},
});
// The component.
export default function DrawerPager ({
results,
visible,
}) {
const accounts = results ? results.get('accounts') : null;
const statuses = results ? results.get('statuses') : null;
const hashtags = results ? results.get('hashtags') : null;
const count = [accounts, statuses, hashtags].reduce(function (size, item) {
if (item && item.size) {
return size + item.size;
}
return size;
}, 0);
// The result.
return (
<Motion
defaultStyle={{ x: -100 }}
style={{
x: spring(visible ? 0 : -100, {
stiffness: 210,
damping: 20,
}),
}}
>
{({ x }) => (
<div
className='drawer--results'
style={{
transform: `translateX(${x}%)`,
visibility: x === -100 ? 'hidden' : 'visible',
}}
>
<header>
<FormattedMessage
{...messages.total}
values={{ count }}
/>
</header>
{accounts && accounts.size ? (
<section>
{accounts.map(
accountId => (
<AccountContainer
id={accountId}
key={accountId}
/>
)
)}
</section>
) : null}
{statuses && statuses.size ? (
<section>
{statuses.map(
statusId => (
<StatusContainer
id={statusId}
key={statusId}
/>
)
)}
</section>
) : null}
{hashtags && hashtags.size ? (
<section>
{hashtags.map(
hashtag => (
<Link
className='hashtag'
key={hashtag}
to={`/timelines/tag/${hashtag}`}
>#{hashtag}</Link>
)
)}
</section>
) : null}
</div>
)}
</Motion>
);
}
DrawerPager.propTypes = {
results: ImmutablePropTypes.map,
visible: PropTypes.bool,
};

View File

@ -0,0 +1,149 @@
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import {
FormattedMessage,
defineMessages,
} from 'react-intl';
import Overlay from 'react-overlays/lib/Overlay';
// Components.
import Icon from 'flavours/glitch/components/icon';
import DrawerSearchPopout from './popout';
// Utils.
import { focusRoot } from 'flavours/glitch/util/dom_helpers';
import {
assignHandlers,
hiddenComponent,
} from 'flavours/glitch/util/react_helpers';
// Messages.
const messages = defineMessages({
placeholder: {
defaultMessage: 'Search',
id: 'search.placeholder',
},
});
// Handlers.
const handlers = {
blur () {
this.setState({ expanded: false });
},
change ({ target: { value } }) {
const { onChange } = this.props;
if (onChange) {
onChange(value);
}
},
clear (e) {
const {
onClear,
submitted,
value: { length },
} = this.props;
e.preventDefault(); // Prevents focus change ??
if (onClear && (submitted || length)) {
onClear();
}
},
focus () {
const { onShow } = this.props;
this.setState({ expanded: true });
if (onShow) {
onShow();
}
},
keyUp (e) {
const { onSubmit } = this.props;
switch (e.key) {
case 'Enter':
if (onSubmit) {
onSubmit();
}
break;
case 'Escape':
focusRoot();
}
},
};
// The component.
export default class DrawerSearch extends React.PureComponent {
constructor (props) {
super(props);
assignHandlers(this, handlers);
this.state = { expanded: false };
}
render () {
const {
blur,
change,
clear,
focus,
keyUp,
} = this.handlers;
const {
intl,
submitted,
value,
} = this.props;
const { expanded } = this.state;
const computedClass = classNames('drawer--search', { active: value.length || submitted });
return (
<div className={computedClass}>
<label>
<span {...hiddenComponent}>
<FormattedMessage {...messages.placeholder} />
</span>
<input
type='text'
placeholder={intl.formatMessage(messages.placeholder)}
value={value}
onChange={change}
onKeyUp={keyUp}
onFocus={focus}
onBlur={blur}
/>
</label>
<div
aria-label={intl.formatMessage(messages.placeholder)}
className='icon'
onClick={clear}
role='button'
tabIndex='0'
>
<Icon icon='search' />
<Icon icon='fa-times-circle' />
</div>
<Overlay
placement='bottom'
show={expanded && !value.length && !submitted}
target={this}
><DrawerSearchPopout /></Overlay>
</div>
);
}
}
DrawerSearch.propTypes = {
value: PropTypes.string,
submitted: PropTypes.bool,
onChange: PropTypes.func,
onSubmit: PropTypes.func,
onClear: PropTypes.func,
onShow: PropTypes.func,
intl: PropTypes.object,
};

View File

@ -0,0 +1,95 @@
// Package imports.
import PropTypes from 'prop-types';
import React from 'react';
import {
FormattedMessage,
defineMessages,
} from 'react-intl';
import spring from 'react-motion/lib/spring';
// Utils.
import Motion from 'flavours/glitch/util/optional_motion';
// Messages.
const messages = defineMessages({
format: {
defaultMessage: 'Advanced search format',
id: 'search_popout.search_format',
},
hashtag: {
defaultMessage: 'hashtag',
id: 'search_popout.tips.hashtag',
},
status: {
defaultMessage: 'status',
id: 'search_popout.tips.status',
},
text: {
defaultMessage: 'Simple text returns matching display names, usernames and hashtags',
id: 'search_popout.tips.text',
},
user: {
defaultMessage: 'user',
id: 'search_popout.tips.user',
},
});
const motionSpring = spring(1, { damping: 35, stiffness: 400 });
export default function DrawerSearchPopout ({ style }) {
return (
<Motion
defaultStyle={{
opacity: 0,
scaleX: 0.85,
scaleY: 0.75,
}}
style={{
opacity: motionSpring,
scaleX: motionSpring,
scaleY: motionSpring,
}}
>
{({ opacity, scaleX, scaleY }) => (
<div
className='drawer--search--popout'
style={{
...style,
position: 'absolute',
width: 285,
opacity: opacity,
transform: `scale(${scaleX}, ${scaleY})`,
}}
>
<h4><FormattedMessage {...messages.format} /></h4>
<ul>
<li>
<em>#example</em>
{' '}
<FormattedMessage {...messages.hashtag} />
</li>
<li>
<em>@username@domain</em>
{' '}
<FormattedMessage {...messages.user} />
</li>
<li>
<em>URL</em>
{' '}
<FormattedMessage {...messages.user} />
</li>
<li>
<em>URL</em>
{' '}
<FormattedMessage {...messages.status} />
</li>
</ul>
<FormattedMessage {...messages.text} />
</div>
)}
</Motion>
);
}
// Props.
DrawerSearchPopout.propTypes = { style: PropTypes.object };

View File

@ -4,3 +4,11 @@ import detectPassiveEvents from 'detect-passive-events';
// This will either be a passive lister options object (if passive
// events are supported), or `false`.
export const withPassive = detectPassiveEvents.hasSupport ? { passive: true } : false;
// Focuses the root element.
export function focusRoot () {
let e;
if (document && (e = document.querySelector('.ui')) && (e = e.parentElement)) {
e.focus();
}
}

View File

@ -14,7 +14,7 @@ export function assignHandlers (target, handlers) {
// This function only returns the component if the result of calling
// `test` with `data` is `true`. Useful with funciton binding.
export function conditionalRender (test, data, component) {
return test ? component : null;
return test(data) ? component : null;
}
// This object provides props to make the component not visible.

View File

@ -1,3 +1,6 @@
import { injectIntl } from 'react-intl';
import { connect } from 'react-redux';
// Merges react-redux props.
export function mergeProps (stateProps, dispatchProps, ownProps) {
Object.assign({}, ownProps, {
@ -5,3 +8,9 @@ export function mergeProps (stateProps, dispatchProps, ownProps) {
state: Object.assign({}, stateProps, ownProps.state || {}),
});
}
// Connects a component.
export function wrap (Component, mapStateToProps, mapDispatchToProps, options) {
const withIntl = typeof options === 'object' ? options.withIntl : !!options;
return (withIntl ? injectIntl : i => i)(connect(mapStateToProps, mapDispatchToProps, mergeProps)(Component));
}