Redesign the landing page, mount public timeline on it (#4122)
* Redesign the landing page, mount public timeline on it * Adjust the standalone mounted component to the lacking of router * Adjust auth layout pages to new design * Fix tests * Standalone public timeline polling every 5 seconds * Remove now obsolete translations * Add responsive design for new landing page * Address reviews * Add floating clouds behind frontpage form * Use access token from public page when available * Fix mentions and hashtags links, cursor on status content in standalone mode * Add footer link to source code * Fix errors on pages that don't embed the component, use classnames * Fix tests * Change anonymous autoPlayGif default to false * When gif autoplay is disabled, hover to play * Add option to hide the timeline preview * Slightly improve alt layout * Add elephant friend to new frontpage * Display "back to mastodon" in place of "login" when logged in on frontpage * Change polling time to 3s
This commit is contained in:
parent
8784bd79d0
commit
e19eefe219
68 changed files with 959 additions and 658 deletions
|
@ -14,6 +14,7 @@ export default class DropdownMenu extends React.PureComponent {
|
|||
size: PropTypes.number.isRequired,
|
||||
direction: PropTypes.string,
|
||||
ariaLabel: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
|
@ -68,9 +69,19 @@ export default class DropdownMenu extends React.PureComponent {
|
|||
}
|
||||
|
||||
render () {
|
||||
const { icon, items, size, direction, ariaLabel } = this.props;
|
||||
const { expanded } = this.state;
|
||||
const { icon, items, size, direction, ariaLabel, disabled } = this.props;
|
||||
const { expanded } = this.state;
|
||||
const directionClass = (direction === 'left') ? 'dropdown__left' : 'dropdown__right';
|
||||
const iconStyle = { fontSize: `${size}px`, width: `${size}px`, lineHeight: `${size}px` };
|
||||
const iconClassname = `fa fa-fw fa-${icon} dropdown__icon`;
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<div className='icon-button disabled' style={iconStyle} aria-label={ariaLabel}>
|
||||
<i className={iconClassname} aria-hidden />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const dropdownItems = expanded && (
|
||||
<ul className='dropdown__content-list'>
|
||||
|
@ -80,8 +91,8 @@ export default class DropdownMenu extends React.PureComponent {
|
|||
|
||||
return (
|
||||
<Dropdown ref={this.setRef} onShow={this.handleShow} onHide={this.handleHide}>
|
||||
<DropdownTrigger className='icon-button' style={{ fontSize: `${size}px`, width: `${size}px`, lineHeight: `${size}px` }} aria-label={ariaLabel}>
|
||||
<i className={`fa fa-fw fa-${icon} dropdown__icon`} aria-hidden />
|
||||
<DropdownTrigger className='icon-button' style={iconStyle} aria-label={ariaLabel}>
|
||||
<i className={iconClassname} aria-hidden />
|
||||
</DropdownTrigger>
|
||||
|
||||
<DropdownContent className={directionClass}>
|
||||
|
|
|
@ -11,18 +11,44 @@ const messages = defineMessages({
|
|||
|
||||
class Item extends React.PureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
attachment: ImmutablePropTypes.map.isRequired,
|
||||
index: PropTypes.number.isRequired,
|
||||
size: PropTypes.number.isRequired,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
autoPlayGif: PropTypes.bool.isRequired,
|
||||
autoPlayGif: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
autoPlayGif: false,
|
||||
};
|
||||
|
||||
handleMouseEnter = (e) => {
|
||||
if (this.hoverToPlay()) {
|
||||
e.target.play();
|
||||
}
|
||||
}
|
||||
|
||||
handleMouseLeave = (e) => {
|
||||
if (this.hoverToPlay()) {
|
||||
e.target.pause();
|
||||
e.target.currentTime = 0;
|
||||
}
|
||||
}
|
||||
|
||||
hoverToPlay () {
|
||||
const { attachment, autoPlayGif } = this.props;
|
||||
return !autoPlayGif && attachment.get('type') === 'gifv';
|
||||
}
|
||||
|
||||
handleClick = (e) => {
|
||||
const { index, onClick } = this.props;
|
||||
|
||||
if (e.button === 0) {
|
||||
if (this.context.router && e.button === 0) {
|
||||
e.preventDefault();
|
||||
onClick(index);
|
||||
}
|
||||
|
@ -116,6 +142,8 @@ class Item extends React.PureComponent {
|
|||
role='application'
|
||||
src={attachment.get('url')}
|
||||
onClick={this.handleClick}
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
autoPlay={autoPlay}
|
||||
loop
|
||||
muted
|
||||
|
@ -144,7 +172,11 @@ export default class MediaGallery extends React.PureComponent {
|
|||
height: PropTypes.number.isRequired,
|
||||
onOpenMedia: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
autoPlayGif: PropTypes.bool.isRequired,
|
||||
autoPlayGif: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
autoPlayGif: false,
|
||||
};
|
||||
|
||||
state = {
|
||||
|
|
|
@ -15,7 +15,7 @@ export default class Permalink extends React.PureComponent {
|
|||
};
|
||||
|
||||
handleClick = (e) => {
|
||||
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
this.context.router.history.push(this.props.to);
|
||||
}
|
||||
|
@ -25,7 +25,7 @@ export default class Permalink extends React.PureComponent {
|
|||
const { href, children, className, ...other } = this.props;
|
||||
|
||||
return (
|
||||
<a href={href} onClick={this.handleClick} {...other} className={`permalink${className ? ' ' + className : ''}`}>
|
||||
<a target='_blank' href={href} onClick={this.handleClick} {...other} className={`permalink${className ? ' ' + className : ''}`}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
|
|
|
@ -140,12 +140,16 @@ export default class Status extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
handleClick = () => {
|
||||
if (!this.context.router) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { status } = this.props;
|
||||
this.context.router.history.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`);
|
||||
}
|
||||
|
||||
handleAccountClick = (e) => {
|
||||
if (e.button === 0) {
|
||||
if (this.context.router && e.button === 0) {
|
||||
const id = Number(e.currentTarget.getAttribute('data-id'));
|
||||
e.preventDefault();
|
||||
this.context.router.history.push(`/accounts/${id}`);
|
||||
|
@ -236,7 +240,7 @@ export default class Status extends ImmutablePureComponent {
|
|||
<div className='status__info'>
|
||||
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
|
||||
|
||||
<a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name'>
|
||||
<a onClick={this.handleAccountClick} target='_blank' data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name'>
|
||||
<div className='status__avatar'>
|
||||
{statusAvatar}
|
||||
</div>
|
||||
|
|
|
@ -40,7 +40,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
|
|||
onBlock: PropTypes.func,
|
||||
onReport: PropTypes.func,
|
||||
onMuteConversation: PropTypes.func,
|
||||
me: PropTypes.number.isRequired,
|
||||
me: PropTypes.number,
|
||||
withDismiss: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
@ -97,6 +97,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
|
|||
const { status, me, intl, withDismiss } = this.props;
|
||||
const reblogDisabled = status.get('visibility') === 'private' || status.get('visibility') === 'direct';
|
||||
const mutingConversation = status.get('muted');
|
||||
const anonymousAccess = !me;
|
||||
|
||||
let menu = [];
|
||||
let reblogIcon = 'retweet';
|
||||
|
@ -137,12 +138,12 @@ export default class StatusActionBar extends ImmutablePureComponent {
|
|||
|
||||
return (
|
||||
<div className='status__action-bar'>
|
||||
<IconButton className='status__action-bar-button' title={replyTitle} icon={replyIcon} onClick={this.handleReplyClick} />
|
||||
<IconButton className='status__action-bar-button' disabled={reblogDisabled} active={status.get('reblogged')} title={reblogDisabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} />
|
||||
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
|
||||
<IconButton className='status__action-bar-button' disabled={anonymousAccess} title={replyTitle} icon={replyIcon} onClick={this.handleReplyClick} />
|
||||
<IconButton className='status__action-bar-button' disabled={anonymousAccess || reblogDisabled} active={status.get('reblogged')} title={reblogDisabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} />
|
||||
<IconButton className='status__action-bar-button star-icon' disabled={anonymousAccess} animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
|
||||
|
||||
<div className='status__action-bar-dropdown'>
|
||||
<DropdownMenu items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel='More' />
|
||||
<DropdownMenu disabled={anonymousAccess} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel='More' />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -6,6 +6,7 @@ import emojify from '../emoji';
|
|||
import { isRtl } from '../rtl';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import Permalink from './permalink';
|
||||
import classnames from 'classnames';
|
||||
|
||||
export default class StatusContent extends React.PureComponent {
|
||||
|
||||
|
@ -43,10 +44,11 @@ export default class StatusContent extends React.PureComponent {
|
|||
} else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
|
||||
link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
|
||||
} else {
|
||||
link.setAttribute('target', '_blank');
|
||||
link.setAttribute('rel', 'noopener');
|
||||
link.setAttribute('title', link.href);
|
||||
}
|
||||
|
||||
link.setAttribute('target', '_blank');
|
||||
link.setAttribute('rel', 'noopener');
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -59,7 +61,7 @@ export default class StatusContent extends React.PureComponent {
|
|||
}
|
||||
|
||||
onMentionClick = (mention, e) => {
|
||||
if (e.button === 0) {
|
||||
if (this.context.router && e.button === 0) {
|
||||
e.preventDefault();
|
||||
this.context.router.history.push(`/accounts/${mention.get('id')}`);
|
||||
}
|
||||
|
@ -68,7 +70,7 @@ export default class StatusContent extends React.PureComponent {
|
|||
onHashtagClick = (hashtag, e) => {
|
||||
hashtag = hashtag.replace(/^#/, '').toLowerCase();
|
||||
|
||||
if (e.button === 0) {
|
||||
if (this.context.router && e.button === 0) {
|
||||
e.preventDefault();
|
||||
this.context.router.history.push(`/timelines/tag/${hashtag}`);
|
||||
}
|
||||
|
@ -120,6 +122,9 @@ export default class StatusContent extends React.PureComponent {
|
|||
const content = { __html: emojify(status.get('content')) };
|
||||
const spoilerContent = { __html: emojify(escapeTextContentForBrowser(status.get('spoiler_text', ''))) };
|
||||
const directionStyle = { direction: 'ltr' };
|
||||
const classNames = classnames('status__content', {
|
||||
'status__content--with-action': this.props.onClick && this.context.router,
|
||||
});
|
||||
|
||||
if (isRtl(status.get('search_index'))) {
|
||||
directionStyle.direction = 'rtl';
|
||||
|
@ -141,7 +146,7 @@ export default class StatusContent extends React.PureComponent {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className='status__content status__content--with-action' ref={this.setRef} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
|
||||
<div className={classNames} ref={this.setRef} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
|
||||
<p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}>
|
||||
<span dangerouslySetInnerHTML={spoilerContent} />
|
||||
{' '}
|
||||
|
@ -157,7 +162,7 @@ export default class StatusContent extends React.PureComponent {
|
|||
return (
|
||||
<div
|
||||
ref={this.setRef}
|
||||
className='status__content status__content--with-action'
|
||||
className={classNames}
|
||||
style={directionStyle}
|
||||
onMouseDown={this.handleMouseDown}
|
||||
onMouseUp={this.handleMouseUp}
|
||||
|
|
|
@ -14,6 +14,10 @@ const messages = defineMessages({
|
|||
@injectIntl
|
||||
export default class VideoPlayer extends React.PureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
media: ImmutablePropTypes.map.isRequired,
|
||||
width: PropTypes.number,
|
||||
|
@ -119,11 +123,15 @@ export default class VideoPlayer extends React.PureComponent {
|
|||
</div>
|
||||
);
|
||||
|
||||
let expandButton = (
|
||||
<div className='status__video-player-expand'>
|
||||
<IconButton overlay title={intl.formatMessage(messages.expand_video)} icon='expand' onClick={this.handleExpand} />
|
||||
</div>
|
||||
);
|
||||
let expandButton = '';
|
||||
|
||||
if (this.context.router) {
|
||||
expandButton = (
|
||||
<div className='status__video-player-expand'>
|
||||
<IconButton overlay title={intl.formatMessage(messages.expand_video)} icon='expand' onClick={this.handleExpand} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let muteButton = '';
|
||||
|
||||
|
@ -138,7 +146,7 @@ export default class VideoPlayer extends React.PureComponent {
|
|||
if (!this.state.visible) {
|
||||
if (sensitive) {
|
||||
return (
|
||||
<div role='button' tabIndex='0' style={{ width: `${width}px`, height: `${height}px` }} className='media-spoiler' onClick={this.handleVisibility}>
|
||||
<div role='button' tabIndex='0' style={{ width: `${width}px`, height: `${height}px`, marginTop: '8px' }} className='media-spoiler' onClick={this.handleVisibility}>
|
||||
{spoilerButton}
|
||||
<span className='media-spoiler__warning'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
|
||||
<span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
|
||||
|
@ -146,7 +154,7 @@ export default class VideoPlayer extends React.PureComponent {
|
|||
);
|
||||
} else {
|
||||
return (
|
||||
<div role='button' tabIndex='0' style={{ width: `${width}px`, height: `${height}px` }} className='media-spoiler' onClick={this.handleVisibility}>
|
||||
<div role='button' tabIndex='0' style={{ width: `${width}px`, height: `${height}px`, marginTop: '8px' }} className='media-spoiler' onClick={this.handleVisibility}>
|
||||
{spoilerButton}
|
||||
<span className='media-spoiler__warning'><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span>
|
||||
<span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
|
||||
|
|
39
app/javascript/mastodon/containers/timeline_container.js
Normal file
39
app/javascript/mastodon/containers/timeline_container.js
Normal file
|
@ -0,0 +1,39 @@
|
|||
import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import configureStore from '../store/configureStore';
|
||||
import { hydrateStore } from '../actions/store';
|
||||
import { IntlProvider, addLocaleData } from 'react-intl';
|
||||
import { getLocale } from '../locales';
|
||||
import PublicTimeline from '../features/standalone/public_timeline';
|
||||
|
||||
const { localeData, messages } = getLocale();
|
||||
addLocaleData(localeData);
|
||||
|
||||
const store = configureStore();
|
||||
const initialStateContainer = document.getElementById('initial-state');
|
||||
|
||||
if (initialStateContainer !== null) {
|
||||
const initialState = JSON.parse(initialStateContainer.textContent);
|
||||
store.dispatch(hydrateStore(initialState));
|
||||
}
|
||||
|
||||
export default class TimelineContainer extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
locale: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { locale } = this.props;
|
||||
|
||||
return (
|
||||
<IntlProvider locale={locale} messages={messages}>
|
||||
<Provider store={store}>
|
||||
<PublicTimeline />
|
||||
</Provider>
|
||||
</IntlProvider>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import StatusListContainer from '../../ui/containers/status_list_container';
|
||||
import {
|
||||
refreshPublicTimeline,
|
||||
expandPublicTimeline,
|
||||
} from '../../../actions/timelines';
|
||||
import Column from '../../../components/column';
|
||||
import ColumnHeader from '../../../components/column_header';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'standalone.public_title', defaultMessage: 'A look inside...' },
|
||||
});
|
||||
|
||||
@connect()
|
||||
@injectIntl
|
||||
export default class PublicTimeline extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
handleHeaderClick = () => {
|
||||
this.column.scrollTop();
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
this.column = c;
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
const { dispatch } = this.props;
|
||||
|
||||
dispatch(refreshPublicTimeline());
|
||||
|
||||
this.polling = setInterval(() => {
|
||||
dispatch(refreshPublicTimeline());
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
if (typeof this.polling !== 'undefined') {
|
||||
clearInterval(this.polling);
|
||||
this.polling = null;
|
||||
}
|
||||
}
|
||||
|
||||
handleLoadMore = () => {
|
||||
this.props.dispatch(expandPublicTimeline());
|
||||
}
|
||||
|
||||
render () {
|
||||
const { intl } = this.props;
|
||||
|
||||
return (
|
||||
<Column ref={this.setRef}>
|
||||
<ColumnHeader
|
||||
icon='globe'
|
||||
title={intl.formatMessage(messages.title)}
|
||||
onClick={this.handleHeaderClick}
|
||||
/>
|
||||
|
||||
<StatusListContainer
|
||||
timelineId='public'
|
||||
loadMore={this.handleLoadMore}
|
||||
scrollKey='standalone_public_timeline'
|
||||
trackScroll={false}
|
||||
/>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue