Add logged-out access to the web UI (#18961)

This commit is contained in:
Eugen Rochko 2022-09-29 04:39:33 +02:00 committed by GitHub
parent 1a5150e9c3
commit 43b5d5e38d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 423 additions and 145 deletions

View File

@ -2,8 +2,8 @@
class HomeController < ApplicationController class HomeController < ApplicationController
before_action :redirect_unauthenticated_to_permalinks! before_action :redirect_unauthenticated_to_permalinks!
before_action :authenticate_user!
before_action :set_referrer_policy_header before_action :set_referrer_policy_header
before_action :set_instance_presenter
def index def index
@body_classes = 'app-body' @body_classes = 'app-body'
@ -14,20 +14,16 @@ class HomeController < ApplicationController
def redirect_unauthenticated_to_permalinks! def redirect_unauthenticated_to_permalinks!
return if user_signed_in? return if user_signed_in?
redirect_to(PermalinkRedirector.new(request.path).redirect_path || default_redirect_path) redirect_path = PermalinkRedirector.new(request.path).redirect_path
end
def default_redirect_path redirect_to(redirect_path) if redirect_path.present?
if request.path.start_with?('/web') || whitelist_mode?
new_user_session_path
elsif single_user_mode?
short_account_path(Account.local.without_suspended.where('id > 0').first)
else
about_path
end
end end
def set_referrer_policy_header def set_referrer_policy_header
response.headers['Referrer-Policy'] = 'origin' response.headers['Referrer-Policy'] = 'origin'
end end
def set_instance_presenter
@instance_presenter = InstancePresenter.new
end
end end

View File

@ -536,10 +536,12 @@ export function expandFollowingFail(id, error) {
export function fetchRelationships(accountIds) { export function fetchRelationships(accountIds) {
return (dispatch, getState) => { return (dispatch, getState) => {
const loadedRelationships = getState().get('relationships'); const state = getState();
const loadedRelationships = state.get('relationships');
const newAccountIds = accountIds.filter(id => loadedRelationships.get(id, null) === null); const newAccountIds = accountIds.filter(id => loadedRelationships.get(id, null) === null);
const signedIn = !!state.getIn(['meta', 'me']);
if (newAccountIds.length === 0) { if (!signedIn || newAccountIds.length === 0) {
return; return;
} }

View File

@ -1,6 +1,7 @@
import api from '../api'; import api from '../api';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import compareId from '../compare_id'; import compareId from '../compare_id';
import { List as ImmutableList } from 'immutable';
export const MARKERS_FETCH_REQUEST = 'MARKERS_FETCH_REQUEST'; export const MARKERS_FETCH_REQUEST = 'MARKERS_FETCH_REQUEST';
export const MARKERS_FETCH_SUCCESS = 'MARKERS_FETCH_SUCCESS'; export const MARKERS_FETCH_SUCCESS = 'MARKERS_FETCH_SUCCESS';
@ -11,7 +12,7 @@ export const synchronouslySubmitMarkers = () => (dispatch, getState) => {
const accessToken = getState().getIn(['meta', 'access_token'], ''); const accessToken = getState().getIn(['meta', 'access_token'], '');
const params = _buildParams(getState()); const params = _buildParams(getState());
if (Object.keys(params).length === 0) { if (Object.keys(params).length === 0 || accessToken === '') {
return; return;
} }
@ -63,7 +64,7 @@ export const synchronouslySubmitMarkers = () => (dispatch, getState) => {
const _buildParams = (state) => { const _buildParams = (state) => {
const params = {}; const params = {};
const lastHomeId = state.getIn(['timelines', 'home', 'items']).find(item => item !== null); const lastHomeId = state.getIn(['timelines', 'home', 'items'], ImmutableList()).find(item => item !== null);
const lastNotificationId = state.getIn(['notifications', 'lastReadId']); const lastNotificationId = state.getIn(['notifications', 'lastReadId']);
if (lastHomeId && compareId(lastHomeId, state.getIn(['markers', 'home'])) > 0) { if (lastHomeId && compareId(lastHomeId, state.getIn(['markers', 'home'])) > 0) {
@ -82,9 +83,10 @@ const _buildParams = (state) => {
}; };
const debouncedSubmitMarkers = debounce((dispatch, getState) => { const debouncedSubmitMarkers = debounce((dispatch, getState) => {
const params = _buildParams(getState()); const accessToken = getState().getIn(['meta', 'access_token'], '');
const params = _buildParams(getState());
if (Object.keys(params).length === 0) { if (Object.keys(params).length === 0 || accessToken === '') {
return; return;
} }

View File

@ -1,8 +1,8 @@
import React from 'react'; import React from 'react';
const Logo = () => ( const Logo = () => (
<svg viewBox='0 0 216.4144 232.00976' className='logo'> <svg viewBox='0 0 261 66' className='logo'>
<use xlinkHref='#mastodon-svg-logo' /> <use xlinkHref='#logo-symbol-wordmark' />
</svg> </svg>
); );

View File

@ -26,7 +26,7 @@ const createIdentityContext = state => ({
signedIn: !!state.meta.me, signedIn: !!state.meta.me,
accountId: state.meta.me, accountId: state.meta.me,
accessToken: state.meta.access_token, accessToken: state.meta.access_token,
permissions: state.role.permissions, permissions: state.role ? state.role.permissions : 0,
}); });
export default class Mastodon extends React.PureComponent { export default class Mastodon extends React.PureComponent {

View File

@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import Button from 'mastodon/components/button'; import Button from 'mastodon/components/button';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { autoPlayGif, me } from 'mastodon/initial_state'; import { autoPlayGif, me, title, domain } from 'mastodon/initial_state';
import classNames from 'classnames'; import classNames from 'classnames';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
import IconButton from 'mastodon/components/icon_button'; import IconButton from 'mastodon/components/icon_button';
@ -15,6 +15,7 @@ import { NavLink } from 'react-router-dom';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container'; import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import AccountNoteContainer from '../containers/account_note_container'; import AccountNoteContainer from '../containers/account_note_container';
import { PERMISSION_MANAGE_USERS } from 'mastodon/permissions'; import { PERMISSION_MANAGE_USERS } from 'mastodon/permissions';
import { Helmet } from 'react-helmet';
const messages = defineMessages({ const messages = defineMessages({
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
@ -54,6 +55,14 @@ const messages = defineMessages({
languages: { id: 'account.languages', defaultMessage: 'Change subscribed languages' }, languages: { id: 'account.languages', defaultMessage: 'Change subscribed languages' },
}); });
const titleFromAccount = account => {
const displayName = account.get('display_name');
const acct = account.get('acct') === account.get('username') ? `${account.get('username')}@${domain}` : account.get('acct');
const prefix = displayName.trim().length === 0 ? account.get('username') : displayName;
return `${prefix} (@${acct})`;
};
const dateFormatOptions = { const dateFormatOptions = {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
@ -132,6 +141,7 @@ class Header extends ImmutablePureComponent {
render () { render () {
const { account, hidden, intl, domain } = this.props; const { account, hidden, intl, domain } = this.props;
const { signedIn } = this.context.identity;
if (!account) { if (!account) {
return null; return null;
@ -162,12 +172,12 @@ class Header extends ImmutablePureComponent {
} }
if (me !== account.get('id')) { if (me !== account.get('id')) {
if (!account.get('relationship')) { // Wait until the relationship is loaded if (signedIn && !account.get('relationship')) { // Wait until the relationship is loaded
actionBtn = ''; actionBtn = '';
} else if (account.getIn(['relationship', 'requested'])) { } else if (account.getIn(['relationship', 'requested'])) {
actionBtn = <Button className={classNames('logo-button', { 'button--with-bell': bellBtn !== '' })} text={intl.formatMessage(messages.cancel_follow_request)} title={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} />; actionBtn = <Button className={classNames('logo-button', { 'button--with-bell': bellBtn !== '' })} text={intl.formatMessage(messages.cancel_follow_request)} title={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} />;
} else if (!account.getIn(['relationship', 'blocking'])) { } else if (!account.getIn(['relationship', 'blocking'])) {
actionBtn = <Button disabled={account.getIn(['relationship', 'blocked_by'])} className={classNames('logo-button', { 'button--destructive': account.getIn(['relationship', 'following']), 'button--with-bell': bellBtn !== '' })} text={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.props.onFollow} />; actionBtn = <Button disabled={account.getIn(['relationship', 'blocked_by'])} className={classNames('logo-button', { 'button--destructive': account.getIn(['relationship', 'following']), 'button--with-bell': bellBtn !== '' })} text={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={signedIn ? this.props.onFollow : undefined} />;
} else if (account.getIn(['relationship', 'blocking'])) { } else if (account.getIn(['relationship', 'blocking'])) {
actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.props.onBlock} />; actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.props.onBlock} />;
} }
@ -183,7 +193,7 @@ class Header extends ImmutablePureComponent {
lockedIcon = <Icon id='lock' title={intl.formatMessage(messages.account_locked)} />; lockedIcon = <Icon id='lock' title={intl.formatMessage(messages.account_locked)} />;
} }
if (account.get('id') !== me) { if (signedIn && 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.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({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.props.onDirect });
menu.push(null); menu.push(null);
@ -206,7 +216,7 @@ class Header extends ImmutablePureComponent {
menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' }); menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' }); menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' }); menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' });
} else { } else if (signedIn) {
if (account.getIn(['relationship', 'following'])) { if (account.getIn(['relationship', 'following'])) {
if (!account.getIn(['relationship', 'muting'])) { if (!account.getIn(['relationship', 'muting'])) {
if (account.getIn(['relationship', 'showing_reblogs'])) { if (account.getIn(['relationship', 'showing_reblogs'])) {
@ -239,7 +249,7 @@ class Header extends ImmutablePureComponent {
menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.props.onReport }); menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.props.onReport });
} }
if (account.get('acct') !== account.get('username')) { if (signedIn && account.get('acct') !== account.get('username')) {
const domain = account.get('acct').split('@')[1]; const domain = account.get('acct').split('@')[1];
menu.push(null); menu.push(null);
@ -298,7 +308,7 @@ class Header extends ImmutablePureComponent {
</React.Fragment> </React.Fragment>
)} )}
<DropdownMenuContainer items={menu} icon='ellipsis-v' size={24} direction='right' /> <DropdownMenuContainer disabled={menu.length === 0} items={menu} icon='ellipsis-v' size={24} direction='right' />
</div> </div>
)} )}
</div> </div>
@ -327,7 +337,7 @@ class Header extends ImmutablePureComponent {
</div> </div>
)} )}
{account.get('id') !== me && <AccountNoteContainer account={account} />} {(account.get('id') !== me && signedIn) && <AccountNoteContainer account={account} />}
{account.get('note').length > 0 && account.get('note') !== '<p></p>' && <div className='account__header__content translate' dangerouslySetInnerHTML={content} />} {account.get('note').length > 0 && account.get('note') !== '<p></p>' && <div className='account__header__content translate' dangerouslySetInnerHTML={content} />}
@ -359,6 +369,10 @@ class Header extends ImmutablePureComponent {
</div> </div>
)} )}
</div> </div>
<Helmet>
<title>{titleFromAccount(account)} - {title}</title>
</Helmet>
</div> </div>
); );
} }

View File

@ -9,6 +9,8 @@ import { expandCommunityTimeline } from '../../actions/timelines';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import ColumnSettingsContainer from './containers/column_settings_container'; import ColumnSettingsContainer from './containers/column_settings_container';
import { connectCommunityStream } from '../../actions/streaming'; import { connectCommunityStream } from '../../actions/streaming';
import { Helmet } from 'react-helmet';
import { title } from 'mastodon/initial_state';
const messages = defineMessages({ const messages = defineMessages({
title: { id: 'column.community', defaultMessage: 'Local timeline' }, title: { id: 'column.community', defaultMessage: 'Local timeline' },
@ -128,6 +130,10 @@ class CommunityTimeline extends React.PureComponent {
emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />} emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />}
bindToDocument={!multiColumn} bindToDocument={!multiColumn}
/> />
<Helmet>
<title>{intl.formatMessage(messages.title)} - {title}</title>
</Helmet>
</Column> </Column>
); );
} }

View File

@ -13,6 +13,8 @@ import RadioButton from 'mastodon/components/radio_button';
import LoadMore from 'mastodon/components/load_more'; import LoadMore from 'mastodon/components/load_more';
import ScrollContainer from 'mastodon/containers/scroll_container'; import ScrollContainer from 'mastodon/containers/scroll_container';
import LoadingIndicator from 'mastodon/components/loading_indicator'; import LoadingIndicator from 'mastodon/components/loading_indicator';
import { title } from 'mastodon/initial_state';
import { Helmet } from 'react-helmet';
const messages = defineMessages({ const messages = defineMessages({
title: { id: 'column.directory', defaultMessage: 'Browse profiles' }, title: { id: 'column.directory', defaultMessage: 'Browse profiles' },
@ -165,6 +167,10 @@ class Directory extends React.PureComponent {
/> />
{multiColumn && !pinned ? <ScrollContainer scrollKey='directory'>{scrollableArea}</ScrollContainer> : scrollableArea} {multiColumn && !pinned ? <ScrollContainer scrollKey='directory'>{scrollableArea}</ScrollContainer> : scrollableArea}
<Helmet>
<title>{intl.formatMessage(messages.title)} - {title}</title>
</Helmet>
</Column> </Column>
); );
} }

View File

@ -11,6 +11,8 @@ import Statuses from './statuses';
import Suggestions from './suggestions'; import Suggestions from './suggestions';
import Search from 'mastodon/features/compose/containers/search_container'; import Search from 'mastodon/features/compose/containers/search_container';
import SearchResults from './results'; import SearchResults from './results';
import { Helmet } from 'react-helmet';
import { title } from 'mastodon/initial_state';
const messages = defineMessages({ const messages = defineMessages({
title: { id: 'explore.title', defaultMessage: 'Explore' }, title: { id: 'explore.title', defaultMessage: 'Explore' },
@ -81,6 +83,10 @@ class Explore extends React.PureComponent {
<Route path='/explore/suggestions' component={Suggestions} /> <Route path='/explore/suggestions' component={Suggestions} />
<Route exact path={['/explore', '/explore/posts', '/search']} component={Statuses} componentParams={{ multiColumn }} /> <Route exact path={['/explore', '/explore/posts', '/search']} component={Statuses} componentParams={{ multiColumn }} />
</Switch> </Switch>
<Helmet>
<title>{intl.formatMessage(messages.title)} - {title}</title>
</Helmet>
</React.Fragment> </React.Fragment>
)} )}
</div> </div>

View File

@ -5,6 +5,7 @@ import Story from './components/story';
import LoadingIndicator from 'mastodon/components/loading_indicator'; import LoadingIndicator from 'mastodon/components/loading_indicator';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { fetchTrendingLinks } from 'mastodon/actions/trends'; import { fetchTrendingLinks } from 'mastodon/actions/trends';
import { FormattedMessage } from 'react-intl';
const mapStateToProps = state => ({ const mapStateToProps = state => ({
links: state.getIn(['trends', 'links', 'items']), links: state.getIn(['trends', 'links', 'items']),
@ -28,6 +29,16 @@ class Links extends React.PureComponent {
render () { render () {
const { isLoading, links } = this.props; const { isLoading, links } = this.props;
if (!isLoading && links.isEmpty()) {
return (
<div className='explore__links scrollable scrollable--flex'>
<div className='empty-column-indicator'>
<FormattedMessage id='empty_column.explore_statuses' defaultMessage='Nothing is trending right now. Check back later!' />
</div>
</div>
);
}
return ( return (
<div className='explore__links'> <div className='explore__links'>
{isLoading ? (<LoadingIndicator />) : links.map(link => ( {isLoading ? (<LoadingIndicator />) : links.map(link => (

View File

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import { FormattedMessage } from 'react-intl'; import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { expandSearch } from 'mastodon/actions/search'; import { expandSearch } from 'mastodon/actions/search';
import Account from 'mastodon/containers/account_container'; import Account from 'mastodon/containers/account_container';
@ -10,10 +10,17 @@ import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
import LoadMore from 'mastodon/components/load_more'; import LoadMore from 'mastodon/components/load_more';
import LoadingIndicator from 'mastodon/components/loading_indicator'; import LoadingIndicator from 'mastodon/components/loading_indicator';
import { title } from 'mastodon/initial_state';
import { Helmet } from 'react-helmet';
const messages = defineMessages({
title: { id: 'search_results.title', defaultMessage: 'Search for {q}' },
});
const mapStateToProps = state => ({ const mapStateToProps = state => ({
isLoading: state.getIn(['search', 'isLoading']), isLoading: state.getIn(['search', 'isLoading']),
results: state.getIn(['search', 'results']), results: state.getIn(['search', 'results']),
q: state.getIn(['search', 'searchTerm']),
}); });
const appendLoadMore = (id, list, onLoadMore) => { const appendLoadMore = (id, list, onLoadMore) => {
@ -37,6 +44,7 @@ const renderStatuses = (results, onLoadMore) => appendLoadMore('statuses', resul
)), onLoadMore); )), onLoadMore);
export default @connect(mapStateToProps) export default @connect(mapStateToProps)
@injectIntl
class Results extends React.PureComponent { class Results extends React.PureComponent {
static propTypes = { static propTypes = {
@ -44,6 +52,8 @@ class Results extends React.PureComponent {
isLoading: PropTypes.bool, isLoading: PropTypes.bool,
multiColumn: PropTypes.bool, multiColumn: PropTypes.bool,
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
q: PropTypes.string,
intl: PropTypes.object,
}; };
state = { state = {
@ -64,7 +74,7 @@ class Results extends React.PureComponent {
} }
render () { render () {
const { isLoading, results } = this.props; const { intl, isLoading, q, results } = this.props;
const { type } = this.state; const { type } = this.state;
let filteredResults = ImmutableList(); let filteredResults = ImmutableList();
@ -106,6 +116,10 @@ class Results extends React.PureComponent {
<div className='explore__search-results'> <div className='explore__search-results'>
{isLoading ? <LoadingIndicator /> : filteredResults} {isLoading ? <LoadingIndicator /> : filteredResults}
</div> </div>
<Helmet>
<title>{intl.formatMessage(messages.title, { q })} - {title}</title>
</Helmet>
</React.Fragment> </React.Fragment>
); );
} }

View File

@ -5,6 +5,7 @@ import AccountCard from 'mastodon/features/directory/components/account_card';
import LoadingIndicator from 'mastodon/components/loading_indicator'; import LoadingIndicator from 'mastodon/components/loading_indicator';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { fetchSuggestions } from 'mastodon/actions/suggestions'; import { fetchSuggestions } from 'mastodon/actions/suggestions';
import { FormattedMessage } from 'react-intl';
const mapStateToProps = state => ({ const mapStateToProps = state => ({
suggestions: state.getIn(['suggestions', 'items']), suggestions: state.getIn(['suggestions', 'items']),
@ -28,6 +29,16 @@ class Suggestions extends React.PureComponent {
render () { render () {
const { isLoading, suggestions } = this.props; const { isLoading, suggestions } = this.props;
if (!isLoading && suggestions.isEmpty()) {
return (
<div className='explore__suggestions scrollable scrollable--flex'>
<div className='empty-column-indicator'>
<FormattedMessage id='empty_column.explore_statuses' defaultMessage='Nothing is trending right now. Check back later!' />
</div>
</div>
);
}
return ( return (
<div className='explore__suggestions'> <div className='explore__suggestions'>
{isLoading ? <LoadingIndicator /> : suggestions.map(suggestion => ( {isLoading ? <LoadingIndicator /> : suggestions.map(suggestion => (

View File

@ -5,6 +5,7 @@ import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
import LoadingIndicator from 'mastodon/components/loading_indicator'; import LoadingIndicator from 'mastodon/components/loading_indicator';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { fetchTrendingHashtags } from 'mastodon/actions/trends'; import { fetchTrendingHashtags } from 'mastodon/actions/trends';
import { FormattedMessage } from 'react-intl';
const mapStateToProps = state => ({ const mapStateToProps = state => ({
hashtags: state.getIn(['trends', 'tags', 'items']), hashtags: state.getIn(['trends', 'tags', 'items']),
@ -28,6 +29,16 @@ class Tags extends React.PureComponent {
render () { render () {
const { isLoading, hashtags } = this.props; const { isLoading, hashtags } = this.props;
if (!isLoading && hashtags.isEmpty()) {
return (
<div className='explore__links scrollable scrollable--flex'>
<div className='empty-column-indicator'>
<FormattedMessage id='empty_column.explore_statuses' defaultMessage='Nothing is trending right now. Check back later!' />
</div>
</div>
);
}
return ( return (
<div className='explore__links'> <div className='explore__links'>
{isLoading ? (<LoadingIndicator />) : hashtags.map(hashtag => ( {isLoading ? (<LoadingIndicator />) : hashtags.map(hashtag => (

View File

@ -14,6 +14,8 @@ import { isEqual } from 'lodash';
import { fetchHashtag, followHashtag, unfollowHashtag } from 'mastodon/actions/tags'; import { fetchHashtag, followHashtag, unfollowHashtag } from 'mastodon/actions/tags';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
import classNames from 'classnames'; import classNames from 'classnames';
import { title } from 'mastodon/initial_state';
import { Helmet } from 'react-helmet';
const messages = defineMessages({ const messages = defineMessages({
followHashtag: { id: 'hashtag.follow', defaultMessage: 'Follow hashtag' }, followHashtag: { id: 'hashtag.follow', defaultMessage: 'Follow hashtag' },
@ -31,6 +33,10 @@ class HashtagTimeline extends React.PureComponent {
disconnects = []; disconnects = [];
static contextTypes = {
identity: PropTypes.object,
};
static propTypes = { static propTypes = {
params: PropTypes.object.isRequired, params: PropTypes.object.isRequired,
columnId: PropTypes.string, columnId: PropTypes.string,
@ -158,6 +164,11 @@ class HashtagTimeline extends React.PureComponent {
handleFollow = () => { handleFollow = () => {
const { dispatch, params, tag } = this.props; const { dispatch, params, tag } = this.props;
const { id } = params; const { id } = params;
const { signedIn } = this.context.identity;
if (!signedIn) {
return;
}
if (tag.get('following')) { if (tag.get('following')) {
dispatch(unfollowHashtag(id)); dispatch(unfollowHashtag(id));
@ -170,6 +181,7 @@ class HashtagTimeline extends React.PureComponent {
const { hasUnread, columnId, multiColumn, tag, intl } = this.props; const { hasUnread, columnId, multiColumn, tag, intl } = this.props;
const { id, local } = this.props.params; const { id, local } = this.props.params;
const pinned = !!columnId; const pinned = !!columnId;
const { signedIn } = this.context.identity;
let followButton; let followButton;
@ -177,7 +189,7 @@ class HashtagTimeline extends React.PureComponent {
const following = tag.get('following'); const following = tag.get('following');
followButton = ( followButton = (
<button className={classNames('column-header__button')} onClick={this.handleFollow} title={intl.formatMessage(following ? messages.unfollowHashtag : messages.followHashtag)} aria-label={intl.formatMessage(following ? messages.unfollowHashtag : messages.followHashtag)} aria-pressed={following ? 'true' : 'false'}> <button className={classNames('column-header__button')} onClick={this.handleFollow} disabled={!signedIn} title={intl.formatMessage(following ? messages.unfollowHashtag : messages.followHashtag)} aria-label={intl.formatMessage(following ? messages.unfollowHashtag : messages.followHashtag)} aria-pressed={following ? 'true' : 'false'}>
<Icon id={following ? 'user-times' : 'user-plus'} fixedWidth className='column-header__icon' /> <Icon id={following ? 'user-times' : 'user-plus'} fixedWidth className='column-header__icon' />
</button> </button>
); );
@ -208,6 +220,10 @@ class HashtagTimeline extends React.PureComponent {
emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />} emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />}
bindToDocument={!multiColumn} bindToDocument={!multiColumn}
/> />
<Helmet>
<title>{`#${id}`} - {title}</title>
</Helmet>
</Column> </Column>
); );
} }

View File

@ -9,6 +9,8 @@ import { expandPublicTimeline } from '../../actions/timelines';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import ColumnSettingsContainer from './containers/column_settings_container'; import ColumnSettingsContainer from './containers/column_settings_container';
import { connectPublicStream } from '../../actions/streaming'; import { connectPublicStream } from '../../actions/streaming';
import { Helmet } from 'react-helmet';
import { title } from 'mastodon/initial_state';
const messages = defineMessages({ const messages = defineMessages({
title: { id: 'column.public', defaultMessage: 'Federated timeline' }, title: { id: 'column.public', defaultMessage: 'Federated timeline' },
@ -131,6 +133,10 @@ class PublicTimeline extends React.PureComponent {
emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other servers to fill it up' />} emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other servers to fill it up' />}
bindToDocument={!multiColumn} bindToDocument={!multiColumn}
/> />
<Helmet>
<title>{intl.formatMessage(messages.title)} - {title}</title>
</Helmet>
</Column> </Column>
); );
} }

View File

@ -56,10 +56,11 @@ import { openModal } from '../../actions/modal';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { HotKeys } from 'react-hotkeys'; import { HotKeys } from 'react-hotkeys';
import { boostModal, deleteModal } from '../../initial_state'; import { boostModal, deleteModal, title } from '../../initial_state';
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../ui/util/fullscreen'; import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../ui/util/fullscreen';
import { textForScreenReader, defaultMediaVisibility } from '../../components/status'; import { textForScreenReader, defaultMediaVisibility } from '../../components/status';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
import { Helmet } from 'react-helmet';
const messages = defineMessages({ const messages = defineMessages({
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
@ -156,6 +157,23 @@ const makeMapStateToProps = () => {
return mapStateToProps; return mapStateToProps;
}; };
const truncate = (str, num) => {
if (str.length > num) {
return str.slice(0, num) + '…';
} else {
return str;
}
};
const titleFromStatus = status => {
const displayName = status.getIn(['account', 'display_name']);
const username = status.getIn(['account', 'username']);
const prefix = displayName.trim().length === 0 ? username : displayName;
const text = status.get('search_index');
return `${prefix}: "${truncate(text, 30)}"`;
};
export default @injectIntl export default @injectIntl
@connect(makeMapStateToProps) @connect(makeMapStateToProps)
class Status extends ImmutablePureComponent { class Status extends ImmutablePureComponent {
@ -605,6 +623,10 @@ class Status extends ImmutablePureComponent {
{descendants} {descendants}
</div> </div>
</ScrollContainer> </ScrollContainer>
<Helmet>
<title>{titleFromStatus(status)} - {title}</title>
</Helmet>
</Column> </Column>
); );
} }

View File

@ -60,6 +60,7 @@ class ColumnsArea extends ImmutablePureComponent {
static contextTypes = { static contextTypes = {
router: PropTypes.object.isRequired, router: PropTypes.object.isRequired,
identity: PropTypes.object.isRequired,
}; };
static propTypes = { static propTypes = {
@ -212,11 +213,12 @@ class ColumnsArea extends ImmutablePureComponent {
render () { render () {
const { columns, children, singleColumn, isModalOpen, intl } = this.props; const { columns, children, singleColumn, isModalOpen, intl } = this.props;
const { shouldAnimate, renderComposePanel } = this.state; const { shouldAnimate, renderComposePanel } = this.state;
const { signedIn } = this.context.identity;
const columnIndex = getIndex(this.context.router.history.location.pathname); const columnIndex = getIndex(this.context.router.history.location.pathname);
if (singleColumn) { if (singleColumn) {
const floatingActionButton = shouldHideFAB(this.context.router.history.location.pathname) ? null : <Link key='floating-action-button' to='/publish' className='floating-action-button' aria-label={intl.formatMessage(messages.publish)}><Icon id='pencil' /></Link>; const floatingActionButton = (!signedIn || shouldHideFAB(this.context.router.history.location.pathname)) ? null : <Link key='floating-action-button' to='/publish' className='floating-action-button' aria-label={intl.formatMessage(messages.publish)}><Icon id='pencil' /></Link>;
const content = columnIndex !== -1 ? ( const content = columnIndex !== -1 ? (
<ReactSwipeableViews key='content' hysteresis={0.2} threshold={15} index={columnIndex} onChangeIndex={this.handleSwipe} onTransitionEnd={this.handleAnimationEnd} animateTransitions={shouldAnimate} springConfig={{ duration: '400ms', delay: '0s', easeFunction: 'ease' }} style={{ height: '100%' }} disabled={disableSwiping}> <ReactSwipeableViews key='content' hysteresis={0.2} threshold={15} index={columnIndex} onChangeIndex={this.handleSwipe} onTransitionEnd={this.handleAnimationEnd} animateTransitions={shouldAnimate} springConfig={{ duration: '400ms', delay: '0s', easeFunction: 'ease' }} style={{ height: '100%' }} disabled={disableSwiping}>

View File

@ -10,6 +10,10 @@ import { changeComposing } from 'mastodon/actions/compose';
export default @connect() export default @connect()
class ComposePanel extends React.PureComponent { class ComposePanel extends React.PureComponent {
static contextTypes = {
identity: PropTypes.object.isRequired,
};
static propTypes = { static propTypes = {
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
}; };
@ -23,11 +27,25 @@ class ComposePanel extends React.PureComponent {
} }
render() { render() {
const { signedIn } = this.context.identity;
return ( return (
<div className='compose-panel' onFocus={this.onFocus}> <div className='compose-panel' onFocus={this.onFocus}>
<SearchContainer openInRoute /> <SearchContainer openInRoute />
<NavigationContainer onClose={this.onBlur} />
<ComposeFormContainer singleColumn /> {!signedIn && (
<React.Fragment>
<div className='flex-spacer' />
</React.Fragment>
)}
{signedIn && (
<React.Fragment>
<NavigationContainer onClose={this.onBlur} />
<ComposeFormContainer singleColumn />
</React.Fragment>
)}
<LinkFooter withHotkeys /> <LinkFooter withHotkeys />
</div> </div>
); );

View File

@ -1,41 +0,0 @@
import { PureComponent } from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { title } from 'mastodon/initial_state';
const mapStateToProps = state => ({
unread: state.getIn(['missed_updates', 'unread']),
});
export default @connect(mapStateToProps)
class DocumentTitle extends PureComponent {
static propTypes = {
unread: PropTypes.number.isRequired,
};
componentDidMount () {
this._sideEffects();
}
componentDidUpdate() {
this._sideEffects();
}
_sideEffects () {
const { unread } = this.props;
if (unread > 99) {
document.title = `(*) ${title}`;
} else if (unread > 0) {
document.title = `(${unread}) ${title}`;
} else {
document.title = title;
}
}
render () {
return null;
}
}

View File

@ -49,20 +49,46 @@ class LinkFooter extends React.PureComponent {
render () { render () {
const { withHotkeys } = this.props; const { withHotkeys } = this.props;
const { signedIn, permissions } = this.context.identity;
const items = [];
if ((permissions & PERMISSION_INVITE_USERS) === PERMISSION_INVITE_USERS) {
items.push(<a key='invites' href='/invites' target='_blank'><FormattedMessage id='getting_started.invite' defaultMessage='Invite people' /></a>);
}
if (withHotkeys) {
items.push(<Link key='hotkeys' to='/keyboard-shortcuts'><FormattedMessage id='navigation_bar.keyboard_shortcuts' defaultMessage='Hotkeys' /></Link>);
}
if (signedIn) {
items.push(<a key='security' href='/auth/edit'><FormattedMessage id='getting_started.security' defaultMessage='Security' /></a>);
}
if (!limitedFederationMode) {
items.push(<a key='about' href='/about/more' target='_blank'><FormattedMessage id='navigation_bar.info' defaultMessage='About this server' /></a>);
}
if (profileDirectory) {
items.push(<Link key='directory' to='/directory'><FormattedMessage id='getting_started.directory' defaultMessage='Profile directory' /></Link>);
}
items.push(<a key='apps' href='https://joinmastodon.org/apps' target='_blank'><FormattedMessage id='navigation_bar.apps' defaultMessage='Mobile apps' /></a>);
items.push(<a key='terms' href='/terms' target='_blank'><FormattedMessage id='getting_started.terms' defaultMessage='Terms of service' /></a>);
if (signedIn) {
items.push(<a key='developers' href='/settings/applications' target='_blank'><FormattedMessage id='getting_started.developers' defaultMessage='Developers' /></a>);
}
items.push(<a key='docs' href='https://docs.joinmastodon.org' target='_blank'><FormattedMessage id='getting_started.documentation' defaultMessage='Documentation' /></a>);
if (signedIn) {
items.push(<a key='logout' href='/auth/sign_out' onClick={this.handleLogoutClick}><FormattedMessage id='navigation_bar.logout' defaultMessage='Logout' /></a>);
}
return ( return (
<div className='getting-started__footer'> <div className='getting-started__footer'>
<ul> <ul>
{((this.context.identity.permissions & PERMISSION_INVITE_USERS) === PERMISSION_INVITE_USERS) && <li><a href='/invites' target='_blank'><FormattedMessage id='getting_started.invite' defaultMessage='Invite people' /></a> · </li>} <li>{items.reduce((prev, curr) => [prev, ' · ', curr])}</li>
{withHotkeys && <li><Link to='/keyboard-shortcuts'><FormattedMessage id='navigation_bar.keyboard_shortcuts' defaultMessage='Hotkeys' /></Link> · </li>}
<li><a href='/auth/edit'><FormattedMessage id='getting_started.security' defaultMessage='Security' /></a> · </li>
{!limitedFederationMode && <li><a href='/about/more' target='_blank'><FormattedMessage id='navigation_bar.info' defaultMessage='About this server' /></a> · </li>}
{profileDirectory && <li><Link to='/directory'><FormattedMessage id='getting_started.directory' defaultMessage='Profile directory' /></Link> · </li>}
<li><a href='https://joinmastodon.org/apps' target='_blank'><FormattedMessage id='navigation_bar.apps' defaultMessage='Mobile apps' /></a> · </li>
<li><a href='/terms' target='_blank'><FormattedMessage id='getting_started.terms' defaultMessage='Terms of service' /></a> · </li>
<li><a href='/settings/applications' target='_blank'><FormattedMessage id='getting_started.developers' defaultMessage='Developers' /></a> · </li>
<li><a href='https://docs.joinmastodon.org' target='_blank'><FormattedMessage id='getting_started.documentation' defaultMessage='Documentation' /></a> · </li>
<li><a href='/auth/sign_out' onClick={this.handleLogoutClick}><FormattedMessage id='navigation_bar.logout' defaultMessage='Logout' /></a></li>
</ul> </ul>
<p> <p>

View File

@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { NavLink, withRouter } from 'react-router-dom'; import PropTypes from 'prop-types';
import { NavLink, Link } from 'react-router-dom';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
import { showTrends } from 'mastodon/initial_state'; import { showTrends } from 'mastodon/initial_state';
@ -7,30 +8,68 @@ import NotificationsCounterIcon from './notifications_counter_icon';
import FollowRequestsNavLink from './follow_requests_nav_link'; import FollowRequestsNavLink from './follow_requests_nav_link';
import ListPanel from './list_panel'; import ListPanel from './list_panel';
import TrendsContainer from 'mastodon/features/getting_started/containers/trends_container'; import TrendsContainer from 'mastodon/features/getting_started/containers/trends_container';
import Logo from 'mastodon/components/logo';
import SignInBanner from './sign_in_banner';
const NavigationPanel = () => ( export default class NavigationPanel extends React.Component {
<div className='navigation-panel'>
<NavLink className='column-link column-link--transparent' to='/home' data-preview-title-id='column.home' data-preview-icon='home' ><Icon className='column-link__icon' id='home' fixedWidth /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>
<NavLink className='column-link column-link--transparent' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><NotificationsCounterIcon className='column-link__icon' /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></NavLink>
<FollowRequestsNavLink />
<NavLink className='column-link column-link--transparent' to='/explore' data-preview-title-id='explore.title' data-preview-icon='hashtag'><Icon className='column-link__icon' id='hashtag' fixedWidth /><FormattedMessage id='explore.title' defaultMessage='Explore' /></NavLink>
<NavLink className='column-link column-link--transparent' to='/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon className='column-link__icon' id='users' fixedWidth /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>
<NavLink className='column-link column-link--transparent' exact to='/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon className='column-link__icon' id='globe' fixedWidth /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>
<NavLink className='column-link column-link--transparent' to='/conversations'><Icon className='column-link__icon' id='at' fixedWidth /><FormattedMessage id='navigation_bar.direct' defaultMessage='Direct messages' /></NavLink>
<NavLink className='column-link column-link--transparent' to='/favourites'><Icon className='column-link__icon' id='star' fixedWidth /><FormattedMessage id='navigation_bar.favourites' defaultMessage='Favourites' /></NavLink>
<NavLink className='column-link column-link--transparent' to='/bookmarks'><Icon className='column-link__icon' id='bookmark' fixedWidth /><FormattedMessage id='navigation_bar.bookmarks' defaultMessage='Bookmarks' /></NavLink>
<NavLink className='column-link column-link--transparent' to='/lists'><Icon className='column-link__icon' id='list-ul' fixedWidth /><FormattedMessage id='navigation_bar.lists' defaultMessage='Lists' /></NavLink>
<ListPanel /> static contextTypes = {
router: PropTypes.object.isRequired,
identity: PropTypes.object.isRequired,
};
<hr /> render () {
const { signedIn } = this.context.identity;
<a className='column-link column-link--transparent' href='/settings/preferences'><Icon className='column-link__icon' id='cog' fixedWidth /><FormattedMessage id='navigation_bar.preferences' defaultMessage='Preferences' /></a> return (
<a className='column-link column-link--transparent' href='/relationships'><Icon className='column-link__icon' id='users' fixedWidth /><FormattedMessage id='navigation_bar.follows_and_followers' defaultMessage='Follows and followers' /></a> <div className='navigation-panel'>
<Link to='/' className='column-link column-link--logo'><Logo /></Link>
{showTrends && <div className='flex-spacer' />} <hr />
{showTrends && <TrendsContainer />}
</div>
);
export default withRouter(NavigationPanel); {signedIn && (
<React.Fragment>
<NavLink className='column-link column-link--transparent' to='/home' data-preview-title-id='column.home' data-preview-icon='home' ><Icon className='column-link__icon' id='home' fixedWidth /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>
<NavLink className='column-link column-link--transparent' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><NotificationsCounterIcon className='column-link__icon' /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></NavLink>
<FollowRequestsNavLink />
</React.Fragment>
)}
<NavLink className='column-link column-link--transparent' to='/explore' data-preview-title-id='explore.title' data-preview-icon='hashtag'><Icon className='column-link__icon' id='hashtag' fixedWidth /><FormattedMessage id='explore.title' defaultMessage='Explore' /></NavLink>
<NavLink className='column-link column-link--transparent' to='/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon className='column-link__icon' id='users' fixedWidth /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>
<NavLink className='column-link column-link--transparent' exact to='/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon className='column-link__icon' id='globe' fixedWidth /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>
{!signedIn && (
<React.Fragment>
<hr />
<SignInBanner />
</React.Fragment>
)}
{signedIn && (
<React.Fragment>
<NavLink className='column-link column-link--transparent' to='/conversations'><Icon className='column-link__icon' id='at' fixedWidth /><FormattedMessage id='navigation_bar.direct' defaultMessage='Direct messages' /></NavLink>
<NavLink className='column-link column-link--transparent' to='/favourites'><Icon className='column-link__icon' id='star' fixedWidth /><FormattedMessage id='navigation_bar.favourites' defaultMessage='Favourites' /></NavLink>
<NavLink className='column-link column-link--transparent' to='/bookmarks'><Icon className='column-link__icon' id='bookmark' fixedWidth /><FormattedMessage id='navigation_bar.bookmarks' defaultMessage='Bookmarks' /></NavLink>
<NavLink className='column-link column-link--transparent' to='/lists'><Icon className='column-link__icon' id='list-ul' fixedWidth /><FormattedMessage id='navigation_bar.lists' defaultMessage='Lists' /></NavLink>
<ListPanel />
<hr />
<a className='column-link column-link--transparent' href='/settings/preferences'><Icon className='column-link__icon' id='cog' fixedWidth /><FormattedMessage id='navigation_bar.preferences' defaultMessage='Preferences' /></a>
<a className='column-link column-link--transparent' href='/relationships'><Icon className='column-link__icon' id='users' fixedWidth /><FormattedMessage id='navigation_bar.follows_and_followers' defaultMessage='Follows and followers' /></a>
</React.Fragment>
)}
{showTrends && (
<React.Fragment>
<div className='flex-spacer' />
<TrendsContainer />
</React.Fragment>
)}
</div>
);
}
}

View File

@ -0,0 +1,11 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
const SignInBanner = () => (
<div className='sign-in-banner'>
<p><FormattedMessage id='sign_in_banner.text' defaultMessage='Sign in to follow profiles or hashtags, favourite, share and reply to posts, or interact from your account on a different server.' /></p>
<a href='/auth/sign_in' className='button button--block'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Sign in' /></a>
</div>
);
export default SignInBanner;

View File

@ -20,7 +20,6 @@ import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodo
import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers'; import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
import UploadArea from './components/upload_area'; import UploadArea from './components/upload_area';
import ColumnsAreaContainer from './containers/columns_area_container'; import ColumnsAreaContainer from './containers/columns_area_container';
import DocumentTitle from './components/document_title';
import PictureInPicture from 'mastodon/features/picture_in_picture'; import PictureInPicture from 'mastodon/features/picture_in_picture';
import { import {
Compose, Compose,
@ -53,8 +52,9 @@ import {
Explore, Explore,
FollowRecommendations, FollowRecommendations,
} from './util/async-components'; } from './util/async-components';
import { me } from '../../initial_state'; import { me, title } from '../../initial_state';
import { closeOnboarding, INTRODUCTION_VERSION } from 'mastodon/actions/onboarding'; import { closeOnboarding, INTRODUCTION_VERSION } from 'mastodon/actions/onboarding';
import { Helmet } from 'react-helmet';
// Dummy import, to make sure that <Status /> ends up in the application bundle. // Dummy import, to make sure that <Status /> ends up in the application bundle.
// Without this it ends up in ~8 very commonly used bundles. // Without this it ends up in ~8 very commonly used bundles.
@ -110,6 +110,10 @@ const keyMap = {
class SwitchingColumnsArea extends React.PureComponent { class SwitchingColumnsArea extends React.PureComponent {
static contextTypes = {
identity: PropTypes.object,
};
static propTypes = { static propTypes = {
children: PropTypes.node, children: PropTypes.node,
location: PropTypes.object, location: PropTypes.object,
@ -145,12 +149,25 @@ class SwitchingColumnsArea extends React.PureComponent {
render () { render () {
const { children, mobile } = this.props; const { children, mobile } = this.props;
const redirect = mobile ? <Redirect from='/' to='/home' exact /> : <Redirect from='/' to='/getting-started' exact />; const { signedIn } = this.context.identity;
let redirect;
if (signedIn) {
if (mobile) {
redirect = <Redirect from='/' to='/home' exact />;
} else {
redirect = <Redirect from='/' to='/getting-started' exact />;
}
} else {
redirect = <Redirect from='/' to='/explore' exact />;
}
return ( return (
<ColumnsAreaContainer ref={this.setRef} singleColumn={mobile}> <ColumnsAreaContainer ref={this.setRef} singleColumn={mobile}>
<WrappedSwitch> <WrappedSwitch>
{redirect} {redirect}
<WrappedRoute path='/getting-started' component={GettingStarted} content={children} /> <WrappedRoute path='/getting-started' component={GettingStarted} content={children} />
<WrappedRoute path='/keyboard-shortcuts' component={KeyboardShortcuts} content={children} /> <WrappedRoute path='/keyboard-shortcuts' component={KeyboardShortcuts} content={children} />
@ -208,6 +225,7 @@ class UI extends React.PureComponent {
static contextTypes = { static contextTypes = {
router: PropTypes.object.isRequired, router: PropTypes.object.isRequired,
identity: PropTypes.object.isRequired,
}; };
static propTypes = { static propTypes = {
@ -343,6 +361,8 @@ class UI extends React.PureComponent {
} }
componentDidMount () { componentDidMount () {
const { signedIn } = this.context.identity;
window.addEventListener('focus', this.handleWindowFocus, false); window.addEventListener('focus', this.handleWindowFocus, false);
window.addEventListener('blur', this.handleWindowBlur, false); window.addEventListener('blur', this.handleWindowBlur, false);
window.addEventListener('beforeunload', this.handleBeforeUnload, false); window.addEventListener('beforeunload', this.handleBeforeUnload, false);
@ -359,16 +379,18 @@ class UI extends React.PureComponent {
} }
// On first launch, redirect to the follow recommendations page // On first launch, redirect to the follow recommendations page
if (this.props.firstLaunch) { if (signedIn && this.props.firstLaunch) {
this.context.router.history.replace('/start'); this.context.router.history.replace('/start');
this.props.dispatch(closeOnboarding()); this.props.dispatch(closeOnboarding());
} }
this.props.dispatch(fetchMarkers()); if (signedIn) {
this.props.dispatch(expandHomeTimeline()); this.props.dispatch(fetchMarkers());
this.props.dispatch(expandNotifications()); this.props.dispatch(expandHomeTimeline());
this.props.dispatch(expandNotifications());
setTimeout(() => this.props.dispatch(fetchRules()), 3000); setTimeout(() => this.props.dispatch(fetchRules()), 3000);
}
this.hotkeys.__mousetrap__.stopCallback = (e, element) => { this.hotkeys.__mousetrap__.stopCallback = (e, element) => {
return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName); return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName);
@ -546,7 +568,10 @@ class UI extends React.PureComponent {
<LoadingBarContainer className='loading-bar' /> <LoadingBarContainer className='loading-bar' />
<ModalContainer /> <ModalContainer />
<UploadArea active={draggingOver} onClose={this.closeUploadModal} /> <UploadArea active={draggingOver} onClose={this.closeUploadModal} />
<DocumentTitle />
<Helmet>
<title>{title}</title>
</Helmet>
</div> </div>
</HotKeys> </HotKeys>
); );

View File

@ -3,6 +3,7 @@ const initialState = element && JSON.parse(element.textContent);
const getMeta = (prop) => initialState && initialState.meta && initialState.meta[prop]; const getMeta = (prop) => initialState && initialState.meta && initialState.meta[prop];
export const domain = getMeta('domain');
export const reduceMotion = getMeta('reduce_motion'); export const reduceMotion = getMeta('reduce_motion');
export const autoPlayGif = getMeta('auto_play_gif'); export const autoPlayGif = getMeta('auto_play_gif');
export const displayMedia = getMeta('display_media'); export const displayMedia = getMeta('display_media');
@ -26,5 +27,6 @@ export const title = getMeta('title');
export const cropImages = getMeta('crop_images'); export const cropImages = getMeta('crop_images');
export const disableSwiping = getMeta('disable_swiping'); export const disableSwiping = getMeta('disable_swiping');
export const languages = initialState && initialState.languages; export const languages = initialState && initialState.languages;
export const server = initialState && initialState.server;
export default initialState; export default initialState;

View File

@ -20,6 +20,7 @@
font-family: inherit; font-family: inherit;
background: $ui-base-color; background: $ui-base-color;
color: $darker-text-color; color: $darker-text-color;
border-radius: 4px;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
} }

View File

@ -126,6 +126,7 @@
&:hover { &:hover {
border-color: lighten($ui-primary-color, 4%); border-color: lighten($ui-primary-color, 4%);
color: lighten($darker-text-color, 4%); color: lighten($darker-text-color, 4%);
text-decoration: none;
} }
&:disabled { &:disabled {
@ -700,6 +701,15 @@
transition: height 0.4s ease, opacity 0.4s ease; transition: height 0.4s ease, opacity 0.4s ease;
} }
.sign-in-banner {
padding: 10px;
p {
color: $darker-text-color;
margin-bottom: 20px;
}
}
.emojione { .emojione {
font-size: inherit; font-size: inherit;
vertical-align: middle; vertical-align: middle;
@ -2214,6 +2224,7 @@ a.account__display-name {
> .scrollable { > .scrollable {
background: $ui-base-color; background: $ui-base-color;
border-radius: 0 0 4px 4px;
} }
} }
@ -2660,6 +2671,26 @@ a.account__display-name {
height: calc(100% - 10px); height: calc(100% - 10px);
overflow-y: hidden; overflow-y: hidden;
.hero-widget {
box-shadow: none;
&__text,
&__img,
&__img img {
border-radius: 0;
}
&__text {
padding: 15px;
color: $secondary-text-color;
strong {
font-weight: 700;
color: $primary-text-color;
}
}
}
.navigation-bar { .navigation-bar {
padding-top: 20px; padding-top: 20px;
padding-bottom: 20px; padding-bottom: 20px;
@ -2667,10 +2698,6 @@ a.account__display-name {
min-height: 20px; min-height: 20px;
} }
.flex-spacer {
background: transparent;
}
.compose-form { .compose-form {
flex: 1; flex: 1;
overflow-y: hidden; overflow-y: hidden;
@ -2709,6 +2736,14 @@ a.account__display-name {
flex: 0 0 auto; flex: 0 0 auto;
} }
.logo {
height: 30px;
width: auto;
}
}
.navigation-panel,
.compose-panel {
hr { hr {
flex: 0 0 auto; flex: 0 0 auto;
border: 0; border: 0;
@ -2836,6 +2871,7 @@ a.account__display-name {
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
background: lighten($ui-base-color, 4%); background: lighten($ui-base-color, 4%);
border-radius: 4px 4px 0 0;
color: $highlight-text-color; color: $highlight-text-color;
cursor: pointer; cursor: pointer;
flex: 0 0 auto; flex: 0 0 auto;
@ -3031,6 +3067,17 @@ a.account__display-name {
color: $highlight-text-color; color: $highlight-text-color;
} }
} }
&--logo {
background: transparent;
padding: 10px;
&:hover,
&:focus,
&:active {
background: transparent;
}
}
} }
.column-link__icon { .column-link__icon {
@ -3551,6 +3598,7 @@ a.status-card.compact:hover {
display: flex; display: flex;
font-size: 16px; font-size: 16px;
background: lighten($ui-base-color, 4%); background: lighten($ui-base-color, 4%);
border-radius: 4px 4px 0 0;
flex: 0 0 auto; flex: 0 0 auto;
cursor: pointer; cursor: pointer;
position: relative; position: relative;

View File

@ -17,10 +17,6 @@ class PermalinkRedirector
find_status_url_by_id(path_segments[2]) find_status_url_by_id(path_segments[2])
elsif path_segments[1] == 'accounts' && path_segments[2] =~ /\d/ elsif path_segments[1] == 'accounts' && path_segments[2] =~ /\d/
find_account_url_by_id(path_segments[2]) find_account_url_by_id(path_segments[2])
elsif path_segments[1] == 'timelines' && path_segments[2] == 'tag' && path_segments[3].present?
find_tag_url_by_name(path_segments[3])
elsif path_segments[1] == 'tags' && path_segments[2].present?
find_tag_url_by_name(path_segments[2])
end end
end end
end end

View File

@ -1,9 +1,11 @@
# frozen_string_literal: true # frozen_string_literal: true
class InitialStateSerializer < ActiveModel::Serializer class InitialStateSerializer < ActiveModel::Serializer
include RoutingHelper
attributes :meta, :compose, :accounts, attributes :meta, :compose, :accounts,
:media_attachments, :settings, :media_attachments, :settings,
:languages :languages, :server
has_one :push_subscription, serializer: REST::WebPushSubscriptionSerializer has_one :push_subscription, serializer: REST::WebPushSubscriptionSerializer
has_one :role, serializer: REST::RoleSerializer has_one :role, serializer: REST::RoleSerializer
@ -82,6 +84,13 @@ class InitialStateSerializer < ActiveModel::Serializer
LanguagesHelper::SUPPORTED_LOCALES.map { |(key, value)| [key, value[0], value[1]] } LanguagesHelper::SUPPORTED_LOCALES.map { |(key, value)| [key, value[0], value[1]] }
end end
def server
{
hero: instance_presenter.hero&.file&.url || instance_presenter.thumbnail&.file&.url || asset_pack_path('media/images/preview.png'),
description: instance_presenter.site_short_description.presence || I18n.t('about.about_mastodon_html'),
}
end
private private
def instance_presenter def instance_presenter

View File

@ -1,10 +1,14 @@
- content_for :header_tags do - content_for :header_tags do
= preload_pack_asset 'features/getting_started.js', crossorigin: 'anonymous' - if user_signed_in?
= preload_pack_asset 'features/compose.js', crossorigin: 'anonymous' = preload_pack_asset 'features/getting_started.js', crossorigin: 'anonymous'
= preload_pack_asset 'features/home_timeline.js', crossorigin: 'anonymous' = preload_pack_asset 'features/compose.js', crossorigin: 'anonymous'
= preload_pack_asset 'features/notifications.js', crossorigin: 'anonymous' = preload_pack_asset 'features/home_timeline.js', crossorigin: 'anonymous'
= preload_pack_asset 'features/notifications.js', crossorigin: 'anonymous'
= render partial: 'shared/og'
%meta{name: 'applicationServerKey', content: Rails.configuration.x.vapid_public_key} %meta{name: 'applicationServerKey', content: Rails.configuration.x.vapid_public_key}
= render_initial_state = render_initial_state
= javascript_pack_tag 'application', crossorigin: 'anonymous' = javascript_pack_tag 'application', crossorigin: 'anonymous'

View File

@ -92,6 +92,7 @@
"punycode": "^2.1.0", "punycode": "^2.1.0",
"react": "^16.14.0", "react": "^16.14.0",
"react-dom": "^16.14.0", "react-dom": "^16.14.0",
"react-helmet": "^6.1.0",
"react-hotkeys": "^1.1.4", "react-hotkeys": "^1.1.4",
"react-immutable-proptypes": "^2.2.0", "react-immutable-proptypes": "^2.2.0",
"react-immutable-pure-component": "^2.2.2", "react-immutable-pure-component": "^2.2.2",

View File

@ -7,27 +7,21 @@ RSpec.describe HomeController, type: :controller do
subject { get :index } subject { get :index }
context 'when not signed in' do context 'when not signed in' do
context 'when requested path is tag timeline' do it 'returns http success' do
it 'redirects to the tag\'s permalink' do
@request.path = '/web/timelines/tag/name'
is_expected.to redirect_to '/tags/name'
end
end
it 'redirects to about page' do
@request.path = '/' @request.path = '/'
is_expected.to redirect_to(about_path) is_expected.to have_http_status(:success)
end end
end end
context 'when signed in' do context 'when signed in' do
let(:user) { Fabricate(:user) } let(:user) { Fabricate(:user) }
before { sign_in(user) } before do
sign_in(user)
end
it 'assigns @body_classes' do it 'returns http success' do
subject is_expected.to have_http_status(:success)
expect(assigns(:body_classes)).to eq 'app-body'
end end
end end
end end

View File

@ -21,7 +21,7 @@ describe PermalinkRedirector do
it 'returns path for legacy tag links' do it 'returns path for legacy tag links' do
redirector = described_class.new('web/timelines/tag/hoge') redirector = described_class.new('web/timelines/tag/hoge')
expect(redirector.redirect_path).to eq '/tags/hoge' expect(redirector.redirect_path).to be_nil
end end
it 'returns path for pretty account links' do it 'returns path for pretty account links' do
@ -36,7 +36,7 @@ describe PermalinkRedirector do
it 'returns path for pretty tag links' do it 'returns path for pretty tag links' do
redirector = described_class.new('web/tags/hoge') redirector = described_class.new('web/tags/hoge')
expect(redirector.redirect_path).to eq '/tags/hoge' expect(redirector.redirect_path).to be_nil
end end
end end
end end

View File

@ -9194,6 +9194,21 @@ react-event-listener@^0.6.0:
prop-types "^15.6.0" prop-types "^15.6.0"
warning "^4.0.1" warning "^4.0.1"
react-fast-compare@^3.1.1:
version "3.2.0"
resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb"
integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==
react-helmet@^6.1.0:
version "6.1.0"
resolved "https://registry.yarnpkg.com/react-helmet/-/react-helmet-6.1.0.tgz#a750d5165cb13cf213e44747502652e794468726"
integrity sha512-4uMzEY9nlDlgxr61NL3XbKRy1hEkXmKNXhjbAIOVw5vcFrsdYbH2FEwcNyWvWinl103nXgzYNlns9ca+8kFiWw==
dependencies:
object-assign "^4.1.1"
prop-types "^15.7.2"
react-fast-compare "^3.1.1"
react-side-effect "^2.1.0"
react-hotkeys@^1.1.4: react-hotkeys@^1.1.4:
version "1.1.4" version "1.1.4"
resolved "https://registry.yarnpkg.com/react-hotkeys/-/react-hotkeys-1.1.4.tgz#a0712aa2e0c03a759fd7885808598497a4dace72" resolved "https://registry.yarnpkg.com/react-hotkeys/-/react-hotkeys-1.1.4.tgz#a0712aa2e0c03a759fd7885808598497a4dace72"
@ -9368,6 +9383,11 @@ react-select@^5.4.0:
prop-types "^15.6.0" prop-types "^15.6.0"
react-transition-group "^4.3.0" react-transition-group "^4.3.0"
react-side-effect@^2.1.0:
version "2.1.2"
resolved "https://registry.yarnpkg.com/react-side-effect/-/react-side-effect-2.1.2.tgz#dc6345b9e8f9906dc2eeb68700b615e0b4fe752a"
integrity sha512-PVjOcvVOyIILrYoyGEpDN3vmYNLdy1CajSFNt4TDsVQC5KpTijDvWVoR+/7Rz2xT978D8/ZtFceXxzsPwZEDvw==
react-sparklines@^1.7.0: react-sparklines@^1.7.0:
version "1.7.0" version "1.7.0"
resolved "https://registry.yarnpkg.com/react-sparklines/-/react-sparklines-1.7.0.tgz#9b1d97e8c8610095eeb2ad658d2e1fcf91f91a60" resolved "https://registry.yarnpkg.com/react-sparklines/-/react-sparklines-1.7.0.tgz#9b1d97e8c8610095eeb2ad658d2e1fcf91f91a60"