Merge pull request #1861 from ClearlyClaire/glitch-soc/features/logged-out-webui

Port logged-out Web UI to glitch-soc
master
Claire 2022-10-09 23:26:02 +02:00 committed by GitHub
commit 94713940c7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
93 changed files with 1802 additions and 505 deletions

View File

@ -20,7 +20,7 @@ class AboutController < ApplicationController
def more def more
flash.now[:notice] = I18n.t('about.instance_actor_flash') if params[:instance_actor] flash.now[:notice] = I18n.t('about.instance_actor_flash') if params[:instance_actor]
toc_generator = TOCGenerator.new(@instance_presenter.site_extended_description) toc_generator = TOCGenerator.new(@instance_presenter.extended_description)
@rules = Rule.ordered @rules = Rule.ordered
@contents = toc_generator.html @contents = toc_generator.html

View File

@ -6,6 +6,6 @@ class Api::V1::InstancesController < Api::BaseController
def show def show
expires_in 3.minutes, public: true expires_in 3.minutes, public: true
render_with_cache json: {}, serializer: REST::InstanceSerializer, root: 'instance' render_with_cache json: InstancePresenter.new, serializer: REST::V1::InstanceSerializer, root: 'instance'
end end
end end

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
class Api::V2::InstancesController < Api::V1::InstancesController
def show
expires_in 3.minutes, public: true
render_with_cache json: InstancePresenter.new, serializer: REST::InstanceSerializer, root: 'instance'
end
end

View File

@ -2,10 +2,10 @@
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_pack before_action :set_pack
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'
@ -16,7 +16,10 @@ 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
redirect_path ||= default_redirect_path
redirect_to(redirect_path) if redirect_path.present?
end end
def set_pack def set_pack
@ -24,8 +27,10 @@ class HomeController < ApplicationController
end end
def default_redirect_path def default_redirect_path
if request.path.start_with?('/web') || whitelist_mode? if whitelist_mode?
new_user_session_path new_user_session_path
elsif request.path.start_with?('/web')
nil
elsif single_user_mode? elsif single_user_mode?
short_account_path(Account.local.without_suspended.where('id > 0').first) short_account_path(Account.local.without_suspended.where('id > 0').first)
else else
@ -36,4 +41,8 @@ class HomeController < ApplicationController
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

@ -553,10 +553,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 'flavours/glitch/util/api'; import api from 'flavours/glitch/util/api';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import compareId from 'flavours/glitch/util/compare_id'; import compareId from 'flavours/glitch/util/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,19 +1,5 @@
import { import { setAlerts } from './setter';
SET_BROWSER_SUPPORT, import { saveSettings } from './registerer';
SET_SUBSCRIPTION,
CLEAR_SUBSCRIPTION,
SET_ALERTS,
setAlerts,
} from './setter';
import { register, saveSettings } from './registerer';
export {
SET_BROWSER_SUPPORT,
SET_SUBSCRIPTION,
CLEAR_SUBSCRIPTION,
SET_ALERTS,
register,
};
export function changeAlerts(path, value) { export function changeAlerts(path, value) {
return dispatch => { return dispatch => {
@ -21,3 +7,11 @@ export function changeAlerts(path, value) {
dispatch(saveSettings()); dispatch(saveSettings());
}; };
} }
export {
CLEAR_SUBSCRIPTION,
SET_BROWSER_SUPPORT,
SET_SUBSCRIPTION,
SET_ALERTS,
} from './setter';
export { register } from './registerer';

View File

@ -1,27 +0,0 @@
import api from 'flavours/glitch/util/api';
export const RULES_FETCH_REQUEST = 'RULES_FETCH_REQUEST';
export const RULES_FETCH_SUCCESS = 'RULES_FETCH_SUCCESS';
export const RULES_FETCH_FAIL = 'RULES_FETCH_FAIL';
export const fetchRules = () => (dispatch, getState) => {
dispatch(fetchRulesRequest());
api(getState)
.get('/api/v1/instance').then(({ data }) => dispatch(fetchRulesSuccess(data.rules)))
.catch(err => dispatch(fetchRulesFail(err)));
};
const fetchRulesRequest = () => ({
type: RULES_FETCH_REQUEST,
});
const fetchRulesSuccess = rules => ({
type: RULES_FETCH_SUCCESS,
rules,
});
const fetchRulesFail = error => ({
type: RULES_FETCH_FAIL,
error,
});

View File

@ -0,0 +1,30 @@
import api from 'flavours/glitch/util/api';
import { importFetchedAccount } from './importer';
export const SERVER_FETCH_REQUEST = 'Server_FETCH_REQUEST';
export const SERVER_FETCH_SUCCESS = 'Server_FETCH_SUCCESS';
export const SERVER_FETCH_FAIL = 'Server_FETCH_FAIL';
export const fetchServer = () => (dispatch, getState) => {
dispatch(fetchServerRequest());
api(getState)
.get('/api/v2/instance').then(({ data }) => {
if (data.contact.account) dispatch(importFetchedAccount(data.contact.account));
dispatch(fetchServerSuccess(data));
}).catch(err => dispatch(fetchServerFail(err)));
};
const fetchServerRequest = () => ({
type: SERVER_FETCH_REQUEST,
});
const fetchServerSuccess = server => ({
type: SERVER_FETCH_SUCCESS,
server,
});
const fetchServerFail = error => ({
type: SERVER_FETCH_FAIL,
error,
});

View File

@ -9,6 +9,7 @@ import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { me } from 'flavours/glitch/util/initial_state'; import { me } from 'flavours/glitch/util/initial_state';
import RelativeTimestamp from './relative_timestamp'; import RelativeTimestamp from './relative_timestamp';
import Skeleton from 'flavours/glitch/components/skeleton';
const messages = defineMessages({ const messages = defineMessages({
follow: { id: 'account.follow', defaultMessage: 'Follow' }, follow: { id: 'account.follow', defaultMessage: 'Follow' },
@ -26,7 +27,7 @@ export default @injectIntl
class Account extends ImmutablePureComponent { class Account extends ImmutablePureComponent {
static propTypes = { static propTypes = {
account: ImmutablePropTypes.map.isRequired, account: ImmutablePropTypes.map,
onFollow: PropTypes.func.isRequired, onFollow: PropTypes.func.isRequired,
onBlock: PropTypes.func.isRequired, onBlock: PropTypes.func.isRequired,
onMute: PropTypes.func.isRequired, onMute: PropTypes.func.isRequired,
@ -77,7 +78,16 @@ class Account extends ImmutablePureComponent {
} = this.props; } = this.props;
if (!account) { if (!account) {
return <div />; return (
<div className='account'>
<div className='account__wrapper'>
<div className='account__display-name'>
<div className='account__avatar-wrapper'><Skeleton width={36} height={36} /></div>
<DisplayName />
</div>
</div>
</div>
);
} }
if (hidden) { if (hidden) {

View File

@ -17,6 +17,7 @@ class ColumnHeader extends React.PureComponent {
static contextTypes = { static contextTypes = {
router: PropTypes.object, router: PropTypes.object,
identity: PropTypes.object,
}; };
static propTypes = { static propTypes = {
@ -150,7 +151,7 @@ class ColumnHeader extends React.PureComponent {
collapsedContent.push(moveButtons); collapsedContent.push(moveButtons);
} }
if (children || (multiColumn && this.props.onPin)) { if (this.context.identity.signedIn && (children || (multiColumn && this.props.onPin))) {
collapseButton = ( collapseButton = (
<button <button
className={collapsibleButtonClassName} className={collapsibleButtonClassName}

View File

@ -3,6 +3,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import { autoPlayGif } from 'flavours/glitch/util/initial_state'; import { autoPlayGif } from 'flavours/glitch/util/initial_state';
import Skeleton from 'flavours/glitch/components/skeleton';
export default class DisplayName extends React.PureComponent { export default class DisplayName extends React.PureComponent {
@ -46,14 +47,15 @@ export default class DisplayName extends React.PureComponent {
const computedClass = classNames('display-name', { inline }, className); const computedClass = classNames('display-name', { inline }, className);
if (!account) return null;
let displayName, suffix; let displayName, suffix;
let acct;
let acct = account.get('acct'); if (account) {
acct = account.get('acct');
if (acct.indexOf('@') === -1 && localDomain) { if (acct.indexOf('@') === -1 && localDomain) {
acct = `${acct}@${localDomain}`; acct = `${acct}@${localDomain}`;
}
} }
if (others && others.size > 0) { if (others && others.size > 0) {
@ -80,9 +82,12 @@ export default class DisplayName extends React.PureComponent {
<span className='display-name__account'>@{acct}</span> <span className='display-name__account'>@{acct}</span>
</a> </a>
); );
} else { } else if (account) {
displayName = <bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi>; displayName = <bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi>;
suffix = <span className='display-name__account'>@{acct}</span>; suffix = <span className='display-name__account'>@{acct}</span>;
} else {
displayName = <bdi><strong className='display-name__html'><Skeleton width='10ch' /></strong></bdi>;
suffix = <span className='display-name__account'><Skeleton width='7ch' /></span>;
} }
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

@ -0,0 +1,12 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
const NotSignedInIndicator = () => (
<div className='scrollable scrollable--flex'>
<div className='empty-column-indicator'>
<FormattedMessage id='not_signed_in_indicator.not_signed_in' defaultMessage='You need to sign in to access this resource.' />
</div>
</div>
);
export default NotSignedInIndicator;

View File

@ -34,6 +34,10 @@ const makeEmojiMap = record => record.get('emojis').reduce((obj, emoji) => {
export default @injectIntl export default @injectIntl
class Poll extends ImmutablePureComponent { class Poll extends ImmutablePureComponent {
static contextTypes = {
identity: PropTypes.object,
};
static propTypes = { static propTypes = {
poll: ImmutablePropTypes.map, poll: ImmutablePropTypes.map,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
@ -217,7 +221,7 @@ class Poll extends ImmutablePureComponent {
</ul> </ul>
<div className='poll__footer'> <div className='poll__footer'>
{!showResults && <button className='button button-secondary' disabled={disabled} onClick={this.handleVote}><FormattedMessage id='poll.vote' defaultMessage='Vote' /></button>} {!showResults && <button className='button button-secondary' disabled={disabled || !this.context.identity.signedIn} onClick={this.handleVote}><FormattedMessage id='poll.vote' defaultMessage='Vote' /></button>}
{showResults && !this.props.disabled && <span><button className='poll__link' onClick={this.handleRefresh}><FormattedMessage id='poll.refresh' defaultMessage='Refresh' /></button> · </span>} {showResults && !this.props.disabled && <span><button className='poll__link' onClick={this.handleRefresh}><FormattedMessage id='poll.refresh' defaultMessage='Refresh' /></button> · </span>}
{votesCount} {votesCount}
{poll.get('expires_at') && <span> · {timeRemaining}</span>} {poll.get('expires_at') && <span> · {timeRemaining}</span>}

View File

@ -0,0 +1,91 @@
import React from 'react';
import PropTypes from 'prop-types';
import { domain } from 'flavours/glitch/util/initial_state';
import { fetchServer } from 'flavours/glitch/actions/server';
import { connect } from 'react-redux';
import Account from 'flavours/glitch/containers/account_container';
import ShortNumber from 'flavours/glitch/components/short_number';
import Skeleton from 'flavours/glitch/components/skeleton';
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
const messages = defineMessages({
aboutActiveUsers: { id: 'server_banner.about_active_users', defaultMessage: 'People using this server during the last 30 days (Monthly Active Users)' },
});
const mapStateToProps = state => ({
server: state.get('server'),
});
export default @connect(mapStateToProps)
@injectIntl
class ServerBanner extends React.PureComponent {
static propTypes = {
server: PropTypes.object,
dispatch: PropTypes.func,
intl: PropTypes.object,
};
componentDidMount () {
const { dispatch } = this.props;
dispatch(fetchServer());
}
render () {
const { server, intl } = this.props;
const isLoading = server.get('isLoading');
return (
<div className='server-banner'>
<div className='server-banner__introduction'>
<FormattedMessage id='server_banner.introduction' defaultMessage='{domain} is part of the decentralized social network powered by {mastodon}.' values={{ domain: <strong>{domain}</strong>, mastodon: <a href='https://joinmastodon.org' target='_blank'>Mastodon</a> }} />
</div>
<img src={server.get('thumbnail')} alt={server.get('title')} className='server-banner__hero' />
<div className='server-banner__description'>
{isLoading ? (
<>
<Skeleton width='100%' />
<br />
<Skeleton width='100%' />
<br />
<Skeleton width='70%' />
</>
) : server.get('description')}
</div>
<div className='server-banner__meta'>
<div className='server-banner__meta__column'>
<h4><FormattedMessage id='server_banner.administered_by' defaultMessage='Administered by:' /></h4>
<Account id={server.getIn(['contact', 'account', 'id'])} />
</div>
<div className='server-banner__meta__column'>
<h4><FormattedMessage id='server_banner.server_stats' defaultMessage='Server stats:' /></h4>
{isLoading ? (
<>
<strong className='server-banner__number'><Skeleton width='10ch' /></strong>
<br />
<span className='server-banner__number-label'><Skeleton width='5ch' /></span>
</>
) : (
<>
<strong className='server-banner__number'><ShortNumber value={server.getIn(['usage', 'users', 'active_month'])} /></strong>
<br />
<span className='server-banner__number-label' title={intl.formatMessage(messages.aboutActiveUsers)}><FormattedMessage id='server_banner.active_users' defaultMessage='active users' /></span>
</>
)}
</div>
</div>
<hr className='spacer' />
<a className='button button--block button-secondary' href='/about/more' target='_blank'><FormattedMessage id='server_banner.learn_more' defaultMessage='Learn more' /></a>
</div>
);
}
}

View File

@ -83,6 +83,7 @@ class Status extends ImmutablePureComponent {
onEmbed: PropTypes.func, onEmbed: PropTypes.func,
onHeightChange: PropTypes.func, onHeightChange: PropTypes.func,
onToggleHidden: PropTypes.func, onToggleHidden: PropTypes.func,
onInteractionModal: PropTypes.func,
muted: PropTypes.bool, muted: PropTypes.bool,
hidden: PropTypes.bool, hidden: PropTypes.bool,
unread: PropTypes.bool, unread: PropTypes.bool,

View File

@ -69,6 +69,7 @@ class StatusActionBar extends ImmutablePureComponent {
onBookmark: PropTypes.func, onBookmark: PropTypes.func,
onFilter: PropTypes.func, onFilter: PropTypes.func,
onAddFilter: PropTypes.func, onAddFilter: PropTypes.func,
onInteractionModal: PropTypes.func,
withDismiss: PropTypes.bool, withDismiss: PropTypes.bool,
withCounters: PropTypes.bool, withCounters: PropTypes.bool,
showReplyCount: PropTypes.bool, showReplyCount: PropTypes.bool,
@ -86,10 +87,12 @@ class StatusActionBar extends ImmutablePureComponent {
] ]
handleReplyClick = () => { handleReplyClick = () => {
if (me) { const { signedIn } = this.context.identity;
if (signedIn) {
this.props.onReply(this.props.status, this.context.router.history); this.props.onReply(this.props.status, this.context.router.history);
} else { } else {
this._openInteractionDialog('reply'); this.props.onInteractionModal('reply', this.props.status);
} }
} }
@ -101,10 +104,22 @@ class StatusActionBar extends ImmutablePureComponent {
} }
handleFavouriteClick = (e) => { handleFavouriteClick = (e) => {
if (me) { const { signedIn } = this.context.identity;
if (signedIn) {
this.props.onFavourite(this.props.status, e); this.props.onFavourite(this.props.status, e);
} else { } else {
this._openInteractionDialog('favourite'); this.props.onInteractionModal('favourite', this.props.status);
}
}
handleReblogClick = e => {
const { signedIn } = this.context.identity;
if (signedIn) {
this.props.onReblog(this.props.status, e);
} else {
this.props.onInteractionModal('reblog', this.props.status);
} }
} }
@ -112,18 +127,6 @@ class StatusActionBar extends ImmutablePureComponent {
this.props.onBookmark(this.props.status, e); this.props.onBookmark(this.props.status, e);
} }
handleReblogClick = e => {
if (me) {
this.props.onReblog(this.props.status, e);
} else {
this._openInteractionDialog('reblog');
}
}
_openInteractionDialog = type => {
window.open(`/interact/${this.props.status.get('id')}?type=${type}`, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
}
handleDeleteClick = () => { handleDeleteClick = () => {
this.props.onDelete(this.props.status, this.context.router.history); this.props.onDelete(this.props.status, this.context.router.history);
} }

View File

@ -31,7 +31,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

@ -244,6 +244,14 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
}); });
}, },
onInteractionModal (type, status) {
dispatch(openModal('INTERACTION', {
type,
accountId: status.getIn(['account', 'id']),
url: status.get('url'),
}));
},
}); });
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status)); export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status));

View File

@ -3,7 +3,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
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 { autoPlayGif, me } from 'flavours/glitch/util/initial_state'; import { autoPlayGif, me, title, domain } from 'flavours/glitch/util/initial_state';
import { preferencesLink, profileLink, accountAdminLink } from 'flavours/glitch/util/backend_links'; import { preferencesLink, profileLink, accountAdminLink } from 'flavours/glitch/util/backend_links';
import classNames from 'classnames'; import classNames from 'classnames';
import Icon from 'flavours/glitch/components/icon'; import Icon from 'flavours/glitch/components/icon';
@ -14,6 +14,7 @@ import { NavLink } from 'react-router-dom';
import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container'; import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
import AccountNoteContainer from '../containers/account_note_container'; import AccountNoteContainer from '../containers/account_note_container';
import { PERMISSION_MANAGE_USERS } from 'flavours/glitch/permissions'; import { PERMISSION_MANAGE_USERS } from 'flavours/glitch/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',
@ -87,6 +96,7 @@ class Header extends ImmutablePureComponent {
onAddToList: PropTypes.func.isRequired, onAddToList: PropTypes.func.isRequired,
onEditAccountNote: PropTypes.func.isRequired, onEditAccountNote: PropTypes.func.isRequired,
onChangeLanguages: PropTypes.func.isRequired, onChangeLanguages: PropTypes.func.isRequired,
onInteractionModal: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
domain: PropTypes.string.isRequired, domain: PropTypes.string.isRequired,
hidden: PropTypes.bool, hidden: PropTypes.bool,
@ -124,6 +134,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;
@ -157,12 +168,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 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 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 : this.props.onInteractionModal} />;
} 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} />;
} }
@ -182,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 && !suspended) { if (signedIn && account.get('id') !== me && !suspended) {
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);
@ -209,7 +220,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'])) {
@ -242,7 +253,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);
@ -301,7 +312,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>
@ -313,7 +324,7 @@ class Header extends ImmutablePureComponent {
</h1> </h1>
</div> </div>
<AccountNoteContainer account={account} /> {signedIn && <AccountNoteContainer account={account} />}
{!(suspended || hidden) && ( {!(suspended || hidden) && (
<div className='account__header__extra'> <div className='account__header__extra'>
@ -339,6 +350,10 @@ class Header extends ImmutablePureComponent {
</div> </div>
)} )}
</div> </div>
<Helmet>
<title>{titleFromAccount(account)} - {title}</title>
</Helmet>
</div> </div>
); );
} }

View File

@ -24,6 +24,7 @@ export default class Header extends ImmutablePureComponent {
onEndorseToggle: PropTypes.func.isRequired, onEndorseToggle: PropTypes.func.isRequired,
onAddToList: PropTypes.func.isRequired, onAddToList: PropTypes.func.isRequired,
onChangeLanguages: PropTypes.func.isRequired, onChangeLanguages: PropTypes.func.isRequired,
onInteractionModal: PropTypes.func.isRequired,
hideTabs: PropTypes.bool, hideTabs: PropTypes.bool,
domain: PropTypes.string.isRequired, domain: PropTypes.string.isRequired,
hidden: PropTypes.bool, hidden: PropTypes.bool,
@ -97,6 +98,10 @@ export default class Header extends ImmutablePureComponent {
this.props.onChangeLanguages(this.props.account); this.props.onChangeLanguages(this.props.account);
} }
handleInteractionModal = () => {
this.props.onInteractionModal(this.props.account);
}
render () { render () {
const { account, hidden, hideTabs } = this.props; const { account, hidden, hideTabs } = this.props;
@ -124,6 +129,7 @@ export default class Header extends ImmutablePureComponent {
onAddToList={this.handleAddToList} onAddToList={this.handleAddToList}
onEditAccountNote={this.handleEditAccountNote} onEditAccountNote={this.handleEditAccountNote}
onChangeLanguages={this.handleChangeLanguages} onChangeLanguages={this.handleChangeLanguages}
onInteractionModal={this.handleInteractionModal}
domain={this.props.domain} domain={this.props.domain}
hidden={hidden} hidden={hidden}
/> />

View File

@ -58,6 +58,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
} }
}, },
onInteractionModal (account) {
dispatch(openModal('INTERACTION', {
type: 'follow',
accountId: account.get('id'),
url: account.get('url'),
}));
},
onBlock (account) { onBlock (account) {
if (account.getIn(['relationship', 'blocking'])) { if (account.getIn(['relationship', 'blocking'])) {
dispatch(unblockAccount(account.get('id'))); dispatch(unblockAccount(account.get('id')));

View File

@ -9,6 +9,8 @@ import { expandCommunityTimeline } from 'flavours/glitch/actions/timelines';
import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns'; import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
import ColumnSettingsContainer from './containers/column_settings_container'; import ColumnSettingsContainer from './containers/column_settings_container';
import { connectCommunityStream } from 'flavours/glitch/actions/streaming'; import { connectCommunityStream } from 'flavours/glitch/actions/streaming';
import { Helmet } from 'react-helmet';
import { title } from 'flavours/glitch/util/initial_state';
const messages = defineMessages({ const messages = defineMessages({
title: { id: 'column.community', defaultMessage: 'Local timeline' }, title: { id: 'column.community', defaultMessage: 'Local timeline' },
@ -39,6 +41,7 @@ class CommunityTimeline extends React.PureComponent {
static contextTypes = { static contextTypes = {
router: PropTypes.object, router: PropTypes.object,
identity: PropTypes.object,
}; };
static propTypes = { static propTypes = {
@ -72,18 +75,30 @@ class CommunityTimeline extends React.PureComponent {
componentDidMount () { componentDidMount () {
const { dispatch, onlyMedia } = this.props; const { dispatch, onlyMedia } = this.props;
const { signedIn } = this.context.identity;
dispatch(expandCommunityTimeline({ onlyMedia })); dispatch(expandCommunityTimeline({ onlyMedia }));
this.disconnect = dispatch(connectCommunityStream({ onlyMedia }));
if (signedIn) {
this.disconnect = dispatch(connectCommunityStream({ onlyMedia }));
}
} }
componentDidUpdate (prevProps) { componentDidUpdate (prevProps) {
const { signedIn } = this.context.identity;
if (prevProps.onlyMedia !== this.props.onlyMedia) { if (prevProps.onlyMedia !== this.props.onlyMedia) {
const { dispatch, onlyMedia } = this.props; const { dispatch, onlyMedia } = this.props;
this.disconnect(); if (this.disconnect) {
this.disconnect();
}
dispatch(expandCommunityTimeline({ onlyMedia })); dispatch(expandCommunityTimeline({ onlyMedia }));
this.disconnect = dispatch(connectCommunityStream({ onlyMedia }));
if (signedIn) {
this.disconnect = dispatch(connectCommunityStream({ onlyMedia }));
}
} }
} }
@ -132,6 +147,10 @@ class CommunityTimeline extends React.PureComponent {
bindToDocument={!multiColumn} bindToDocument={!multiColumn}
regex={this.props.regex} regex={this.props.regex}
/> />
<Helmet>
<title>{intl.formatMessage(messages.title)} - {title}</title>
</Helmet>
</Column> </Column>
); );
} }

View File

@ -13,6 +13,8 @@ import RadioButton from 'flavours/glitch/components/radio_button';
import LoadMore from 'flavours/glitch/components/load_more'; import LoadMore from 'flavours/glitch/components/load_more';
import ScrollContainer from 'flavours/glitch/containers/scroll_container'; import ScrollContainer from 'flavours/glitch/containers/scroll_container';
import LoadingIndicator from 'flavours/glitch/components/loading_indicator'; import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
import { title } from 'flavours/glitch/util/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

@ -12,6 +12,8 @@ import Suggestions from './suggestions';
import Search from 'flavours/glitch/features/compose/containers/search_container'; import Search from 'flavours/glitch/features/compose/containers/search_container';
import SearchResults from './results'; import SearchResults from './results';
import { showTrends } from 'flavours/glitch/util/initial_state'; import { showTrends } from 'flavours/glitch/util/initial_state';
import { Helmet } from 'react-helmet';
import { title } from 'flavours/glitch/util/initial_state';
const messages = defineMessages({ const messages = defineMessages({
title: { id: 'explore.title', defaultMessage: 'Explore' }, title: { id: 'explore.title', defaultMessage: 'Explore' },
@ -29,13 +31,13 @@ class Explore extends React.PureComponent {
static contextTypes = { static contextTypes = {
router: PropTypes.object, router: PropTypes.object,
identity: PropTypes.object,
}; };
static propTypes = { static propTypes = {
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
multiColumn: PropTypes.bool, multiColumn: PropTypes.bool,
isSearching: PropTypes.bool, isSearching: PropTypes.bool,
layout: PropTypes.string,
}; };
handleHeaderClick = () => { handleHeaderClick = () => {
@ -47,22 +49,21 @@ class Explore extends React.PureComponent {
} }
render () { render () {
const { intl, multiColumn, isSearching, layout } = this.props; const { intl, multiColumn, isSearching } = this.props;
const { signedIn } = this.context.identity;
return ( return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}> <Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
{layout === 'mobile' ? ( <ColumnHeader
<div className='explore__search-header'> icon={isSearching ? 'search' : 'hashtag'}
<Search /> title={intl.formatMessage(isSearching ? messages.searchResults : messages.title)}
</div> onClick={this.handleHeaderClick}
) : ( multiColumn={multiColumn}
<ColumnHeader />
icon={isSearching ? 'search' : 'hashtag'}
title={intl.formatMessage(isSearching ? messages.searchResults : messages.title)} <div className='explore__search-header'>
onClick={this.handleHeaderClick} <Search />
multiColumn={multiColumn} </div>
/>
)}
<div className='scrollable scrollable--flex'> <div className='scrollable scrollable--flex'>
{isSearching ? ( {isSearching ? (
@ -73,7 +74,7 @@ class Explore extends React.PureComponent {
<NavLink exact to='/explore'><FormattedMessage id='explore.trending_statuses' defaultMessage='Posts' /></NavLink> <NavLink exact to='/explore'><FormattedMessage id='explore.trending_statuses' defaultMessage='Posts' /></NavLink>
<NavLink exact to='/explore/tags'><FormattedMessage id='explore.trending_tags' defaultMessage='Hashtags' /></NavLink> <NavLink exact to='/explore/tags'><FormattedMessage id='explore.trending_tags' defaultMessage='Hashtags' /></NavLink>
<NavLink exact to='/explore/links'><FormattedMessage id='explore.trending_links' defaultMessage='News' /></NavLink> <NavLink exact to='/explore/links'><FormattedMessage id='explore.trending_links' defaultMessage='News' /></NavLink>
<NavLink exact to='/explore/suggestions'><FormattedMessage id='explore.suggested_follows' defaultMessage='For you' /></NavLink> {signedIn && <NavLink exact to='/explore/suggestions'><FormattedMessage id='explore.suggested_follows' defaultMessage='For you' /></NavLink>}
</div> </div>
<Switch> <Switch>
@ -82,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 'flavours/glitch/components/loading_indicator'; import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { fetchTrendingLinks } from 'flavours/glitch/actions/trends'; import { fetchTrendingLinks } from 'flavours/glitch/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 'flavours/glitch/actions/search'; import { expandSearch } from 'flavours/glitch/actions/search';
import Account from 'flavours/glitch/containers/account_container'; import Account from 'flavours/glitch/containers/account_container';
@ -10,10 +10,17 @@ import { ImmutableHashtag as Hashtag } from 'flavours/glitch/components/hashtag'
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
import LoadMore from 'flavours/glitch/components/load_more'; import LoadMore from 'flavours/glitch/components/load_more';
import LoadingIndicator from 'flavours/glitch/components/loading_indicator'; import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
import { title } from 'flavours/glitch/util/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 'flavours/glitch/features/directory/components/account_c
import LoadingIndicator from 'flavours/glitch/components/loading_indicator'; import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { fetchSuggestions, dismissSuggestion } from 'flavours/glitch/actions/suggestions'; import { fetchSuggestions, dismissSuggestion } from 'flavours/glitch/actions/suggestions';
import { FormattedMessage } from 'react-intl';
const mapStateToProps = state => ({ const mapStateToProps = state => ({
suggestions: state.getIn(['suggestions', 'items']), suggestions: state.getIn(['suggestions', 'items']),
@ -33,6 +34,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 'flavours/glitch/components/hashtag'
import LoadingIndicator from 'flavours/glitch/components/loading_indicator'; import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { fetchTrendingHashtags } from 'flavours/glitch/actions/trends'; import { fetchTrendingHashtags } from 'flavours/glitch/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

@ -10,7 +10,6 @@ import { requestBrowserPermission } from 'flavours/glitch/actions/notifications'
import { markAsPartial } from 'flavours/glitch/actions/timelines'; import { markAsPartial } from 'flavours/glitch/actions/timelines';
import Column from 'flavours/glitch/features/ui/components/column'; import Column from 'flavours/glitch/features/ui/components/column';
import Account from './components/account'; import Account from './components/account';
import Logo from 'flavours/glitch/components/logo';
import imageGreeting from 'mastodon/../images/elephant_ui_greeting.svg'; import imageGreeting from 'mastodon/../images/elephant_ui_greeting.svg';
import Button from 'flavours/glitch/components/button'; import Button from 'flavours/glitch/components/button';
@ -78,7 +77,10 @@ class FollowRecommendations extends ImmutablePureComponent {
<Column> <Column>
<div className='scrollable follow-recommendations-container'> <div className='scrollable follow-recommendations-container'>
<div className='column-title'> <div className='column-title'>
<Logo /> <svg viewBox='0 0 79 79' className='logo'>
<use xlinkHref='#logo-symbol-icon' />
</svg>
<h3><FormattedMessage id='follow_recommendations.heading' defaultMessage="Follow people you'd like to see posts from! Here are some suggestions." /></h3> <h3><FormattedMessage id='follow_recommendations.heading' defaultMessage="Follow people you'd like to see posts from! Here are some suggestions." /></h3>
<p><FormattedMessage id='follow_recommendations.lead' defaultMessage="Posts from people you follow will show up in chronological order on your home feed. Don't be afraid to make mistakes, you can unfollow people just as easily any time!" /></p> <p><FormattedMessage id='follow_recommendations.lead' defaultMessage="Posts from people you follow will show up in chronological order on your home feed. Don't be afraid to make mistakes, you can unfollow people just as easily any time!" /></p>
</div> </div>

View File

@ -14,6 +14,8 @@ import { isEqual } from 'lodash';
import { fetchHashtag, followHashtag, unfollowHashtag } from 'flavours/glitch/actions/tags'; import { fetchHashtag, followHashtag, unfollowHashtag } from 'flavours/glitch/actions/tags';
import Icon from 'flavours/glitch/components/icon'; import Icon from 'flavours/glitch/components/icon';
import classNames from 'classnames'; import classNames from 'classnames';
import { title } from 'flavours/glitch/util/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,
@ -90,6 +96,12 @@ class HashtagTimeline extends React.PureComponent {
} }
_subscribe (dispatch, id, tags = {}, local) { _subscribe (dispatch, id, tags = {}, local) {
const { signedIn } = this.context.identity;
if (!signedIn) {
return;
}
let any = (tags.any || []).map(tag => tag.value); let any = (tags.any || []).map(tag => tag.value);
let all = (tags.all || []).map(tag => tag.value); let all = (tags.all || []).map(tag => tag.value);
let none = (tags.none || []).map(tag => tag.value); let none = (tags.none || []).map(tag => tag.value);
@ -158,6 +170,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 +187,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 +195,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 +226,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

@ -13,6 +13,9 @@ import { fetchAnnouncements, toggleShowAnnouncements } from 'flavours/glitch/act
import AnnouncementsContainer from 'flavours/glitch/features/getting_started/containers/announcements_container'; import AnnouncementsContainer from 'flavours/glitch/features/getting_started/containers/announcements_container';
import classNames from 'classnames'; import classNames from 'classnames';
import IconWithBadge from 'flavours/glitch/components/icon_with_badge'; import IconWithBadge from 'flavours/glitch/components/icon_with_badge';
import NotSignedInIndicator from 'flavours/glitch/components/not_signed_in_indicator';
import { Helmet } from 'react-helmet';
import { title } from 'flavours/glitch/util/initial_state';
const messages = defineMessages({ const messages = defineMessages({
title: { id: 'column.home', defaultMessage: 'Home' }, title: { id: 'column.home', defaultMessage: 'Home' },
@ -33,6 +36,10 @@ export default @connect(mapStateToProps)
@injectIntl @injectIntl
class HomeTimeline extends React.PureComponent { class HomeTimeline extends React.PureComponent {
static contextTypes = {
identity: PropTypes.object,
};
static propTypes = { static propTypes = {
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
@ -115,6 +122,7 @@ class HomeTimeline extends React.PureComponent {
render () { render () {
const { intl, hasUnread, columnId, multiColumn, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props; const { intl, hasUnread, columnId, multiColumn, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props;
const pinned = !!columnId; const pinned = !!columnId;
const { signedIn } = this.context.identity;
let announcementsButton = null; let announcementsButton = null;
@ -149,15 +157,21 @@ class HomeTimeline extends React.PureComponent {
<ColumnSettingsContainer /> <ColumnSettingsContainer />
</ColumnHeader> </ColumnHeader>
<StatusListContainer {signedIn ? (
trackScroll={!pinned} <StatusListContainer
scrollKey={`home_timeline-${columnId}`} trackScroll={!pinned}
onLoadMore={this.handleLoadMore} scrollKey={`home_timeline-${columnId}`}
timelineId='home' onLoadMore={this.handleLoadMore}
emptyMessage={<FormattedMessage id='empty_column.home' defaultMessage='Your home timeline is empty! Follow more people to fill it up. {suggestions}' values={{ suggestions: <Link to='/start'><FormattedMessage id='empty_column.home.suggestions' defaultMessage='See some suggestions' /></Link> }} />} timelineId='home'
bindToDocument={!multiColumn} emptyMessage={<FormattedMessage id='empty_column.home' defaultMessage='Your home timeline is empty! Follow more people to fill it up. {suggestions}' values={{ suggestions: <Link to='/start'><FormattedMessage id='empty_column.home.suggestions' defaultMessage='See some suggestions' /></Link> }} />}
regex={this.props.regex} bindToDocument={!multiColumn}
/> regex={this.props.regex}
/>
) : <NotSignedInIndicator />}
<Helmet>
<title>{intl.formatMessage(messages.title)} - {title}</title>
</Helmet>
</Column> </Column>
); );
} }

View File

@ -0,0 +1,132 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import { registrationsOpen } from 'flavours/glitch/util/initial_state';
import { connect } from 'react-redux';
import Icon from 'flavours/glitch/components/icon';
import classNames from 'classnames';
const mapStateToProps = (state, { accountId }) => ({
displayNameHtml: state.getIn(['accounts', accountId, 'display_name_html']),
});
class Copypaste extends React.PureComponent {
static propTypes = {
value: PropTypes.string,
};
state = {
copied: false,
};
setRef = c => {
this.input = c;
}
handleInputClick = () => {
this.setState({ copied: false });
this.input.focus();
this.input.select();
this.input.setSelectionRange(0, this.input.value.length);
}
handleButtonClick = () => {
const { value } = this.props;
navigator.clipboard.writeText(value);
this.input.blur();
this.setState({ copied: true });
this.timeout = setTimeout(() => this.setState({ copied: false }), 700);
}
componentWillUnmount () {
if (this.timeout) clearTimeout(this.timeout);
}
render () {
const { value } = this.props;
const { copied } = this.state;
return (
<div className={classNames('copypaste', { copied })}>
<input
type='text'
ref={this.setRef}
value={value}
readOnly
onClick={this.handleInputClick}
/>
<button className='button' onClick={this.handleButtonClick}>
{copied ? <FormattedMessage id='copypaste.copied' defaultMessage='Copied' /> : <FormattedMessage id='copypaste.copy' defaultMessage='Copy' />}
</button>
</div>
);
}
}
export default @connect(mapStateToProps)
class InteractionModal extends React.PureComponent {
static propTypes = {
displayNameHtml: PropTypes.string,
url: PropTypes.string,
type: PropTypes.oneOf(['reply', 'reblog', 'favourite', 'follow']),
};
render () {
const { url, type, displayNameHtml } = this.props;
const name = <bdi dangerouslySetInnerHTML={{ __html: displayNameHtml }} />;
let title, actionDescription, icon;
switch(type) {
case 'reply':
icon = <Icon id='reply' />;
title = <FormattedMessage id='interaction_modal.title.reply' defaultMessage="Reply to {name}'s post" values={{ name }} />;
actionDescription = <FormattedMessage id='interaction_modal.description.reply' defaultMessage='With an account on Mastodon, you can respond to this post.' />;
break;
case 'reblog':
icon = <Icon id='retweet' />;
title = <FormattedMessage id='interaction_modal.title.reblog' defaultMessage="Boost {name}'s post" values={{ name }} />;
actionDescription = <FormattedMessage id='interaction_modal.description.reblog' defaultMessage='With an account on Mastodon, you can boost this post to share it with your own followers.' />;
break;
case 'favourite':
icon = <Icon id='star' />;
title = <FormattedMessage id='interaction_modal.title.favourite' defaultMessage="Favourite {name}'s post" values={{ name }} />;
actionDescription = <FormattedMessage id='interaction_modal.description.favourite' defaultMessage='With an account on Mastodon, you can favourite this post to let the author know you appreciate it and save it for later.' />;
break;
case 'follow':
icon = <Icon id='user-plus' />;
title = <FormattedMessage id='interaction_modal.title.follow' defaultMessage='Follow {name}' values={{ name }} />;
actionDescription = <FormattedMessage id='interaction_modal.description.follow' defaultMessage='With an account on Mastodon, you can follow {name} to receive their posts in your home feed.' values={{ name }} />;
break;
}
return (
<div className='modal-root__modal interaction-modal'>
<div className='interaction-modal__lead'>
<h3><span className='interaction-modal__icon'>{icon}</span> {title}</h3>
<p>{actionDescription} <FormattedMessage id='interaction_modal.preamble' defaultMessage="Since Mastodon is decentralized, you can use your existing account hosted by another Mastodon server or compatible platform if you don't have an account on this one." /></p>
</div>
<div className='interaction-modal__choices'>
<div className='interaction-modal__choices__choice'>
<h3><FormattedMessage id='interaction_modal.on_this_server' defaultMessage='On this server' /></h3>
<a href='/auth/sign_in' className='button button--block'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Sign in' /></a>
<a href={registrationsOpen ? '/auth/sign_up' : 'https://joinmastodon.org/servers'} className='button button--block button-tertiary'><FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' /></a>
</div>
<div className='interaction-modal__choices__choice'>
<h3><FormattedMessage id='interaction_modal.on_another_server' defaultMessage='On a different server' /></h3>
<p><FormattedMessage id='interaction_modal.other_server_instructions' defaultMessage='Simply copy and paste this URL into the search bar of your favourite app or the web interface where you are signed in.' /></p>
<Copypaste value={url} />
</div>
</div>
</div>
);
}
}

View File

@ -28,6 +28,9 @@ import LoadGap from 'flavours/glitch/components/load_gap';
import Icon from 'flavours/glitch/components/icon'; import Icon from 'flavours/glitch/components/icon';
import compareId from 'flavours/glitch/util/compare_id'; import compareId from 'flavours/glitch/util/compare_id';
import NotificationsPermissionBanner from './components/notifications_permission_banner'; import NotificationsPermissionBanner from './components/notifications_permission_banner';
import NotSignedInIndicator from 'flavours/glitch/components/not_signed_in_indicator';
import { Helmet } from 'react-helmet';
import { title } from 'flavours/glitch/util/initial_state';
import NotificationPurgeButtonsContainer from 'flavours/glitch/containers/notification_purge_buttons_container'; import NotificationPurgeButtonsContainer from 'flavours/glitch/containers/notification_purge_buttons_container';
@ -94,6 +97,10 @@ export default @connect(mapStateToProps, mapDispatchToProps)
@injectIntl @injectIntl
class Notifications extends React.PureComponent { class Notifications extends React.PureComponent {
static contextTypes = {
identity: PropTypes.object,
};
static propTypes = { static propTypes = {
columnId: PropTypes.string, columnId: PropTypes.string,
notifications: ImmutablePropTypes.list.isRequired, notifications: ImmutablePropTypes.list.isRequired,
@ -224,10 +231,11 @@ class Notifications extends React.PureComponent {
const { animatingNCD } = this.state; const { animatingNCD } = this.state;
const pinned = !!columnId; const pinned = !!columnId;
const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. When other people interact with you, you will see it here." />; const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. When other people interact with you, you will see it here." />;
const { signedIn } = this.context.identity;
let scrollableContent = null; let scrollableContent = null;
const filterBarContainer = showFilterBar const filterBarContainer = (signedIn && showFilterBar)
? (<FilterBarContainer />) ? (<FilterBarContainer />)
: null; : null;
@ -257,26 +265,32 @@ class Notifications extends React.PureComponent {
this.scrollableContent = scrollableContent; this.scrollableContent = scrollableContent;
const scrollContainer = ( let scrollContainer;
<ScrollableList
scrollKey={`notifications-${columnId}`} if (signedIn) {
trackScroll={!pinned} scrollContainer = (
isLoading={isLoading} <ScrollableList
showLoading={isLoading && notifications.size === 0} scrollKey={`notifications-${columnId}`}
hasMore={hasMore} trackScroll={!pinned}
numPending={numPending} isLoading={isLoading}
prepend={needsNotificationPermission && <NotificationsPermissionBanner />} showLoading={isLoading && notifications.size === 0}
alwaysPrepend hasMore={hasMore}
emptyMessage={emptyMessage} numPending={numPending}
onLoadMore={this.handleLoadOlder} prepend={needsNotificationPermission && <NotificationsPermissionBanner />}
onLoadPending={this.handleLoadPending} alwaysPrepend
onScrollToTop={this.handleScrollToTop} emptyMessage={emptyMessage}
onScroll={this.handleScroll} onLoadMore={this.handleLoadOlder}
bindToDocument={!multiColumn} onLoadPending={this.handleLoadPending}
> onScrollToTop={this.handleScrollToTop}
{scrollableContent} onScroll={this.handleScroll}
</ScrollableList> bindToDocument={!multiColumn}
); >
{scrollableContent}
</ScrollableList>
);
} else {
scrollContainer = <NotSignedInIndicator />;
}
const extraButtons = []; const extraButtons = [];
@ -354,8 +368,13 @@ class Notifications extends React.PureComponent {
> >
<ColumnSettingsContainer /> <ColumnSettingsContainer />
</ColumnHeader> </ColumnHeader>
{filterBarContainer} {filterBarContainer}
{scrollContainer} {scrollContainer}
<Helmet>
<title>{intl.formatMessage(messages.title)} - {title}</title>
</Helmet>
</Column> </Column>
); );
} }

View File

@ -44,6 +44,7 @@ class Footer extends ImmutablePureComponent {
static contextTypes = { static contextTypes = {
router: PropTypes.object, router: PropTypes.object,
identity: PropTypes.object,
}; };
static propTypes = { static propTypes = {
@ -69,26 +70,44 @@ class Footer extends ImmutablePureComponent {
}; };
handleReplyClick = () => { handleReplyClick = () => {
const { dispatch, askReplyConfirmation, intl } = this.props; const { dispatch, askReplyConfirmation, status, intl } = this.props;
const { signedIn } = this.context.identity;
if (askReplyConfirmation) { if (signedIn) {
dispatch(openModal('CONFIRM', { if (askReplyConfirmation) {
message: intl.formatMessage(messages.replyMessage), dispatch(openModal('CONFIRM', {
confirm: intl.formatMessage(messages.replyConfirm), message: intl.formatMessage(messages.replyMessage),
onConfirm: this._performReply, confirm: intl.formatMessage(messages.replyConfirm),
})); onConfirm: this._performReply,
}));
} else {
this._performReply();
}
} else { } else {
this._performReply(); dispatch(openModal('INTERACTION', {
type: 'reply',
accountId: status.getIn(['account', 'id']),
url: status.get('url'),
}));
} }
}; };
handleFavouriteClick = () => { handleFavouriteClick = () => {
const { dispatch, status } = this.props; const { dispatch, status } = this.props;
const { signedIn } = this.context.identity;
if (status.get('favourited')) { if (signedIn) {
dispatch(unfavourite(status)); if (status.get('favourited')) {
dispatch(unfavourite(status));
} else {
dispatch(favourite(status));
}
} else { } else {
dispatch(favourite(status)); dispatch(openModal('INTERACTION', {
type: 'favourite',
accountId: status.getIn(['account', 'id']),
url: status.get('url'),
}));
} }
}; };
@ -99,13 +118,22 @@ class Footer extends ImmutablePureComponent {
handleReblogClick = e => { handleReblogClick = e => {
const { dispatch, status } = this.props; const { dispatch, status } = this.props;
const { signedIn } = this.context.identity;
if (status.get('reblogged')) { if (signedIn) {
dispatch(unreblog(status)); if (status.get('reblogged')) {
} else if ((e && e.shiftKey) || !boostModal) { dispatch(unreblog(status));
this._performReblog(); } else if ((e && e.shiftKey) || !boostModal) {
this._performReblog();
} else {
dispatch(initBoostModal({ status, onReblog: this._performReblog }));
}
} else { } else {
dispatch(initBoostModal({ status, onReblog: this._performReblog })); dispatch(openModal('INTERACTION', {
type: 'reblog',
accountId: status.getIn(['account', 'id']),
url: status.get('url'),
}));
} }
}; };

View File

@ -9,6 +9,8 @@ import { expandPublicTimeline } from 'flavours/glitch/actions/timelines';
import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns'; import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
import ColumnSettingsContainer from './containers/column_settings_container'; import ColumnSettingsContainer from './containers/column_settings_container';
import { connectPublicStream } from 'flavours/glitch/actions/streaming'; import { connectPublicStream } from 'flavours/glitch/actions/streaming';
import { Helmet } from 'react-helmet';
import { title } from 'flavours/glitch/util/initial_state';
const messages = defineMessages({ const messages = defineMessages({
title: { id: 'column.public', defaultMessage: 'Federated timeline' }, title: { id: 'column.public', defaultMessage: 'Federated timeline' },
@ -43,6 +45,7 @@ class PublicTimeline extends React.PureComponent {
static contextTypes = { static contextTypes = {
router: PropTypes.object, router: PropTypes.object,
identity: PropTypes.object,
}; };
static propTypes = { static propTypes = {
@ -78,18 +81,29 @@ class PublicTimeline extends React.PureComponent {
componentDidMount () { componentDidMount () {
const { dispatch, onlyMedia, onlyRemote, allowLocalOnly } = this.props; const { dispatch, onlyMedia, onlyRemote, allowLocalOnly } = this.props;
const { signedIn } = this.context.identity;
dispatch(expandPublicTimeline({ onlyMedia, onlyRemote, allowLocalOnly })); dispatch(expandPublicTimeline({ onlyMedia, onlyRemote, allowLocalOnly }));
this.disconnect = dispatch(connectPublicStream({ onlyMedia, onlyRemote, allowLocalOnly })); if (signedIn) {
this.disconnect = dispatch(connectPublicStream({ onlyMedia, onlyRemote, allowLocalOnly }));
}
} }
componentDidUpdate (prevProps) { componentDidUpdate (prevProps) {
const { signedIn } = this.context.identity;
if (prevProps.onlyMedia !== this.props.onlyMedia || prevProps.onlyRemote !== this.props.onlyRemote || prevProps.allowLocalOnly !== this.props.allowLocalOnly) { if (prevProps.onlyMedia !== this.props.onlyMedia || prevProps.onlyRemote !== this.props.onlyRemote || prevProps.allowLocalOnly !== this.props.allowLocalOnly) {
const { dispatch, onlyMedia, onlyRemote, allowLocalOnly } = this.props; const { dispatch, onlyMedia, onlyRemote, allowLocalOnly } = this.props;
this.disconnect(); if (this.disconnect) {
this.disconnect();
}
dispatch(expandPublicTimeline({ onlyMedia, onlyRemote, allowLocalOnly })); dispatch(expandPublicTimeline({ onlyMedia, onlyRemote, allowLocalOnly }));
this.disconnect = dispatch(connectPublicStream({ onlyMedia, onlyRemote, allowLocalOnly }));
if (signedIn) {
this.disconnect = dispatch(connectPublicStream({ onlyMedia, onlyRemote, allowLocalOnly }));
}
} }
} }
@ -138,6 +152,10 @@ class PublicTimeline extends React.PureComponent {
bindToDocument={!multiColumn} bindToDocument={!multiColumn}
regex={this.props.regex} regex={this.props.regex}
/> />
<Helmet>
<title>{intl.formatMessage(messages.title)} - {title}</title>
</Helmet>
</Column> </Column>
); );
} }

View File

@ -20,7 +20,7 @@ const messages = defineMessages({
}); });
const mapStateToProps = state => ({ const mapStateToProps = state => ({
rules: state.get('rules'), rules: state.getIn(['server', 'rules']),
}); });
export default @connect(mapStateToProps) export default @connect(mapStateToProps)

View File

@ -7,7 +7,7 @@ import Button from 'flavours/glitch/components/button';
import Option from './components/option'; import Option from './components/option';
const mapStateToProps = state => ({ const mapStateToProps = state => ({
rules: state.get('rules'), rules: state.getIn(['server', 'rules']),
}); });
export default @connect(mapStateToProps) export default @connect(mapStateToProps)

View File

@ -152,6 +152,7 @@ class ActionBar extends React.PureComponent {
render () { render () {
const { status, intl } = this.props; const { status, intl } = this.props;
const { signedIn, permissions } = this.context.identity;
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
const pinnableStatus = ['public', 'unlisted', 'private'].includes(status.get('visibility')); const pinnableStatus = ['public', 'unlisted', 'private'].includes(status.get('visibility'));
@ -184,7 +185,7 @@ class ActionBar extends React.PureComponent {
menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick }); menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick });
menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick }); menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick });
menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport }); menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
if ((this.context.identity.permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS && (accountAdminLink || statusAdminLink)) { if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS && (accountAdminLink || statusAdminLink)) {
menu.push(null); menu.push(null);
if (accountAdminLink !== undefined) { if (accountAdminLink !== undefined) {
menu.push({ menu.push({
@ -224,7 +225,7 @@ class ActionBar extends React.PureComponent {
<div className='detailed-status__button'><IconButton className={classNames({ reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} /></div> <div className='detailed-status__button'><IconButton className={classNames({ reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} /></div>
<div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /></div> <div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /></div>
{shareButton} {shareButton}
<div className='detailed-status__button'><IconButton className='bookmark-icon' active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /></div> <div className='detailed-status__button'><IconButton className='bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /></div>
<div className='detailed-status__action-bar-dropdown'> <div className='detailed-status__action-bar-dropdown'>
<DropdownMenuContainer size={18} icon='ellipsis-h' items={menu} direction='left' title={intl.formatMessage(messages.more)} /> <DropdownMenuContainer size={18} icon='ellipsis-h' items={menu} direction='left' title={intl.formatMessage(messages.more)} />

View File

@ -47,11 +47,12 @@ import { openModal } from 'flavours/glitch/actions/modal';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl } 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, favouriteModal, deleteModal } from 'flavours/glitch/util/initial_state'; import { boostModal, favouriteModal, deleteModal, title } from 'flavours/glitch/util/initial_state';
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from 'flavours/glitch/util/fullscreen'; import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from 'flavours/glitch/util/fullscreen';
import { autoUnfoldCW } from 'flavours/glitch/util/content_warning'; import { autoUnfoldCW } from 'flavours/glitch/util/content_warning';
import { textForScreenReader, defaultMediaVisibility } from 'flavours/glitch/components/status'; import { textForScreenReader, defaultMediaVisibility } from 'flavours/glitch/components/status';
import Icon from 'flavours/glitch/components/icon'; import Icon from 'flavours/glitch/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' },
@ -147,12 +148,30 @@ 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 {
static contextTypes = { static contextTypes = {
router: PropTypes.object, router: PropTypes.object,
identity: PropTypes.object,
}; };
static propTypes = { static propTypes = {
@ -245,14 +264,25 @@ class Status extends ImmutablePureComponent {
} }
handleFavouriteClick = (status, e) => { handleFavouriteClick = (status, e) => {
if (status.get('favourited')) { const { dispatch } = this.props;
this.props.dispatch(unfavourite(status)); const { signedIn } = this.context.identity;
} else {
if ((e && e.shiftKey) || !favouriteModal) { if (signedIn) {
this.handleModalFavourite(status); if (status.get('favourited')) {
dispatch(unfavourite(status));
} else { } else {
this.props.dispatch(openModal('FAVOURITE', { status, onFavourite: this.handleModalFavourite })); if ((e && e.shiftKey) || !favouriteModal) {
this.handleModalFavourite(status);
} else {
dispatch(openModal('FAVOURITE', { status, onFavourite: this.handleModalFavourite }));
}
} }
} else {
dispatch(openModal('INTERACTION', {
type: 'favourite',
accountId: status.getIn(['account', 'id']),
url: status.get('url'),
}));
} }
} }
@ -265,16 +295,26 @@ class Status extends ImmutablePureComponent {
} }
handleReplyClick = (status) => { handleReplyClick = (status) => {
let { askReplyConfirmation, dispatch, intl } = this.props; const { askReplyConfirmation, dispatch, intl } = this.props;
if (askReplyConfirmation) { const { signedIn } = this.context.identity;
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.replyMessage), if (signedIn) {
confirm: intl.formatMessage(messages.replyConfirm), if (askReplyConfirmation) {
onDoNotAsk: () => dispatch(changeLocalSetting(['confirm_before_clearing_draft'], false)), dispatch(openModal('CONFIRM', {
onConfirm: () => dispatch(replyCompose(status, this.context.router.history)), message: intl.formatMessage(messages.replyMessage),
})); confirm: intl.formatMessage(messages.replyConfirm),
onDoNotAsk: () => dispatch(changeLocalSetting(['confirm_before_clearing_draft'], false)),
onConfirm: () => dispatch(replyCompose(status, this.context.router.history)),
}));
} else {
dispatch(replyCompose(status, this.context.router.history));
}
} else { } else {
dispatch(replyCompose(status, this.context.router.history)); dispatch(openModal('INTERACTION', {
type: 'reply',
accountId: status.getIn(['account', 'id']),
url: status.get('url'),
}));
} }
} }
@ -290,13 +330,22 @@ class Status extends ImmutablePureComponent {
handleReblogClick = (status, e) => { handleReblogClick = (status, e) => {
const { settings, dispatch } = this.props; const { settings, dispatch } = this.props;
const { signedIn } = this.context.identity;
if (settings.get('confirm_boost_missing_media_description') && status.get('media_attachments').some(item => !item.get('description')) && !status.get('reblogged')) { if (signedIn) {
dispatch(initBoostModal({ status, onReblog: this.handleModalReblog, missingMediaDescription: true })); if (settings.get('confirm_boost_missing_media_description') && status.get('media_attachments').some(item => !item.get('description')) && !status.get('reblogged')) {
} else if ((e && e.shiftKey) || !boostModal) { dispatch(initBoostModal({ status, onReblog: this.handleModalReblog, missingMediaDescription: true }));
this.handleModalReblog(status); } else if ((e && e.shiftKey) || !boostModal) {
this.handleModalReblog(status);
} else {
dispatch(initBoostModal({ status, onReblog: this.handleModalReblog }));
}
} else { } else {
dispatch(initBoostModal({ status, onReblog: this.handleModalReblog })); dispatch(openModal('INTERACTION', {
type: 'reblog',
accountId: status.getIn(['account', 'id']),
url: status.get('url'),
}));
} }
} }
@ -633,6 +682,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 = {
@ -213,11 +214,12 @@ class ColumnsArea extends ImmutablePureComponent {
render () { render () {
const { columns, children, singleColumn, intl, navbarUnder, openSettings } = this.props; const { columns, children, singleColumn, intl, navbarUnder, openSettings } = 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

@ -1,16 +1,42 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import SearchContainer from 'flavours/glitch/features/compose/containers/search_container'; import SearchContainer from 'flavours/glitch/features/compose/containers/search_container';
import ComposeFormContainer from 'flavours/glitch/features/compose/containers/compose_form_container'; import ComposeFormContainer from 'flavours/glitch/features/compose/containers/compose_form_container';
import NavigationContainer from 'flavours/glitch/features/compose/containers/navigation_container'; import NavigationContainer from 'flavours/glitch/features/compose/containers/navigation_container';
import LinkFooter from './link_footer'; import LinkFooter from './link_footer';
import ServerBanner from 'flavours/glitch/components/server_banner';
const ComposePanel = () => ( export default
<div className='compose-panel'> class ComposePanel extends React.PureComponent {
<SearchContainer openInRoute />
<NavigationContainer />
<ComposeFormContainer singleColumn />
<LinkFooter withHotkeys />
</div>
);
export default ComposePanel; static contextTypes = {
identity: PropTypes.object.isRequired,
};
render() {
const { signedIn } = this.context.identity;
return (
<div className='compose-panel'>
<SearchContainer openInRoute />
{!signedIn && (
<React.Fragment>
<ServerBanner />
<div className='flex-spacer' />
</React.Fragment>
)}
{signedIn && (
<React.Fragment>
<NavigationContainer />
<ComposeFormContainer singleColumn />
</React.Fragment>
)}
<LinkFooter withHotkeys />
</div>
);
}
};

View File

@ -34,6 +34,7 @@ class LinkFooter extends React.PureComponent {
}; };
static propTypes = { static propTypes = {
withHotkeys: PropTypes.bool,
onLogout: PropTypes.func.isRequired, onLogout: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
}; };
@ -48,18 +49,53 @@ class LinkFooter extends React.PureComponent {
} }
render () { render () {
const { withHotkeys } = this.props;
const { signedIn, permissions } = this.context.identity;
const items = [];
if ((this.context.identity.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 (signedIn && withHotkeys) {
items.push(<Link key='hotkeys' to='/keyboard-shortcuts'><FormattedMessage id='navigation_bar.keyboard_shortcuts' defaultMessage='Hotkeys' /></Link>);
}
if (signedIn && securityLink) {
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>);
if (privacyPolicyLink) {
items.push(<a key='terms' href={privacyPolicyLink} target='_blank'><FormattedMessage id='getting_started.privacy_policy' defaultMessage='Privacy Policy' /></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>} {items.map((item, index, array) => (
{!!securityLink && <li><a href='/auth/edit'><FormattedMessage id='getting_started.security' defaultMessage='Security' /></a> · </li>} <li>{item} { index === array.length - 1 ? null : ' · ' }</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={privacyPolicyLink} target='_blank'><FormattedMessage id='getting_started.privacy_policy' defaultMessage='Privacy Policy' /></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={signOutLink} onClick={this.handleLogoutClick}><FormattedMessage id='navigation_bar.logout' defaultMessage='Logout' /></a></li>
</ul> </ul>
<p> <p>

View File

@ -16,6 +16,7 @@ import ConfirmationModal from './confirmation_modal';
import SubscribedLanguagesModal from 'flavours/glitch/features/subscribed_languages_modal'; import SubscribedLanguagesModal from 'flavours/glitch/features/subscribed_languages_modal';
import FocalPointModal from './focal_point_modal'; import FocalPointModal from './focal_point_modal';
import DeprecatedSettingsModal from './deprecated_settings_modal'; import DeprecatedSettingsModal from './deprecated_settings_modal';
import InteractionModal from 'flavours/glitch/features/interaction_modal';
import { import {
OnboardingModal, OnboardingModal,
MuteModal, MuteModal,
@ -53,6 +54,7 @@ const MODAL_COMPONENTS = {
'COMPARE_HISTORY': CompareHistoryModal, 'COMPARE_HISTORY': CompareHistoryModal,
'FILTER': FilterModal, 'FILTER': FilterModal,
'SUBSCRIBED_LANGUAGES': () => Promise.resolve({ default: SubscribedLanguagesModal }), 'SUBSCRIBED_LANGUAGES': () => Promise.resolve({ default: SubscribedLanguagesModal }),
'INTERACTION': () => Promise.resolve({ default: InteractionModal }),
}; };
export default class ModalRoot extends React.PureComponent { export default class ModalRoot extends React.PureComponent {

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 'flavours/glitch/components/icon'; import Icon from 'flavours/glitch/components/icon';
import { showTrends } from 'flavours/glitch/util/initial_state'; import { showTrends } from 'flavours/glitch/util/initial_state';
@ -8,30 +9,70 @@ 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 'flavours/glitch/features/getting_started/containers/trends_container'; import TrendsContainer from 'flavours/glitch/features/getting_started/containers/trends_container';
import SignInBanner from './sign_in_banner';
const NavigationPanel = ({ onOpenSettings }) => ( 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 />
{ showTrends && <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='envelope' fixedWidth /><FormattedMessage id='navigation_bar.direct' defaultMessage='Direct messages' /></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 /> static propTypes = {
onOpenSettings: PropTypes.func,
};
{!!preferencesLink && <a className='column-link column-link--transparent' href={preferencesLink} target='_blank'><Icon className='column-link__icon' id='cog' fixedWidth /><FormattedMessage id='navigation_bar.preferences' defaultMessage='Preferences' /></a>} render() {
<a className='column-link column-link--transparent' href='#' onClick={onOpenSettings}><Icon className='column-link__icon' id='cogs' fixedWidth /><FormattedMessage id='navigation_bar.app_settings' defaultMessage='App settings' /></a> const { signedIn } = this.context.identity;
{!!relationshipsLink && <a className='column-link column-link--transparent' href={relationshipsLink} target='_blank'><Icon className='column-link__icon' id='users' fixedWidth /><FormattedMessage id='navigation_bar.follows_and_followers' defaultMessage='Follows and followers' /></a>} const { onOpenSettings } = this.props;
{showTrends && <div className='flex-spacer' />} return (
{showTrends && <TrendsContainer />} <div className='navigation-panel'>
</div> {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>
)}
export default withRouter(NavigationPanel); { showTrends && <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='envelope' fixedWidth /><FormattedMessage id='navigation_bar.direct' defaultMessage='Direct messages' /></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 />
{!!preferencesLink && <a className='column-link column-link--transparent' href={preferencesLink} target='_blank'><Icon className='column-link__icon' id='cog' fixedWidth /><FormattedMessage id='navigation_bar.preferences' defaultMessage='Preferences' /></a>}
<a className='column-link column-link--transparent' href='#' onClick={onOpenSettings}><Icon className='column-link__icon' id='cogs' fixedWidth /><FormattedMessage id='navigation_bar.app_settings' defaultMessage='App settings' /></a>
{!!relationshipsLink && <a className='column-link column-link--transparent' href={relationshipsLink} target='_blank'><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

@ -2,7 +2,7 @@ import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { submitReport } from 'flavours/glitch/actions/reports'; import { submitReport } from 'flavours/glitch/actions/reports';
import { expandAccountTimeline } from 'flavours/glitch/actions/timelines'; import { expandAccountTimeline } from 'flavours/glitch/actions/timelines';
import { fetchRules } from 'flavours/glitch/actions/rules'; import { fetchServer } from 'flavours/glitch/actions/server';
import { fetchRelationships } from 'flavours/glitch/actions/accounts'; import { fetchRelationships } from 'flavours/glitch/actions/accounts';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
@ -119,7 +119,7 @@ class ReportModal extends ImmutablePureComponent {
dispatch(fetchRelationships([accountId])); dispatch(fetchRelationships([accountId]));
dispatch(expandAccountTimeline(accountId, { withReplies: true })); dispatch(expandAccountTimeline(accountId, { withReplies: true }));
dispatch(fetchRules()); dispatch(fetchServer());
} }
render () { render () {

View File

@ -0,0 +1,13 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { registrationsOpen } from 'flavours/glitch/util/initial_state';
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>
<a href={registrationsOpen ? '/auth/sign_up' : 'https://joinmastodon.org/servers'} className='button button--block button-tertiary'><FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' /></a>
</div>
);
export default SignInBanner;

View File

@ -10,7 +10,7 @@ import { debounce } from 'lodash';
import { uploadCompose, resetCompose, changeComposeSpoilerness } from 'flavours/glitch/actions/compose'; import { uploadCompose, resetCompose, changeComposeSpoilerness } from 'flavours/glitch/actions/compose';
import { expandHomeTimeline } from 'flavours/glitch/actions/timelines'; import { expandHomeTimeline } from 'flavours/glitch/actions/timelines';
import { expandNotifications, notificationsSetVisibility } from 'flavours/glitch/actions/notifications'; import { expandNotifications, notificationsSetVisibility } from 'flavours/glitch/actions/notifications';
import { fetchRules } from 'flavours/glitch/actions/rules'; import { fetchServer } from 'flavours/glitch/actions/server';
import { clearHeight } from 'flavours/glitch/actions/height_cache'; import { clearHeight } from 'flavours/glitch/actions/height_cache';
import { changeLayout } from 'flavours/glitch/actions/app'; import { changeLayout } from 'flavours/glitch/actions/app';
import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'flavours/glitch/actions/markers'; import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'flavours/glitch/actions/markers';
@ -54,9 +54,10 @@ import {
FollowRecommendations, FollowRecommendations,
} from 'flavours/glitch/util/async-components'; } from 'flavours/glitch/util/async-components';
import { HotKeys } from 'react-hotkeys'; import { HotKeys } from 'react-hotkeys';
import { me } from 'flavours/glitch/util/initial_state'; import { me, title } from 'flavours/glitch/util/initial_state';
import { closeOnboarding, INTRODUCTION_VERSION } from 'flavours/glitch/actions/onboarding'; import { closeOnboarding, INTRODUCTION_VERSION } from 'flavours/glitch/actions/onboarding';
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
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.
@ -121,6 +122,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,
@ -157,12 +162,25 @@ class SwitchingColumnsArea extends React.PureComponent {
render () { render () {
const { children, mobile, navbarUnder } = this.props; const { children, mobile, navbarUnder } = 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} navbarUnder={navbarUnder}> <ColumnsAreaContainer ref={this.setRef} singleColumn={mobile} navbarUnder={navbarUnder}>
<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} />
@ -219,6 +237,10 @@ export default @connect(mapStateToProps)
@withRouter @withRouter
class UI extends React.Component { class UI extends React.Component {
static contextTypes = {
identity: PropTypes.object.isRequired,
};
static propTypes = { static propTypes = {
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
children: PropTypes.node, children: PropTypes.node,
@ -358,6 +380,8 @@ class UI extends React.Component {
} }
componentDidMount () { componentDidMount () {
const { signedIn } = this.context.identity;
window.addEventListener('beforeunload', this.handleBeforeUnload, false); window.addEventListener('beforeunload', this.handleBeforeUnload, false);
window.addEventListener('resize', this.handleResize, { passive: true }); window.addEventListener('resize', this.handleResize, { passive: true });
@ -374,16 +398,18 @@ class UI extends React.Component {
this.favicon = new Favico({ animation:"none" }); this.favicon = new Favico({ animation:"none" });
// 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(fetchServer()), 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);
@ -635,6 +661,10 @@ class UI extends React.Component {
<LoadingBarContainer className='loading-bar' /> <LoadingBarContainer className='loading-bar' />
<ModalContainer /> <ModalContainer />
<UploadArea active={draggingOver} onClose={this.closeUploadModal} /> <UploadArea active={draggingOver} onClose={this.closeUploadModal} />
<Helmet>
<title>{title}</title>
</Helmet>
</div> </div>
</HotKeys> </HotKeys>
); );

View File

@ -1,8 +1,10 @@
import 'packs/public-path'; import 'packs/public-path';
import loadPolyfills from 'flavours/glitch/util/load_polyfills'; import loadPolyfills from 'flavours/glitch/util/load_polyfills';
loadPolyfills().then(() => { loadPolyfills().then(async () => {
require('flavours/glitch/util/main').default(); const { default: main } = await import('flavours/glitch/util/main');
return main();
}).catch(e => { }).catch(e => {
console.error(e); console.error(e);
}); });

View File

@ -17,7 +17,7 @@ import push_notifications from './push_notifications';
import status_lists from './status_lists'; import status_lists from './status_lists';
import mutes from './mutes'; import mutes from './mutes';
import blocks from './blocks'; import blocks from './blocks';
import rules from './rules'; import server from './server';
import boosts from './boosts'; import boosts from './boosts';
import contexts from './contexts'; import contexts from './contexts';
import compose from './compose'; import compose from './compose';
@ -64,7 +64,7 @@ const reducers = {
push_notifications, push_notifications,
mutes, mutes,
blocks, blocks,
rules, server,
boosts, boosts,
contexts, contexts,
compose, compose,

View File

@ -1,13 +0,0 @@
import { RULES_FETCH_SUCCESS } from 'flavours/glitch/actions/rules';
import { List as ImmutableList, fromJS } from 'immutable';
const initialState = ImmutableList();
export default function rules(state = initialState, action) {
switch (action.type) {
case RULES_FETCH_SUCCESS:
return fromJS(action.rules);
default:
return state;
}
}

View File

@ -0,0 +1,19 @@
import { SERVER_FETCH_REQUEST, SERVER_FETCH_SUCCESS, SERVER_FETCH_FAIL } from 'flavours/glitch/actions/server';
import { Map as ImmutableMap, fromJS } from 'immutable';
const initialState = ImmutableMap({
isLoading: true,
});
export default function server(state = initialState, action) {
switch (action.type) {
case SERVER_FETCH_REQUEST:
return state.set('isLoading', true);
case SERVER_FETCH_SUCCESS:
return fromJS(action.server).set('isLoading', false);
case SERVER_FETCH_FAIL:
return state.set('isLoading', false);
default:
return state;
}
}

View File

@ -60,6 +60,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

@ -117,6 +117,7 @@
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;
@ -204,6 +205,17 @@
color: $highlight-text-color; color: $highlight-text-color;
} }
} }
&--logo {
background: transparent;
padding: 10px;
&:hover,
&:focus,
&:active {
background: transparent;
}
}
} }
.column-link__icon { .column-link__icon {
@ -255,6 +267,7 @@
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;
@ -309,6 +322,7 @@
> .scrollable { > .scrollable {
background: $ui-base-color; background: $ui-base-color;
border-radius: 0 0 4px 4px;
} }
} }
@ -352,6 +366,11 @@
&:focus { &:focus {
text-shadow: 0 0 4px darken($ui-highlight-color, 5%); text-shadow: 0 0 4px darken($ui-highlight-color, 5%);
} }
&:disabled {
color: $dark-text-color;
cursor: default;
}
} }
.column-header__notif-cleaning-buttons { .column-header__notif-cleaning-buttons {

View File

@ -110,6 +110,27 @@
&: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 {
opacity: 0.5;
}
}
&.button-tertiary {
background: transparent;
padding: 6px 17px;
color: $highlight-text-color;
border: 1px solid $highlight-text-color;
&:active,
&:focus,
&:hover {
background: $ui-highlight-color;
color: $primary-text-color;
border: 0;
padding: 7px 18px;
} }
&:disabled { &:disabled {
@ -1756,3 +1777,4 @@ noscript {
@import 'single_column'; @import 'single_column';
@import 'announcements'; @import 'announcements';
@import 'explore'; @import 'explore';
@import 'signed_out';

View File

@ -23,6 +23,7 @@
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
box-sizing: border-box;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@ -1304,3 +1305,123 @@ img.modal-warning {
margin-bottom: 15px; margin-bottom: 15px;
width: 60px; width: 60px;
} }
.interaction-modal {
max-width: 90vw;
width: 600px;
background: $ui-base-color;
border-radius: 8px;
overflow: hidden;
position: relative;
display: block;
padding: 20px;
h3 {
font-size: 22px;
line-height: 33px;
font-weight: 700;
text-align: center;
}
&__icon {
color: $highlight-text-color;
margin: 0 5px;
}
&__lead {
padding: 20px;
text-align: center;
h3 {
margin-bottom: 15px;
}
p {
font-size: 17px;
line-height: 22px;
color: $darker-text-color;
}
}
&__choices {
display: flex;
&__choice {
flex: 0 0 auto;
width: 50%;
box-sizing: border-box;
padding: 20px;
h3 {
margin-bottom: 20px;
}
p {
color: $darker-text-color;
margin-bottom: 20px;
}
.button {
margin-bottom: 10px;
&:last-child {
margin-bottom: 0;
}
}
}
}
@media screen and (max-width: $no-gap-breakpoint - 1px) {
&__choices {
display: block;
&__choice {
width: auto;
margin-bottom: 20px;
}
}
}
}
.copypaste {
display: flex;
align-items: center;
gap: 10px;
input {
display: block;
font-family: inherit;
background: darken($ui-base-color, 8%);
border: 1px solid $highlight-text-color;
color: $darker-text-color;
border-radius: 4px;
padding: 6px 9px;
line-height: 22px;
font-size: 14px;
transition: border-color 300ms linear;
flex: 1 1 auto;
overflow: hidden;
&:focus {
outline: 0;
background: darken($ui-base-color, 4%);
}
}
.button {
flex: 0 0 auto;
transition: background 300ms linear;
}
&.copied {
input {
border: 1px solid $valid-value-color;
transition: none;
}
.button {
background: $valid-value-color;
transition: none;
}
}
}

View File

@ -0,0 +1,96 @@
.sign-in-banner {
padding: 10px;
p {
color: $darker-text-color;
margin-bottom: 20px;
}
.button {
margin-bottom: 10px;
}
}
.server-banner {
padding: 20px 0;
&__introduction {
color: $darker-text-color;
margin-bottom: 20px;
strong {
font-weight: 600;
}
a {
color: inherit;
text-decoration: underline;
&:hover,
&:active,
&:focus {
text-decoration: none;
}
}
}
&__hero {
display: block;
border-radius: 4px;
width: 100%;
height: auto;
margin-bottom: 20px;
aspect-ratio: 1.9;
border: 0;
background: $ui-base-color;
object-fit: cover;
}
&__description {
margin-bottom: 20px;
}
&__meta {
display: flex;
gap: 10px;
max-width: 100%;
&__column {
flex: 0 0 auto;
width: calc(50% - 5px);
overflow: hidden;
}
}
&__number {
font-weight: 600;
color: $primary-text-color;
font-size: 14px;
}
&__number-label {
color: $darker-text-color;
font-weight: 500;
font-size: 14px;
}
h4 {
text-transform: uppercase;
color: $darker-text-color;
margin-bottom: 10px;
font-weight: 600;
}
.account {
padding: 0;
border: 0;
}
.account__avatar-wrapper {
margin-left: 0;
}
.spacer {
margin: 10px 0;
}
}

View File

@ -6,6 +6,26 @@
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;
}
}
}
.search__input { .search__input {
line-height: 18px; line-height: 18px;
font-size: 16px; font-size: 16px;
@ -21,10 +41,6 @@
flex: 0 1 48px; flex: 0 1 48px;
} }
.flex-spacer {
background: transparent;
}
.composer { .composer {
flex: 1; flex: 1;
overflow-y: hidden; overflow-y: hidden;
@ -61,6 +77,14 @@
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;

View File

@ -11,6 +11,7 @@ const initialState = element && function () {
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') || (getMeta('display_sensitive_media') ? 'show_all' : 'default'); export const displayMedia = getMeta('display_media') || (getMeta('display_sensitive_media') ? 'show_all' : 'default');
@ -24,17 +25,19 @@ export const searchEnabled = getMeta('search_enabled');
export const maxChars = (initialState && initialState.max_toot_chars) || 500; export const maxChars = (initialState && initialState.max_toot_chars) || 500;
export const pollLimits = (initialState && initialState.poll_limits); export const pollLimits = (initialState && initialState.poll_limits);
export const limitedFederationMode = getMeta('limited_federation_mode'); export const limitedFederationMode = getMeta('limited_federation_mode');
export const registrationsOpen = getMeta('registrations_open');
export const repository = getMeta('repository'); export const repository = getMeta('repository');
export const source_url = getMeta('source_url'); export const source_url = getMeta('source_url');
export const version = getMeta('version'); export const version = getMeta('version');
export const mascot = getMeta('mascot'); export const mascot = getMeta('mascot');
export const profile_directory = getMeta('profile_directory'); export const profile_directory = getMeta('profile_directory');
export const defaultContentType = getMeta('default_content_type'); export const defaultContentType = getMeta('default_content_type');
export const forceSingleColumn = getMeta('advanced_layout') === false; export const forceSingleColumn = !getMeta('advanced_layout');
export const useBlurhash = getMeta('use_blurhash'); export const useBlurhash = getMeta('use_blurhash');
export const usePendingItems = getMeta('use_pending_items'); export const usePendingItems = getMeta('use_pending_items');
export const useSystemEmojiFont = getMeta('system_emoji_font'); export const useSystemEmojiFont = getMeta('system_emoji_font');
export const showTrends = getMeta('trends'); export const showTrends = getMeta('trends');
export const title = getMeta('title');
export const disableSwiping = getMeta('disable_swiping'); export const disableSwiping = getMeta('disable_swiping');
export const languages = initialState && initialState.languages; export const languages = initialState && initialState.languages;

View File

@ -1,12 +1,14 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import * as registerPushNotifications from 'flavours/glitch/actions/push_notifications';
import { setupBrowserNotifications } from 'flavours/glitch/actions/notifications'; import { setupBrowserNotifications } from 'flavours/glitch/actions/notifications';
import Mastodon, { store } from 'flavours/glitch/containers/mastodon'; import Mastodon, { store } from 'flavours/glitch/containers/mastodon';
import ready from 'flavours/glitch/util/ready'; import ready from 'flavours/glitch/util/ready';
const perf = require('./performance'); const perf = require('flavours/glitch/util/performance');
/**
* @returns {Promise<void>}
*/
function main() { function main() {
perf.start('main()'); perf.start('main()');
@ -18,7 +20,7 @@ function main() {
} }
} }
ready(() => { return ready(async () => {
const mountNode = document.getElementById('mastodon'); const mountNode = document.getElementById('mastodon');
const props = JSON.parse(mountNode.getAttribute('data-props')); const props = JSON.parse(mountNode.getAttribute('data-props'));
@ -26,19 +28,28 @@ function main() {
store.dispatch(setupBrowserNotifications()); store.dispatch(setupBrowserNotifications());
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
import('workbox-window') const [{ Workbox }, { me }] = await Promise.all([
.then(({ Workbox }) => { import('workbox-window'),
const wb = new Workbox('/sw.js'); import('mastodon/initial_state'),
]);
return wb.register(); const wb = new Workbox('/sw.js');
})
.then(() => { try {
store.dispatch(registerPushNotifications.register()); await wb.register();
}) } catch (err) {
.catch(err => { console.error(err);
console.error(err);
}); return;
}
if (me) {
const registerPushNotifications = await import('flavours/glitch/actions/push_notifications');
store.dispatch(registerPushNotifications.register());
}
} }
perf.stop('main()'); perf.stop('main()');
}); });
} }

View File

@ -1,7 +1,32 @@
export default function ready(loaded) { // @ts-check
if (['interactive', 'complete'].includes(document.readyState)) {
loaded(); /**
} else { * @param {(() => void) | (() => Promise<void>)} callback
document.addEventListener('DOMContentLoaded', loaded); * @returns {Promise<void>}
} */
export default function ready(callback) {
return new Promise((resolve, reject) => {
function loaded() {
let result;
try {
result = callback();
} catch (err) {
reject(err);
return;
}
if (typeof result?.then === 'function') {
result.then(resolve).catch(reject);
} else {
resolve();
}
}
if (['interactive', 'complete'].includes(document.readyState)) {
loaded();
} else {
document.addEventListener('DOMContentLoaded', loaded);
}
});
} }

View File

@ -1,27 +0,0 @@
import api from '../api';
export const RULES_FETCH_REQUEST = 'RULES_FETCH_REQUEST';
export const RULES_FETCH_SUCCESS = 'RULES_FETCH_SUCCESS';
export const RULES_FETCH_FAIL = 'RULES_FETCH_FAIL';
export const fetchRules = () => (dispatch, getState) => {
dispatch(fetchRulesRequest());
api(getState)
.get('/api/v1/instance').then(({ data }) => dispatch(fetchRulesSuccess(data.rules)))
.catch(err => dispatch(fetchRulesFail(err)));
};
const fetchRulesRequest = () => ({
type: RULES_FETCH_REQUEST,
});
const fetchRulesSuccess = rules => ({
type: RULES_FETCH_SUCCESS,
rules,
});
const fetchRulesFail = error => ({
type: RULES_FETCH_FAIL,
error,
});

View File

@ -0,0 +1,30 @@
import api from '../api';
import { importFetchedAccount } from './importer';
export const SERVER_FETCH_REQUEST = 'Server_FETCH_REQUEST';
export const SERVER_FETCH_SUCCESS = 'Server_FETCH_SUCCESS';
export const SERVER_FETCH_FAIL = 'Server_FETCH_FAIL';
export const fetchServer = () => (dispatch, getState) => {
dispatch(fetchServerRequest());
api(getState)
.get('/api/v2/instance').then(({ data }) => {
if (data.contact.account) dispatch(importFetchedAccount(data.contact.account));
dispatch(fetchServerSuccess(data));
}).catch(err => dispatch(fetchServerFail(err)));
};
const fetchServerRequest = () => ({
type: SERVER_FETCH_REQUEST,
});
const fetchServerSuccess = server => ({
type: SERVER_FETCH_SUCCESS,
server,
});
const fetchServerFail = error => ({
type: SERVER_FETCH_FAIL,
error,
});

View File

@ -9,6 +9,7 @@ import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { me } from '../initial_state'; import { me } from '../initial_state';
import RelativeTimestamp from './relative_timestamp'; import RelativeTimestamp from './relative_timestamp';
import Skeleton from 'mastodon/components/skeleton';
const messages = defineMessages({ const messages = defineMessages({
follow: { id: 'account.follow', defaultMessage: 'Follow' }, follow: { id: 'account.follow', defaultMessage: 'Follow' },
@ -26,7 +27,7 @@ export default @injectIntl
class Account extends ImmutablePureComponent { class Account extends ImmutablePureComponent {
static propTypes = { static propTypes = {
account: ImmutablePropTypes.map.isRequired, account: ImmutablePropTypes.map,
onFollow: PropTypes.func.isRequired, onFollow: PropTypes.func.isRequired,
onBlock: PropTypes.func.isRequired, onBlock: PropTypes.func.isRequired,
onMute: PropTypes.func.isRequired, onMute: PropTypes.func.isRequired,
@ -67,7 +68,16 @@ class Account extends ImmutablePureComponent {
const { account, intl, hidden, onActionClick, actionIcon, actionTitle, defaultAction } = this.props; const { account, intl, hidden, onActionClick, actionIcon, actionTitle, defaultAction } = this.props;
if (!account) { if (!account) {
return <div />; return (
<div className='account'>
<div className='account__wrapper'>
<div className='account__display-name'>
<div className='account__avatar-wrapper'><Skeleton width={36} height={36} /></div>
<DisplayName />
</div>
</div>
</div>
);
} }
if (hidden) { if (hidden) {

View File

@ -2,11 +2,12 @@ import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { autoPlayGif } from 'mastodon/initial_state'; import { autoPlayGif } from 'mastodon/initial_state';
import Skeleton from 'mastodon/components/skeleton';
export default class DisplayName extends React.PureComponent { export default class DisplayName extends React.PureComponent {
static propTypes = { static propTypes = {
account: ImmutablePropTypes.map.isRequired, account: ImmutablePropTypes.map,
others: ImmutablePropTypes.list, others: ImmutablePropTypes.list,
localDomain: PropTypes.string, localDomain: PropTypes.string,
}; };
@ -48,7 +49,7 @@ export default class DisplayName extends React.PureComponent {
if (others.size - 2 > 0) { if (others.size - 2 > 0) {
suffix = `+${others.size - 2}`; suffix = `+${others.size - 2}`;
} }
} else { } else if ((others && others.size > 0) || this.props.account) {
if (others && others.size > 0) { if (others && others.size > 0) {
account = others.first(); account = others.first();
} else { } else {
@ -63,6 +64,9 @@ export default class DisplayName extends React.PureComponent {
displayName = <bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi>; displayName = <bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi>;
suffix = <span className='display-name__account'>@{acct}</span>; suffix = <span className='display-name__account'>@{acct}</span>;
} else {
displayName = <bdi><strong className='display-name__html'><Skeleton width='10ch' /></strong></bdi>;
suffix = <span className='display-name__account'><Skeleton width='7ch' /></span>;
} }
return ( return (

View File

@ -0,0 +1,91 @@
import React from 'react';
import PropTypes from 'prop-types';
import { domain } from 'mastodon/initial_state';
import { fetchServer } from 'mastodon/actions/server';
import { connect } from 'react-redux';
import Account from 'mastodon/containers/account_container';
import ShortNumber from 'mastodon/components/short_number';
import Skeleton from 'mastodon/components/skeleton';
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
const messages = defineMessages({
aboutActiveUsers: { id: 'server_banner.about_active_users', defaultMessage: 'People using this server during the last 30 days (Monthly Active Users)' },
});
const mapStateToProps = state => ({
server: state.get('server'),
});
export default @connect(mapStateToProps)
@injectIntl
class ServerBanner extends React.PureComponent {
static propTypes = {
server: PropTypes.object,
dispatch: PropTypes.func,
intl: PropTypes.object,
};
componentDidMount () {
const { dispatch } = this.props;
dispatch(fetchServer());
}
render () {
const { server, intl } = this.props;
const isLoading = server.get('isLoading');
return (
<div className='server-banner'>
<div className='server-banner__introduction'>
<FormattedMessage id='server_banner.introduction' defaultMessage='{domain} is part of the decentralized social network powered by {mastodon}.' values={{ domain: <strong>{domain}</strong>, mastodon: <a href='https://joinmastodon.org' target='_blank'>Mastodon</a> }} />
</div>
<img src={server.get('thumbnail')} alt={server.get('title')} className='server-banner__hero' />
<div className='server-banner__description'>
{isLoading ? (
<>
<Skeleton width='100%' />
<br />
<Skeleton width='100%' />
<br />
<Skeleton width='70%' />
</>
) : server.get('description')}
</div>
<div className='server-banner__meta'>
<div className='server-banner__meta__column'>
<h4><FormattedMessage id='server_banner.administered_by' defaultMessage='Administered by:' /></h4>
<Account id={server.getIn(['contact', 'account', 'id'])} />
</div>
<div className='server-banner__meta__column'>
<h4><FormattedMessage id='server_banner.server_stats' defaultMessage='Server stats:' /></h4>
{isLoading ? (
<>
<strong className='server-banner__number'><Skeleton width='10ch' /></strong>
<br />
<span className='server-banner__number-label'><Skeleton width='5ch' /></span>
</>
) : (
<>
<strong className='server-banner__number'><ShortNumber value={server.getIn(['usage', 'users', 'active_month'])} /></strong>
<br />
<span className='server-banner__number-label' title={intl.formatMessage(messages.aboutActiveUsers)}><FormattedMessage id='server_banner.active_users' defaultMessage='active users' /></span>
</>
)}
</div>
</div>
<hr className='spacer' />
<a className='button button--block button-secondary' href='/about/more' target='_blank'><FormattedMessage id='server_banner.learn_more' defaultMessage='Learn more' /></a>
</div>
);
}
}

View File

@ -7,7 +7,7 @@ import Button from 'mastodon/components/button';
import Option from './components/option'; import Option from './components/option';
const mapStateToProps = state => ({ const mapStateToProps = state => ({
rules: state.get('rules'), rules: state.getIn(['server', 'rules']),
}); });
export default @connect(mapStateToProps) export default @connect(mapStateToProps)

View File

@ -5,6 +5,7 @@ import SearchContainer from 'mastodon/features/compose/containers/search_contain
import ComposeFormContainer from 'mastodon/features/compose/containers/compose_form_container'; import ComposeFormContainer from 'mastodon/features/compose/containers/compose_form_container';
import NavigationContainer from 'mastodon/features/compose/containers/navigation_container'; import NavigationContainer from 'mastodon/features/compose/containers/navigation_container';
import LinkFooter from './link_footer'; import LinkFooter from './link_footer';
import ServerBanner from 'mastodon/components/server_banner';
import { changeComposing } from 'mastodon/actions/compose'; import { changeComposing } from 'mastodon/actions/compose';
export default @connect() export default @connect()
@ -35,6 +36,7 @@ class ComposePanel extends React.PureComponent {
{!signedIn && ( {!signedIn && (
<React.Fragment> <React.Fragment>
<ServerBanner />
<div className='flex-spacer' /> <div className='flex-spacer' />
</React.Fragment> </React.Fragment>
)} )}

View File

@ -2,7 +2,7 @@ import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { submitReport } from 'mastodon/actions/reports'; import { submitReport } from 'mastodon/actions/reports';
import { expandAccountTimeline } from 'mastodon/actions/timelines'; import { expandAccountTimeline } from 'mastodon/actions/timelines';
import { fetchRules } from 'mastodon/actions/rules'; import { fetchServer } from 'mastodon/actions/server';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import { makeGetAccount } from 'mastodon/selectors'; import { makeGetAccount } from 'mastodon/selectors';
@ -117,7 +117,7 @@ class ReportModal extends ImmutablePureComponent {
const { dispatch, accountId } = this.props; const { dispatch, accountId } = this.props;
dispatch(expandAccountTimeline(accountId, { withReplies: true })); dispatch(expandAccountTimeline(accountId, { withReplies: true }));
dispatch(fetchRules()); dispatch(fetchServer());
} }
render () { render () {

View File

@ -13,7 +13,7 @@ import { debounce } from 'lodash';
import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose'; import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose';
import { expandHomeTimeline } from '../../actions/timelines'; import { expandHomeTimeline } from '../../actions/timelines';
import { expandNotifications } from '../../actions/notifications'; import { expandNotifications } from '../../actions/notifications';
import { fetchRules } from '../../actions/rules'; import { fetchServer } from '../../actions/server';
import { clearHeight } from '../../actions/height_cache'; import { clearHeight } from '../../actions/height_cache';
import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app'; import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app';
import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers'; import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers';
@ -389,7 +389,7 @@ class UI extends React.PureComponent {
this.props.dispatch(expandHomeTimeline()); this.props.dispatch(expandHomeTimeline());
this.props.dispatch(expandNotifications()); this.props.dispatch(expandNotifications());
setTimeout(() => this.props.dispatch(fetchRules()), 3000); setTimeout(() => this.props.dispatch(fetchServer()), 3000);
} }
this.hotkeys.__mousetrap__.stopCallback = (e, element) => { this.hotkeys.__mousetrap__.stopCallback = (e, element) => {

View File

@ -29,6 +29,5 @@ 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

@ -17,7 +17,7 @@ import status_lists from './status_lists';
import mutes from './mutes'; import mutes from './mutes';
import blocks from './blocks'; import blocks from './blocks';
import boosts from './boosts'; import boosts from './boosts';
import rules from './rules'; import server from './server';
import contexts from './contexts'; import contexts from './contexts';
import compose from './compose'; import compose from './compose';
import search from './search'; import search from './search';
@ -62,7 +62,7 @@ const reducers = {
mutes, mutes,
blocks, blocks,
boosts, boosts,
rules, server,
contexts, contexts,
compose, compose,
search, search,

View File

@ -1,13 +0,0 @@
import { RULES_FETCH_SUCCESS } from 'mastodon/actions/rules';
import { List as ImmutableList, fromJS } from 'immutable';
const initialState = ImmutableList();
export default function rules(state = initialState, action) {
switch (action.type) {
case RULES_FETCH_SUCCESS:
return fromJS(action.rules);
default:
return state;
}
}

View File

@ -0,0 +1,19 @@
import { SERVER_FETCH_REQUEST, SERVER_FETCH_SUCCESS, SERVER_FETCH_FAIL } from 'mastodon/actions/server';
import { Map as ImmutableMap, fromJS } from 'immutable';
const initialState = ImmutableMap({
isLoading: true,
});
export default function server(state = initialState, action) {
switch (action.type) {
case SERVER_FETCH_REQUEST:
return state.set('isLoading', true);
case SERVER_FETCH_SUCCESS:
return fromJS(action.server).set('isLoading', false);
case SERVER_FETCH_FAIL:
return state.set('isLoading', false);
default:
return state;
}
}

View File

@ -7949,3 +7949,85 @@ noscript {
} }
} }
} }
.server-banner {
padding: 20px 0;
&__introduction {
color: $darker-text-color;
margin-bottom: 20px;
strong {
font-weight: 600;
}
a {
color: inherit;
text-decoration: underline;
&:hover,
&:active,
&:focus {
text-decoration: none;
}
}
}
&__hero {
display: block;
border-radius: 4px;
width: 100%;
height: auto;
margin-bottom: 20px;
aspect-ratio: 1.9;
border: 0;
background: $ui-base-color;
object-fit: cover;
}
&__description {
margin-bottom: 20px;
}
&__meta {
display: flex;
gap: 10px;
max-width: 100%;
&__column {
flex: 0 0 auto;
width: calc(50% - 5px);
overflow: hidden;
}
}
&__number {
font-weight: 600;
color: $primary-text-color;
}
&__number-label {
color: $darker-text-color;
font-weight: 500;
}
h4 {
text-transform: uppercase;
color: $darker-text-color;
margin-bottom: 10px;
font-weight: 600;
}
.account {
padding: 0;
border: 0;
}
.account__avatar-wrapper {
margin-left: 0;
}
.spacer {
margin: 10px 0;
}
}

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,19 +1,51 @@
# frozen_string_literal: true # frozen_string_literal: true
class InstancePresenter class InstancePresenter < ActiveModelSerializers::Model
delegate( attributes :domain, :title, :version, :source_url,
:site_contact_email, :description, :languages, :rules, :contact
:site_title,
:site_short_description,
:site_description,
:site_extended_description,
:site_terms,
:closed_registrations_message,
to: Setting
)
def contact_account class ContactPresenter < ActiveModelSerializers::Model
Account.find_local(Setting.site_contact_username.strip.gsub(/\A@/, '')) attributes :email, :account
def email
Setting.site_contact_email
end
def account
Account.find_local(Setting.site_contact_username.strip.gsub(/\A@/, ''))
end
end
def contact
ContactPresenter.new
end
def closed_registrations_message
Setting.closed_registrations_message
end
def description
Setting.site_short_description
end
def extended_description
Setting.site_extended_description
end
def privacy_policy
Setting.site_terms
end
def domain
Rails.configuration.x.local_domain
end
def title
Setting.site_title
end
def languages
[I18n.default_locale]
end end
def rules def rules
@ -40,8 +72,8 @@ class InstancePresenter
Rails.cache.fetch('sample_accounts', expires_in: 12.hours) { Account.local.discoverable.popular.limit(3) } Rails.cache.fetch('sample_accounts', expires_in: 12.hours) { Account.local.discoverable.popular.limit(3) }
end end
def version_number def version
Mastodon::Version Mastodon::Version.to_s
end end
def source_url def source_url

View File

@ -6,7 +6,7 @@ class InitialStateSerializer < ActiveModel::Serializer
attributes :meta, :compose, :accounts, attributes :meta, :compose, :accounts,
:media_attachments, :settings, :media_attachments, :settings,
:max_toot_chars, :poll_limits, :max_toot_chars, :poll_limits,
:languages, :server :languages
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
@ -24,18 +24,19 @@ class InitialStateSerializer < ActiveModel::Serializer
} }
end end
# rubocop:disable Metrics/AbcSize
def meta def meta
store = { store = {
streaming_api_base_url: Rails.configuration.x.streaming_api_base_url, streaming_api_base_url: Rails.configuration.x.streaming_api_base_url,
access_token: object.token, access_token: object.token,
locale: I18n.locale, locale: I18n.locale,
domain: Rails.configuration.x.local_domain, domain: instance_presenter.domain,
title: instance_presenter.site_title, title: instance_presenter.title,
admin: object.admin&.id&.to_s, admin: object.admin&.id&.to_s,
search_enabled: Chewy.enabled?, search_enabled: Chewy.enabled?,
repository: Mastodon::Version.repository, repository: Mastodon::Version.repository,
source_url: Mastodon::Version.source_url, source_url: instance_presenter.source_url,
version: Mastodon::Version.to_s, version: instance_presenter.version,
limited_federation_mode: Rails.configuration.x.whitelist_mode, limited_federation_mode: Rails.configuration.x.whitelist_mode,
mascot: instance_presenter.mascot&.file&.url, mascot: instance_presenter.mascot&.file&.url,
profile_directory: Setting.profile_directory, profile_directory: Setting.profile_directory,
@ -71,6 +72,7 @@ class InitialStateSerializer < ActiveModel::Serializer
store store
end end
# rubocop:enable Metrics/AbcSize
def compose def compose
store = {} store = {}
@ -102,13 +104,6 @@ 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

@ -22,11 +22,11 @@ class ManifestSerializer < ActiveModel::Serializer
:share_target, :shortcuts :share_target, :shortcuts
def name def name
object.site_title object.title
end end
def short_name def short_name
object.site_title object.title
end end
def icons def icons

View File

@ -1,74 +1,39 @@
# frozen_string_literal: true # frozen_string_literal: true
class REST::InstanceSerializer < ActiveModel::Serializer class REST::InstanceSerializer < ActiveModel::Serializer
class ContactSerializer < ActiveModel::Serializer
attributes :email
has_one :account, serializer: REST::AccountSerializer
end
include RoutingHelper include RoutingHelper
attributes :uri, :title, :short_description, :description, :email, attributes :domain, :title, :version, :source_url, :description,
:version, :urls, :stats, :thumbnail, :max_toot_chars, :poll_limits, :usage, :thumbnail, :languages, :configuration,
:languages, :registrations, :approval_required, :invites_enabled, :registrations
:configuration
has_one :contact_account, serializer: REST::AccountSerializer
has_one :contact, serializer: ContactSerializer
has_many :rules, serializer: REST::RuleSerializer has_many :rules, serializer: REST::RuleSerializer
delegate :contact_account, :rules, to: :instance_presenter
def uri
Rails.configuration.x.local_domain
end
def title
Setting.site_title
end
def short_description
Setting.site_short_description
end
def description
Setting.site_description
end
def email
Setting.site_contact_email
end
def version
Mastodon::Version.to_s
end
def thumbnail def thumbnail
instance_presenter.thumbnail ? full_asset_url(instance_presenter.thumbnail.file.url) : full_pack_url('media/images/preview.png') object.thumbnail ? full_asset_url(object.thumbnail.file.url) : full_pack_url('media/images/preview.png')
end end
def max_toot_chars def usage
StatusLengthValidator::MAX_CHARS
end
def poll_limits
{ {
max_options: PollValidator::MAX_OPTIONS, users: {
max_option_chars: PollValidator::MAX_OPTION_CHARS, active_month: object.active_user_count(4),
min_expiration: PollValidator::MIN_EXPIRATION, },
max_expiration: PollValidator::MAX_EXPIRATION,
} }
end end
def stats
{
user_count: instance_presenter.user_count,
status_count: instance_presenter.status_count,
domain_count: instance_presenter.domain_count,
}
end
def urls
{ streaming_api: Rails.configuration.x.streaming_api_base_url }
end
def configuration def configuration
{ {
urls: {
streaming: Rails.configuration.x.streaming_api_base_url,
},
statuses: { statuses: {
max_characters: StatusLengthValidator::MAX_CHARS, max_characters: StatusLengthValidator::MAX_CHARS,
max_media_attachments: 4, max_media_attachments: 4,
@ -93,25 +58,10 @@ class REST::InstanceSerializer < ActiveModel::Serializer
} }
end end
def languages
[I18n.default_locale]
end
def registrations def registrations
Setting.registrations_mode != 'none' && !Rails.configuration.x.single_user_mode {
end enabled: Setting.registrations_mode != 'none' && !Rails.configuration.x.single_user_mode,
approval_required: Setting.registrations_mode == 'approved',
def approval_required }
Setting.registrations_mode == 'approved'
end
def invites_enabled
UserRole.everyone.can?(:invite_users)
end
private
def instance_presenter
@instance_presenter ||= InstancePresenter.new
end end
end end

View File

@ -0,0 +1,115 @@
# frozen_string_literal: true
class REST::V1::InstanceSerializer < ActiveModel::Serializer
include RoutingHelper
attributes :uri, :title, :short_description, :description, :email,
:version, :urls, :stats, :thumbnail, :max_toot_chars, :poll_limits,
:languages, :registrations, :approval_required, :invites_enabled,
:configuration
has_one :contact_account, serializer: REST::AccountSerializer
has_many :rules, serializer: REST::RuleSerializer
def uri
object.domain
end
def short_description
object.description
end
def description
Setting.site_description # Legacy
end
def email
object.contact.email
end
def contact_account
object.contact.account
end
def thumbnail
instance_presenter.thumbnail ? full_asset_url(instance_presenter.thumbnail.file.url) : full_pack_url('media/images/preview.png')
end
def max_toot_chars
StatusLengthValidator::MAX_CHARS
end
def poll_limits
{
max_options: PollValidator::MAX_OPTIONS,
max_option_chars: PollValidator::MAX_OPTION_CHARS,
min_expiration: PollValidator::MIN_EXPIRATION,
max_expiration: PollValidator::MAX_EXPIRATION,
}
end
def stats
{
user_count: instance_presenter.user_count,
status_count: instance_presenter.status_count,
domain_count: instance_presenter.domain_count,
}
end
def urls
{ streaming_api: Rails.configuration.x.streaming_api_base_url }
end
def usage
{
users: {
active_month: instance_presenter.active_user_count(4),
},
}
end
def configuration
{
statuses: {
max_characters: StatusLengthValidator::MAX_CHARS,
max_media_attachments: 4,
characters_reserved_per_url: StatusLengthValidator::URL_PLACEHOLDER_CHARS,
},
media_attachments: {
supported_mime_types: MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES + MediaAttachment::AUDIO_MIME_TYPES,
image_size_limit: MediaAttachment::IMAGE_LIMIT,
image_matrix_limit: Attachmentable::MAX_MATRIX_LIMIT,
video_size_limit: MediaAttachment::VIDEO_LIMIT,
video_frame_rate_limit: MediaAttachment::MAX_VIDEO_FRAME_RATE,
video_matrix_limit: MediaAttachment::MAX_VIDEO_MATRIX_LIMIT,
},
polls: {
max_options: PollValidator::MAX_OPTIONS,
max_characters_per_option: PollValidator::MAX_OPTION_CHARS,
min_expiration: PollValidator::MIN_EXPIRATION,
max_expiration: PollValidator::MAX_EXPIRATION,
},
}
end
def registrations
Setting.registrations_mode != 'none' && !Rails.configuration.x.single_user_mode
end
def approval_required
Setting.registrations_mode == 'approved'
end
def invites_enabled
UserRole.everyone.can?(:invite_users)
end
private
def instance_presenter
@instance_presenter ||= InstancePresenter.new
end
end

View File

@ -8,7 +8,7 @@
.column-0 .column-0
.public-account-header.public-account-header--no-bar .public-account-header.public-account-header--no-bar
.public-account-header__image .public-account-header__image
= image_tag @instance_presenter.hero&.file&.url || @instance_presenter.thumbnail&.file&.url || asset_pack_path('media/images/preview.png'), alt: @instance_presenter.site_title, class: 'parallax' = image_tag @instance_presenter.hero&.file&.url || @instance_presenter.thumbnail&.file&.url || asset_pack_path('media/images/preview.png'), alt: @instance_presenter.title, class: 'parallax'
.column-1 .column-1
.landing-page__call-to-action{ dir: 'ltr' } .landing-page__call-to-action{ dir: 'ltr' }
@ -30,14 +30,14 @@
.contact-widget .contact-widget
%h4= t 'about.administered_by' %h4= t 'about.administered_by'
= account_link_to(@instance_presenter.contact_account) = account_link_to(@instance_presenter.contact.account)
- if @instance_presenter.site_contact_email.present? - if @instance_presenter.contact.email.present?
%h4 %h4
= succeed ':' do = succeed ':' do
= t 'about.contact' = t 'about.contact'
= mail_to @instance_presenter.site_contact_email, nil, title: @instance_presenter.site_contact_email = mail_to @instance_presenter.contact.email, nil, title: @instance_presenter.contact.email
.column-3 .column-3
= render 'application/flashes' = render 'application/flashes'

View File

@ -53,11 +53,11 @@
.hero-widget .hero-widget
.hero-widget__img .hero-widget__img
= image_tag @instance_presenter.hero&.file&.url || @instance_presenter.thumbnail&.file&.url || asset_pack_path('media/images/preview.png'), alt: @instance_presenter.site_title = image_tag @instance_presenter.hero&.file&.url || @instance_presenter.thumbnail&.file&.url || asset_pack_path('media/images/preview.png'), alt: @instance_presenter.title
.hero-widget__text .hero-widget__text
%p %p
= @instance_presenter.site_short_description.html_safe.presence || t('about.about_mastodon_html') = @instance_presenter.description.html_safe.presence || t('about.about_mastodon_html')
= link_to about_more_path do = link_to about_more_path do
= t('about.learn_more') = t('about.learn_more')
= fa_icon 'angle-double-right' = fa_icon 'angle-double-right'
@ -66,7 +66,7 @@
.hero-widget__footer__column .hero-widget__footer__column
%h4= t 'about.administered_by' %h4= t 'about.administered_by'
= account_link_to @instance_presenter.contact_account = account_link_to @instance_presenter.contact.account
.hero-widget__footer__column .hero-widget__footer__column
%h4= t 'about.server_stats' %h4= t 'about.server_stats'

View File

@ -1,9 +1,9 @@
.hero-widget .hero-widget
.hero-widget__img .hero-widget__img
= image_tag @instance_presenter.hero&.file&.url || @instance_presenter.thumbnail&.file&.url || asset_pack_path('media/images/preview.png'), alt: @instance_presenter.site_title = image_tag @instance_presenter.hero&.file&.url || @instance_presenter.thumbnail&.file&.url || asset_pack_path('media/images/preview.png'), alt: @instance_presenter.title
.hero-widget__text .hero-widget__text
%p= @instance_presenter.site_short_description.html_safe.presence || t('about.about_mastodon_html') %p= @instance_presenter.description.html_safe.presence || t('about.about_mastodon_html')
- if Setting.trends && !(user_signed_in? && !current_user.setting_trends) - if Setting.trends && !(user_signed_in? && !current_user.setting_trends)
- trends = Trends.tags.query.allowed.limit(3) - trends = Trends.tags.query.allowed.limit(3)

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
.notranslate.app-holder#mastodon{ data: { props: Oj.dump(default_props) } } .notranslate.app-holder#mastodon{ data: { props: Oj.dump(default_props) } }

View File

@ -4,6 +4,6 @@
.grid .grid
.column-0 .column-0
.box-widget .box-widget
.rich-formatting= @instance_presenter.site_terms.html_safe.presence || t('terms.body_html') .rich-formatting= @instance_presenter.privacy_policy.html_safe.presence || t('terms.body_html')
.column-1 .column-1
= render 'application/sidebar' = render 'application/sidebar'

View File

@ -1,12 +1,12 @@
- thumbnail = @instance_presenter.thumbnail - thumbnail = @instance_presenter.thumbnail
- description ||= strip_tags(@instance_presenter.site_short_description.presence || t('about.about_mastodon_html')) - description ||= strip_tags(@instance_presenter.description.presence || t('about.about_mastodon_html'))
%meta{ name: 'description', content: description }/ %meta{ name: 'description', content: description }/
= opengraph 'og:site_name', t('about.hosted_on', domain: site_hostname) = opengraph 'og:site_name', t('about.hosted_on', domain: site_hostname)
= opengraph 'og:url', url_for(only_path: false) = opengraph 'og:url', url_for(only_path: false)
= opengraph 'og:type', 'website' = opengraph 'og:type', 'website'
= opengraph 'og:title', @instance_presenter.site_title = opengraph 'og:title', @instance_presenter.title
= opengraph 'og:description', description = opengraph 'og:description', description
= opengraph 'og:image', full_asset_url(thumbnail&.file&.url || asset_pack_path('media/images/preview.png', protocol: :request)) = opengraph 'og:image', full_asset_url(thumbnail&.file&.url || asset_pack_path('media/images/preview.png', protocol: :request))
= opengraph 'og:image:width', thumbnail ? thumbnail.meta['width'] : '1200' = opengraph 'og:image:width', thumbnail ? thumbnail.meta['width'] : '1200'

View File

@ -639,10 +639,12 @@ Rails.application.routes.draw do
end end
namespace :v2 do namespace :v2 do
resources :media, only: [:create]
get '/search', to: 'search#index', as: :search get '/search', to: 'search#index', as: :search
resources :media, only: [:create]
resources :suggestions, only: [:index] resources :suggestions, only: [:index]
resources :filters, only: [:index, :create, :show, :update, :destroy] resources :filters, only: [:index, :create, :show, :update, :destroy]
resource :instance, only: [:show]
namespace :admin do namespace :admin do
resources :accounts, only: [:index] resources :accounts, only: [:index]

View File

@ -8,9 +8,9 @@ RSpec.describe HomeController, type: :controller do
context 'when not signed in' do context 'when not signed in' do
context 'when requested path is tag timeline' do context 'when requested path is tag timeline' do
it 'redirects to the tag\'s permalink' do it 'returns http success' do
@request.path = '/web/timelines/tag/name' @request.path = '/web/timelines/tag/name'
is_expected.to redirect_to '/tags/name' is_expected.to have_http_status(:success)
end end
end end
@ -23,11 +23,12 @@ RSpec.describe HomeController, type: :controller do
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

@ -3,21 +3,20 @@ require 'rails_helper'
describe InstancePresenter do describe InstancePresenter do
let(:instance_presenter) { InstancePresenter.new } let(:instance_presenter) { InstancePresenter.new }
context do describe '#description' do
around do |example| around do |example|
site_description = Setting.site_description site_description = Setting.site_short_description
example.run example.run
Setting.site_description = site_description Setting.site_short_description = site_description
end end
it "delegates site_description to Setting" do it "delegates site_description to Setting" do
Setting.site_description = "Site desc" Setting.site_short_description = "Site desc"
expect(instance_presenter.description).to eq "Site desc"
expect(instance_presenter.site_description).to eq "Site desc"
end end
end end
context do describe '#extended_description' do
around do |example| around do |example|
site_extended_description = Setting.site_extended_description site_extended_description = Setting.site_extended_description
example.run example.run
@ -26,12 +25,11 @@ describe InstancePresenter do
it "delegates site_extended_description to Setting" do it "delegates site_extended_description to Setting" do
Setting.site_extended_description = "Extended desc" Setting.site_extended_description = "Extended desc"
expect(instance_presenter.extended_description).to eq "Extended desc"
expect(instance_presenter.site_extended_description).to eq "Extended desc"
end end
end end
context do describe '#email' do
around do |example| around do |example|
site_contact_email = Setting.site_contact_email site_contact_email = Setting.site_contact_email
example.run example.run
@ -40,12 +38,11 @@ describe InstancePresenter do
it "delegates contact_email to Setting" do it "delegates contact_email to Setting" do
Setting.site_contact_email = "admin@example.com" Setting.site_contact_email = "admin@example.com"
expect(instance_presenter.contact.email).to eq "admin@example.com"
expect(instance_presenter.site_contact_email).to eq "admin@example.com"
end end
end end
describe "contact_account" do describe '#account' do
around do |example| around do |example|
site_contact_username = Setting.site_contact_username site_contact_username = Setting.site_contact_username
example.run example.run
@ -55,12 +52,11 @@ describe InstancePresenter do
it "returns the account for the site contact username" do it "returns the account for the site contact username" do
Setting.site_contact_username = "aaa" Setting.site_contact_username = "aaa"
account = Fabricate(:account, username: "aaa") account = Fabricate(:account, username: "aaa")
expect(instance_presenter.contact.account).to eq(account)
expect(instance_presenter.contact_account).to eq(account)
end end
end end
describe "user_count" do describe '#user_count' do
it "returns the number of site users" do it "returns the number of site users" do
Rails.cache.write 'user_count', 123 Rails.cache.write 'user_count', 123
@ -68,7 +64,7 @@ describe InstancePresenter do
end end
end end
describe "status_count" do describe '#status_count' do
it "returns the number of local statuses" do it "returns the number of local statuses" do
Rails.cache.write 'local_status_count', 234 Rails.cache.write 'local_status_count', 234
@ -76,7 +72,7 @@ describe InstancePresenter do
end end
end end
describe "domain_count" do describe '#domain_count' do
it "returns the number of known domains" do it "returns the number of known domains" do
Rails.cache.write 'distinct_domain_count', 345 Rails.cache.write 'distinct_domain_count', 345
@ -84,9 +80,9 @@ describe InstancePresenter do
end end
end end
describe '#version_number' do describe '#version' do
it 'returns Mastodon::Version' do it 'returns string' do
expect(instance_presenter.version_number).to be(Mastodon::Version) expect(instance_presenter.version).to be_a String
end end
end end

View File

@ -14,26 +14,7 @@ describe 'about/show.html.haml', without_verify_partial_doubles: true do
end end
it 'has valid open graph tags' do it 'has valid open graph tags' do
instance_presenter = double( assign(:instance_presenter, InstancePresenter.new)
:instance_presenter,
site_title: 'something',
site_short_description: 'something',
site_description: 'something',
version_number: '1.0',
source_url: 'https://github.com/mastodon/mastodon',
open_registrations: false,
thumbnail: nil,
hero: nil,
mascot: nil,
user_count: 420,
status_count: 69,
active_user_count: 420,
commit_hash: commit_hash,
contact_account: nil,
sample_accounts: []
)
assign(:instance_presenter, instance_presenter)
render render
header_tags = view.content_for(:header_tags) header_tags = view.content_for(:header_tags)