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

Port logged-out Web UI to glitch-soc
main
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
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
@contents = toc_generator.html

View File

@ -6,6 +6,6 @@ class Api::V1::InstancesController < Api::BaseController
def show
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

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
before_action :redirect_unauthenticated_to_permalinks!
before_action :authenticate_user!
before_action :set_pack
before_action :set_referrer_policy_header
before_action :set_instance_presenter
def index
@body_classes = 'app-body'
@ -16,7 +16,10 @@ class HomeController < ApplicationController
def redirect_unauthenticated_to_permalinks!
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
def set_pack
@ -24,8 +27,10 @@ class HomeController < ApplicationController
end
def default_redirect_path
if request.path.start_with?('/web') || whitelist_mode?
if whitelist_mode?
new_user_session_path
elsif request.path.start_with?('/web')
nil
elsif single_user_mode?
short_account_path(Account.local.without_suspended.where('id > 0').first)
else
@ -36,4 +41,8 @@ class HomeController < ApplicationController
def set_referrer_policy_header
response.headers['Referrer-Policy'] = 'origin'
end
def set_instance_presenter
@instance_presenter = InstancePresenter.new
end
end

View File

@ -553,10 +553,12 @@ export function expandFollowingFail(id, error) {
export function fetchRelationships(accountIds) {
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 signedIn = !!state.getIn(['meta', 'me']);
if (newAccountIds.length === 0) {
if (!signedIn || newAccountIds.length === 0) {
return;
}

View File

@ -1,6 +1,7 @@
import api from 'flavours/glitch/util/api';
import { debounce } from 'lodash';
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_SUCCESS = 'MARKERS_FETCH_SUCCESS';
@ -11,7 +12,7 @@ export const synchronouslySubmitMarkers = () => (dispatch, 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;
}
@ -63,7 +64,7 @@ export const synchronouslySubmitMarkers = () => (dispatch, getState) => {
const _buildParams = (state) => {
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']);
if (lastHomeId && compareId(lastHomeId, state.getIn(['markers', 'home'])) > 0) {
@ -82,9 +83,10 @@ const _buildParams = (state) => {
};
const debouncedSubmitMarkers = debounce((dispatch, 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;
}

View File

@ -1,19 +1,5 @@
import {
SET_BROWSER_SUPPORT,
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,
};
import { setAlerts } from './setter';
import { saveSettings } from './registerer';
export function changeAlerts(path, value) {
return dispatch => {
@ -21,3 +7,11 @@ export function changeAlerts(path, value) {
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 { me } from 'flavours/glitch/util/initial_state';
import RelativeTimestamp from './relative_timestamp';
import Skeleton from 'flavours/glitch/components/skeleton';
const messages = defineMessages({
follow: { id: 'account.follow', defaultMessage: 'Follow' },
@ -26,7 +27,7 @@ export default @injectIntl
class Account extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
account: ImmutablePropTypes.map,
onFollow: PropTypes.func.isRequired,
onBlock: PropTypes.func.isRequired,
onMute: PropTypes.func.isRequired,
@ -77,7 +78,16 @@ class Account extends ImmutablePureComponent {
} = this.props;
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) {

View File

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

View File

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

View File

@ -1,8 +1,8 @@
import React from 'react';
const Logo = () => (
<svg viewBox='0 0 216.4144 232.00976' className='logo'>
<use xlinkHref='#mastodon-svg-logo' />
<svg viewBox='0 0 261 66' className='logo'>
<use xlinkHref='#logo-symbol-wordmark' />
</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
class Poll extends ImmutablePureComponent {
static contextTypes = {
identity: PropTypes.object,
};
static propTypes = {
poll: ImmutablePropTypes.map,
intl: PropTypes.object.isRequired,
@ -217,7 +221,7 @@ class Poll extends ImmutablePureComponent {
</ul>
<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>}
{votesCount}
{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,
onHeightChange: PropTypes.func,
onToggleHidden: PropTypes.func,
onInteractionModal: PropTypes.func,
muted: PropTypes.bool,
hidden: PropTypes.bool,
unread: PropTypes.bool,

View File

@ -69,6 +69,7 @@ class StatusActionBar extends ImmutablePureComponent {
onBookmark: PropTypes.func,
onFilter: PropTypes.func,
onAddFilter: PropTypes.func,
onInteractionModal: PropTypes.func,
withDismiss: PropTypes.bool,
withCounters: PropTypes.bool,
showReplyCount: PropTypes.bool,
@ -86,10 +87,12 @@ class StatusActionBar extends ImmutablePureComponent {
]
handleReplyClick = () => {
if (me) {
const { signedIn } = this.context.identity;
if (signedIn) {
this.props.onReply(this.props.status, this.context.router.history);
} else {
this._openInteractionDialog('reply');
this.props.onInteractionModal('reply', this.props.status);
}
}
@ -101,10 +104,22 @@ class StatusActionBar extends ImmutablePureComponent {
}
handleFavouriteClick = (e) => {
if (me) {
const { signedIn } = this.context.identity;
if (signedIn) {
this.props.onFavourite(this.props.status, e);
} 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);
}
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 = () => {
this.props.onDelete(this.props.status, this.context.router.history);
}

View File

@ -31,7 +31,7 @@ const createIdentityContext = state => ({
signedIn: !!state.meta.me,
accountId: state.meta.me,
accessToken: state.meta.access_token,
permissions: state.role.permissions,
permissions: state.role ? state.role.permissions : 0,
});
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));

View File

@ -3,7 +3,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
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 classNames from 'classnames';
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 AccountNoteContainer from '../containers/account_note_container';
import { PERMISSION_MANAGE_USERS } from 'flavours/glitch/permissions';
import { Helmet } from 'react-helmet';
const messages = defineMessages({
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
@ -54,6 +55,14 @@ const messages = defineMessages({
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 = {
month: 'short',
day: 'numeric',
@ -87,6 +96,7 @@ class Header extends ImmutablePureComponent {
onAddToList: PropTypes.func.isRequired,
onEditAccountNote: PropTypes.func.isRequired,
onChangeLanguages: PropTypes.func.isRequired,
onInteractionModal: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
domain: PropTypes.string.isRequired,
hidden: PropTypes.bool,
@ -124,6 +134,7 @@ class Header extends ImmutablePureComponent {
render () {
const { account, hidden, intl, domain } = this.props;
const { signedIn } = this.context.identity;
if (!account) {
return null;
@ -157,12 +168,12 @@ class Header extends ImmutablePureComponent {
}
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 = '';
} 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} />;
} 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'])) {
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)} />;
}
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.direct, { name: account.get('username') }), action: this.props.onDirect });
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.blocks), to: '/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', 'muting'])) {
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 });
}
if (account.get('acct') !== account.get('username')) {
if (signedIn && account.get('acct') !== account.get('username')) {
const domain = account.get('acct').split('@')[1];
menu.push(null);
@ -301,7 +312,7 @@ class Header extends ImmutablePureComponent {
</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>
@ -313,7 +324,7 @@ class Header extends ImmutablePureComponent {
</h1>
</div>
<AccountNoteContainer account={account} />
{signedIn && <AccountNoteContainer account={account} />}
{!(suspended || hidden) && (
<div className='account__header__extra'>
@ -339,6 +350,10 @@ class Header extends ImmutablePureComponent {
</div>
)}
</div>
<Helmet>
<title>{titleFromAccount(account)} - {title}</title>
</Helmet>
</div>
);
}

View File

@ -24,6 +24,7 @@ export default class Header extends ImmutablePureComponent {
onEndorseToggle: PropTypes.func.isRequired,
onAddToList: PropTypes.func.isRequired,
onChangeLanguages: PropTypes.func.isRequired,
onInteractionModal: PropTypes.func.isRequired,
hideTabs: PropTypes.bool,
domain: PropTypes.string.isRequired,
hidden: PropTypes.bool,
@ -97,6 +98,10 @@ export default class Header extends ImmutablePureComponent {
this.props.onChangeLanguages(this.props.account);
}
handleInteractionModal = () => {
this.props.onInteractionModal(this.props.account);
}
render () {
const { account, hidden, hideTabs } = this.props;
@ -124,6 +129,7 @@ export default class Header extends ImmutablePureComponent {
onAddToList={this.handleAddToList}
onEditAccountNote={this.handleEditAccountNote}
onChangeLanguages={this.handleChangeLanguages}
onInteractionModal={this.handleInteractionModal}
domain={this.props.domain}
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) {
if (account.getIn(['relationship', 'blocking'])) {
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 ColumnSettingsContainer from './containers/column_settings_container';
import { connectCommunityStream } from 'flavours/glitch/actions/streaming';
import { Helmet } from 'react-helmet';
import { title } from 'flavours/glitch/util/initial_state';
const messages = defineMessages({
title: { id: 'column.community', defaultMessage: 'Local timeline' },
@ -39,6 +41,7 @@ class CommunityTimeline extends React.PureComponent {
static contextTypes = {
router: PropTypes.object,
identity: PropTypes.object,
};
static propTypes = {
@ -72,20 +75,32 @@ class CommunityTimeline extends React.PureComponent {
componentDidMount () {
const { dispatch, onlyMedia } = this.props;
const { signedIn } = this.context.identity;
dispatch(expandCommunityTimeline({ onlyMedia }));
if (signedIn) {
this.disconnect = dispatch(connectCommunityStream({ onlyMedia }));
}
}
componentDidUpdate (prevProps) {
const { signedIn } = this.context.identity;
if (prevProps.onlyMedia !== this.props.onlyMedia) {
const { dispatch, onlyMedia } = this.props;
if (this.disconnect) {
this.disconnect();
}
dispatch(expandCommunityTimeline({ onlyMedia }));
if (signedIn) {
this.disconnect = dispatch(connectCommunityStream({ onlyMedia }));
}
}
}
componentWillUnmount () {
if (this.disconnect) {
@ -132,6 +147,10 @@ class CommunityTimeline extends React.PureComponent {
bindToDocument={!multiColumn}
regex={this.props.regex}
/>
<Helmet>
<title>{intl.formatMessage(messages.title)} - {title}</title>
</Helmet>
</Column>
);
}

View File

@ -13,6 +13,8 @@ import RadioButton from 'flavours/glitch/components/radio_button';
import LoadMore from 'flavours/glitch/components/load_more';
import ScrollContainer from 'flavours/glitch/containers/scroll_container';
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: 'column.directory', defaultMessage: 'Browse profiles' },
@ -165,6 +167,10 @@ class Directory extends React.PureComponent {
/>
{multiColumn && !pinned ? <ScrollContainer scrollKey='directory'>{scrollableArea}</ScrollContainer> : scrollableArea}
<Helmet>
<title>{intl.formatMessage(messages.title)} - {title}</title>
</Helmet>
</Column>
);
}

View File

@ -12,6 +12,8 @@ import Suggestions from './suggestions';
import Search from 'flavours/glitch/features/compose/containers/search_container';
import SearchResults from './results';
import { showTrends } from 'flavours/glitch/util/initial_state';
import { Helmet } from 'react-helmet';
import { title } from 'flavours/glitch/util/initial_state';
const messages = defineMessages({
title: { id: 'explore.title', defaultMessage: 'Explore' },
@ -29,13 +31,13 @@ class Explore extends React.PureComponent {
static contextTypes = {
router: PropTypes.object,
identity: PropTypes.object,
};
static propTypes = {
intl: PropTypes.object.isRequired,
multiColumn: PropTypes.bool,
isSearching: PropTypes.bool,
layout: PropTypes.string,
};
handleHeaderClick = () => {
@ -47,22 +49,21 @@ class Explore extends React.PureComponent {
}
render () {
const { intl, multiColumn, isSearching, layout } = this.props;
const { intl, multiColumn, isSearching } = this.props;
const { signedIn } = this.context.identity;
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
{layout === 'mobile' ? (
<div className='explore__search-header'>
<Search />
</div>
) : (
<ColumnHeader
icon={isSearching ? 'search' : 'hashtag'}
title={intl.formatMessage(isSearching ? messages.searchResults : messages.title)}
onClick={this.handleHeaderClick}
multiColumn={multiColumn}
/>
)}
<div className='explore__search-header'>
<Search />
</div>
<div className='scrollable scrollable--flex'>
{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/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/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>
<Switch>
@ -82,6 +83,10 @@ class Explore extends React.PureComponent {
<Route path='/explore/suggestions' component={Suggestions} />
<Route exact path={['/explore', '/explore/posts', '/search']} component={Statuses} componentParams={{ multiColumn }} />
</Switch>
<Helmet>
<title>{intl.formatMessage(messages.title)} - {title}</title>
</Helmet>
</React.Fragment>
)}
</div>

View File

@ -5,6 +5,7 @@ import Story from './components/story';
import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
import { connect } from 'react-redux';
import { fetchTrendingLinks } from 'flavours/glitch/actions/trends';
import { FormattedMessage } from 'react-intl';
const mapStateToProps = state => ({
links: state.getIn(['trends', 'links', 'items']),
@ -28,6 +29,16 @@ class Links extends React.PureComponent {
render () {
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 (
<div className='explore__links'>
{isLoading ? (<LoadingIndicator />) : links.map(link => (

View File

@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { FormattedMessage } from 'react-intl';
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import { expandSearch } from 'flavours/glitch/actions/search';
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 LoadMore from 'flavours/glitch/components/load_more';
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 => ({
isLoading: state.getIn(['search', 'isLoading']),
results: state.getIn(['search', 'results']),
q: state.getIn(['search', 'searchTerm']),
});
const appendLoadMore = (id, list, onLoadMore) => {
@ -37,6 +44,7 @@ const renderStatuses = (results, onLoadMore) => appendLoadMore('statuses', resul
)), onLoadMore);
export default @connect(mapStateToProps)
@injectIntl
class Results extends React.PureComponent {
static propTypes = {
@ -44,6 +52,8 @@ class Results extends React.PureComponent {
isLoading: PropTypes.bool,
multiColumn: PropTypes.bool,
dispatch: PropTypes.func.isRequired,
q: PropTypes.string,
intl: PropTypes.object,
};
state = {
@ -64,7 +74,7 @@ class Results extends React.PureComponent {
}
render () {
const { isLoading, results } = this.props;
const { intl, isLoading, q, results } = this.props;
const { type } = this.state;
let filteredResults = ImmutableList();
@ -106,6 +116,10 @@ class Results extends React.PureComponent {
<div className='explore__search-results'>
{isLoading ? <LoadingIndicator /> : filteredResults}
</div>
<Helmet>
<title>{intl.formatMessage(messages.title, { q })} - {title}</title>
</Helmet>
</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 { connect } from 'react-redux';
import { fetchSuggestions, dismissSuggestion } from 'flavours/glitch/actions/suggestions';
import { FormattedMessage } from 'react-intl';
const mapStateToProps = state => ({
suggestions: state.getIn(['suggestions', 'items']),
@ -33,6 +34,16 @@ class Suggestions extends React.PureComponent {
render () {
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 (
<div className='explore__suggestions'>
{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 { connect } from 'react-redux';
import { fetchTrendingHashtags } from 'flavours/glitch/actions/trends';
import { FormattedMessage } from 'react-intl';
const mapStateToProps = state => ({
hashtags: state.getIn(['trends', 'tags', 'items']),
@ -28,6 +29,16 @@ class Tags extends React.PureComponent {
render () {
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 (
<div className='explore__links'>
{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 Column from 'flavours/glitch/features/ui/components/column';
import Account from './components/account';
import Logo from 'flavours/glitch/components/logo';
import imageGreeting from 'mastodon/../images/elephant_ui_greeting.svg';
import Button from 'flavours/glitch/components/button';
@ -78,7 +77,10 @@ class FollowRecommendations extends ImmutablePureComponent {
<Column>
<div className='scrollable follow-recommendations-container'>
<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>
<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>

View File

@ -14,6 +14,8 @@ import { isEqual } from 'lodash';
import { fetchHashtag, followHashtag, unfollowHashtag } from 'flavours/glitch/actions/tags';
import Icon from 'flavours/glitch/components/icon';
import classNames from 'classnames';
import { title } from 'flavours/glitch/util/initial_state';
import { Helmet } from 'react-helmet';
const messages = defineMessages({
followHashtag: { id: 'hashtag.follow', defaultMessage: 'Follow hashtag' },
@ -31,6 +33,10 @@ class HashtagTimeline extends React.PureComponent {
disconnects = [];
static contextTypes = {
identity: PropTypes.object,
};
static propTypes = {
params: PropTypes.object.isRequired,
columnId: PropTypes.string,
@ -90,6 +96,12 @@ class HashtagTimeline extends React.PureComponent {
}
_subscribe (dispatch, id, tags = {}, local) {
const { signedIn } = this.context.identity;
if (!signedIn) {
return;
}
let any = (tags.any || []).map(tag => tag.value);
let all = (tags.all || []).map(tag => tag.value);
let none = (tags.none || []).map(tag => tag.value);
@ -158,6 +170,11 @@ class HashtagTimeline extends React.PureComponent {
handleFollow = () => {
const { dispatch, params, tag } = this.props;
const { id } = params;
const { signedIn } = this.context.identity;
if (!signedIn) {
return;
}
if (tag.get('following')) {
dispatch(unfollowHashtag(id));
@ -170,6 +187,7 @@ class HashtagTimeline extends React.PureComponent {
const { hasUnread, columnId, multiColumn, tag, intl } = this.props;
const { id, local } = this.props.params;
const pinned = !!columnId;
const { signedIn } = this.context.identity;
let followButton;
@ -177,7 +195,7 @@ class HashtagTimeline extends React.PureComponent {
const following = tag.get('following');
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' />
</button>
);
@ -208,6 +226,10 @@ class HashtagTimeline extends React.PureComponent {
emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />}
bindToDocument={!multiColumn}
/>
<Helmet>
<title>{`#${id}`} - {title}</title>
</Helmet>
</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 classNames from 'classnames';
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({
title: { id: 'column.home', defaultMessage: 'Home' },
@ -33,6 +36,10 @@ export default @connect(mapStateToProps)
@injectIntl
class HomeTimeline extends React.PureComponent {
static contextTypes = {
identity: PropTypes.object,
};
static propTypes = {
dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
@ -115,6 +122,7 @@ class HomeTimeline extends React.PureComponent {
render () {
const { intl, hasUnread, columnId, multiColumn, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props;
const pinned = !!columnId;
const { signedIn } = this.context.identity;
let announcementsButton = null;
@ -149,6 +157,7 @@ class HomeTimeline extends React.PureComponent {
<ColumnSettingsContainer />
</ColumnHeader>
{signedIn ? (
<StatusListContainer
trackScroll={!pinned}
scrollKey={`home_timeline-${columnId}`}
@ -158,6 +167,11 @@ class HomeTimeline extends React.PureComponent {
bindToDocument={!multiColumn}
regex={this.props.regex}
/>
) : <NotSignedInIndicator />}
<Helmet>
<title>{intl.formatMessage(messages.title)} - {title}</title>
</Helmet>
</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 compareId from 'flavours/glitch/util/compare_id';
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';
@ -94,6 +97,10 @@ export default @connect(mapStateToProps, mapDispatchToProps)
@injectIntl
class Notifications extends React.PureComponent {
static contextTypes = {
identity: PropTypes.object,
};
static propTypes = {
columnId: PropTypes.string,
notifications: ImmutablePropTypes.list.isRequired,
@ -224,10 +231,11 @@ class Notifications extends React.PureComponent {
const { animatingNCD } = this.state;
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 { signedIn } = this.context.identity;
let scrollableContent = null;
const filterBarContainer = showFilterBar
const filterBarContainer = (signedIn && showFilterBar)
? (<FilterBarContainer />)
: null;
@ -257,7 +265,10 @@ class Notifications extends React.PureComponent {
this.scrollableContent = scrollableContent;
const scrollContainer = (
let scrollContainer;
if (signedIn) {
scrollContainer = (
<ScrollableList
scrollKey={`notifications-${columnId}`}
trackScroll={!pinned}
@ -277,6 +288,9 @@ class Notifications extends React.PureComponent {
{scrollableContent}
</ScrollableList>
);
} else {
scrollContainer = <NotSignedInIndicator />;
}
const extraButtons = [];
@ -354,8 +368,13 @@ class Notifications extends React.PureComponent {
>
<ColumnSettingsContainer />
</ColumnHeader>
{filterBarContainer}
{scrollContainer}
<Helmet>
<title>{intl.formatMessage(messages.title)} - {title}</title>
</Helmet>
</Column>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -152,6 +152,7 @@ class ActionBar extends React.PureComponent {
render () {
const { status, intl } = this.props;
const { signedIn, permissions } = this.context.identity;
const publicStatus = ['public', 'unlisted'].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.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick });
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);
if (accountAdminLink !== undefined) {
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='star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /></div>
{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'>
<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 ImmutablePureComponent from 'react-immutable-pure-component';
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 { autoUnfoldCW } from 'flavours/glitch/util/content_warning';
import { textForScreenReader, defaultMediaVisibility } from 'flavours/glitch/components/status';
import Icon from 'flavours/glitch/components/icon';
import { Helmet } from 'react-helmet';
const messages = defineMessages({
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
@ -147,12 +148,30 @@ const makeMapStateToProps = () => {
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
@connect(makeMapStateToProps)
class Status extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
identity: PropTypes.object,
};
static propTypes = {
@ -245,15 +264,26 @@ class Status extends ImmutablePureComponent {
}
handleFavouriteClick = (status, e) => {
const { dispatch } = this.props;
const { signedIn } = this.context.identity;
if (signedIn) {
if (status.get('favourited')) {
this.props.dispatch(unfavourite(status));
dispatch(unfavourite(status));
} else {
if ((e && e.shiftKey) || !favouriteModal) {
this.handleModalFavourite(status);
} else {
this.props.dispatch(openModal('FAVOURITE', { status, onFavourite: this.handleModalFavourite }));
dispatch(openModal('FAVOURITE', { status, onFavourite: this.handleModalFavourite }));
}
}
} else {
dispatch(openModal('INTERACTION', {
type: 'favourite',
accountId: status.getIn(['account', 'id']),
url: status.get('url'),
}));
}
}
handlePin = (status) => {
@ -265,7 +295,10 @@ class Status extends ImmutablePureComponent {
}
handleReplyClick = (status) => {
let { askReplyConfirmation, dispatch, intl } = this.props;
const { askReplyConfirmation, dispatch, intl } = this.props;
const { signedIn } = this.context.identity;
if (signedIn) {
if (askReplyConfirmation) {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.replyMessage),
@ -276,6 +309,13 @@ class Status extends ImmutablePureComponent {
} else {
dispatch(replyCompose(status, this.context.router.history));
}
} else {
dispatch(openModal('INTERACTION', {
type: 'reply',
accountId: status.getIn(['account', 'id']),
url: status.get('url'),
}));
}
}
handleModalReblog = (status, privacy) => {
@ -290,7 +330,9 @@ class Status extends ImmutablePureComponent {
handleReblogClick = (status, e) => {
const { settings, dispatch } = this.props;
const { signedIn } = this.context.identity;
if (signedIn) {
if (settings.get('confirm_boost_missing_media_description') && status.get('media_attachments').some(item => !item.get('description')) && !status.get('reblogged')) {
dispatch(initBoostModal({ status, onReblog: this.handleModalReblog, missingMediaDescription: true }));
} else if ((e && e.shiftKey) || !boostModal) {
@ -298,6 +340,13 @@ class Status extends ImmutablePureComponent {
} else {
dispatch(initBoostModal({ status, onReblog: this.handleModalReblog }));
}
} else {
dispatch(openModal('INTERACTION', {
type: 'reblog',
accountId: status.getIn(['account', 'id']),
url: status.get('url'),
}));
}
}
handleBookmarkClick = (status) => {
@ -633,6 +682,10 @@ class Status extends ImmutablePureComponent {
{descendants}
</div>
</ScrollContainer>
<Helmet>
<title>{titleFromStatus(status)} - {title}</title>
</Helmet>
</Column>
);
}

View File

@ -60,6 +60,7 @@ class ColumnsArea extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object.isRequired,
identity: PropTypes.object.isRequired,
};
static propTypes = {
@ -213,11 +214,12 @@ class ColumnsArea extends ImmutablePureComponent {
render () {
const { columns, children, singleColumn, intl, navbarUnder, openSettings } = this.props;
const { shouldAnimate, renderComposePanel } = this.state;
const { signedIn } = this.context.identity;
const columnIndex = getIndex(this.context.router.history.location.pathname);
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 ? (
<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 PropTypes from 'prop-types';
import SearchContainer from 'flavours/glitch/features/compose/containers/search_container';
import ComposeFormContainer from 'flavours/glitch/features/compose/containers/compose_form_container';
import NavigationContainer from 'flavours/glitch/features/compose/containers/navigation_container';
import LinkFooter from './link_footer';
import ServerBanner from 'flavours/glitch/components/server_banner';
const ComposePanel = () => (
export default
class ComposePanel extends React.PureComponent {
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>
);
);
}
export default ComposePanel;
};

View File

@ -34,6 +34,7 @@ class LinkFooter extends React.PureComponent {
};
static propTypes = {
withHotkeys: PropTypes.bool,
onLogout: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
@ -48,18 +49,53 @@ class LinkFooter extends React.PureComponent {
}
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 (
<div className='getting-started__footer'>
<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>}
{!!securityLink && <li><a href='/auth/edit'><FormattedMessage id='getting_started.security' defaultMessage='Security' /></a> · </li>}
{!limitedFederationMode && <li><a href='/about/more' target='_blank'><FormattedMessage id='navigation_bar.info' defaultMessage='About this server' /></a> · </li>}
{profileDirectory && <li><Link to='/directory'><FormattedMessage id='getting_started.directory' defaultMessage='Profile directory' /></Link> · </li>}
<li><a href='https://joinmastodon.org/apps' target='_blank'><FormattedMessage id='navigation_bar.apps' defaultMessage='Mobile apps' /></a> · </li>
<li><a href={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>
{items.map((item, index, array) => (
<li>{item} { index === array.length - 1 ? null : ' · ' }</li>
))}
</ul>
<p>

View File

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

View File

@ -1,5 +1,6 @@
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 Icon from 'flavours/glitch/components/icon';
import { showTrends } from 'flavours/glitch/util/initial_state';
@ -8,15 +9,47 @@ import NotificationsCounterIcon from './notifications_counter_icon';
import FollowRequestsNavLink from './follow_requests_nav_link';
import ListPanel from './list_panel';
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 {
static contextTypes = {
router: PropTypes.object.isRequired,
identity: PropTypes.object.isRequired,
};
static propTypes = {
onOpenSettings: PropTypes.func,
};
render() {
const { signedIn } = this.context.identity;
const { onOpenSettings } = this.props;
return (
<div className='navigation-panel'>
{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>
)}
{ 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>
@ -28,10 +61,18 @@ const NavigationPanel = ({ onOpenSettings }) => (
{!!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>
)}
{showTrends && <div className='flex-spacer' />}
{showTrends && <TrendsContainer />}
</div>
);
);
}
export default withRouter(NavigationPanel);
}

View File

@ -2,7 +2,7 @@ import React from 'react';
import { connect } from 'react-redux';
import { submitReport } from 'flavours/glitch/actions/reports';
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 PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
@ -119,7 +119,7 @@ class ReportModal extends ImmutablePureComponent {
dispatch(fetchRelationships([accountId]));
dispatch(expandAccountTimeline(accountId, { withReplies: true }));
dispatch(fetchRules());
dispatch(fetchServer());
}
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 { expandHomeTimeline } from 'flavours/glitch/actions/timelines';
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 { changeLayout } from 'flavours/glitch/actions/app';
import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'flavours/glitch/actions/markers';
@ -54,9 +54,10 @@ import {
FollowRecommendations,
} from 'flavours/glitch/util/async-components';
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 { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
import { Helmet } from 'react-helmet';
// Dummy import, to make sure that <Status /> ends up in the application bundle.
// Without this it ends up in ~8 very commonly used bundles.
@ -121,6 +122,10 @@ const keyMap = {
class SwitchingColumnsArea extends React.PureComponent {
static contextTypes = {
identity: PropTypes.object,
};
static propTypes = {
children: PropTypes.node,
location: PropTypes.object,
@ -157,12 +162,25 @@ class SwitchingColumnsArea extends React.PureComponent {
render () {
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 (
<ColumnsAreaContainer ref={this.setRef} singleColumn={mobile} navbarUnder={navbarUnder}>
<WrappedSwitch>
{redirect}
<WrappedRoute path='/getting-started' component={GettingStarted} content={children} />
<WrappedRoute path='/keyboard-shortcuts' component={KeyboardShortcuts} content={children} />
@ -219,6 +237,10 @@ export default @connect(mapStateToProps)
@withRouter
class UI extends React.Component {
static contextTypes = {
identity: PropTypes.object.isRequired,
};
static propTypes = {
dispatch: PropTypes.func.isRequired,
children: PropTypes.node,
@ -358,6 +380,8 @@ class UI extends React.Component {
}
componentDidMount () {
const { signedIn } = this.context.identity;
window.addEventListener('beforeunload', this.handleBeforeUnload, false);
window.addEventListener('resize', this.handleResize, { passive: true });
@ -374,16 +398,18 @@ class UI extends React.Component {
this.favicon = new Favico({ animation:"none" });
// 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.props.dispatch(closeOnboarding());
}
if (signedIn) {
this.props.dispatch(fetchMarkers());
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) => {
return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName);
@ -635,6 +661,10 @@ class UI extends React.Component {
<LoadingBarContainer className='loading-bar' />
<ModalContainer />
<UploadArea active={draggingOver} onClose={this.closeUploadModal} />
<Helmet>
<title>{title}</title>
</Helmet>
</div>
</HotKeys>
);

View File

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

View File

@ -17,7 +17,7 @@ import push_notifications from './push_notifications';
import status_lists from './status_lists';
import mutes from './mutes';
import blocks from './blocks';
import rules from './rules';
import server from './server';
import boosts from './boosts';
import contexts from './contexts';
import compose from './compose';
@ -64,7 +64,7 @@ const reducers = {
push_notifications,
mutes,
blocks,
rules,
server,
boosts,
contexts,
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;
background: $ui-base-color;
color: $darker-text-color;
border-radius: 4px;
font-size: 14px;
margin: 0;
}

View File

@ -117,6 +117,7 @@
box-sizing: border-box;
width: 100%;
background: lighten($ui-base-color, 4%);
border-radius: 4px 4px 0 0;
color: $highlight-text-color;
cursor: pointer;
flex: 0 0 auto;
@ -204,6 +205,17 @@
color: $highlight-text-color;
}
}
&--logo {
background: transparent;
padding: 10px;
&:hover,
&:focus,
&:active {
background: transparent;
}
}
}
.column-link__icon {
@ -255,6 +267,7 @@
display: flex;
font-size: 16px;
background: lighten($ui-base-color, 4%);
border-radius: 4px 4px 0 0;
flex: 0 0 auto;
cursor: pointer;
position: relative;
@ -309,6 +322,7 @@
> .scrollable {
background: $ui-base-color;
border-radius: 0 0 4px 4px;
}
}
@ -352,6 +366,11 @@
&:focus {
text-shadow: 0 0 4px darken($ui-highlight-color, 5%);
}
&:disabled {
color: $dark-text-color;
cursor: default;
}
}
.column-header__notif-cleaning-buttons {

View File

@ -110,6 +110,27 @@
&:hover {
border-color: lighten($ui-primary-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 {
@ -1756,3 +1777,4 @@ noscript {
@import 'single_column';
@import 'announcements';
@import 'explore';
@import 'signed_out';

View File

@ -23,6 +23,7 @@
left: 0;
width: 100%;
height: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
@ -1304,3 +1305,123 @@ img.modal-warning {
margin-bottom: 15px;
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);
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 {
line-height: 18px;
font-size: 16px;
@ -21,10 +41,6 @@
flex: 0 1 48px;
}
.flex-spacer {
background: transparent;
}
.composer {
flex: 1;
overflow-y: hidden;
@ -61,6 +77,14 @@
flex: 0 0 auto;
}
.logo {
height: 30px;
width: auto;
}
}
.navigation-panel,
.compose-panel {
hr {
flex: 0 0 auto;
border: 0;

View File

@ -11,6 +11,7 @@ const initialState = element && function () {
const getMeta = (prop) => initialState && initialState.meta && initialState.meta[prop];
export const domain = getMeta('domain');
export const reduceMotion = getMeta('reduce_motion');
export const autoPlayGif = getMeta('auto_play_gif');
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 pollLimits = (initialState && initialState.poll_limits);
export const limitedFederationMode = getMeta('limited_federation_mode');
export const registrationsOpen = getMeta('registrations_open');
export const repository = getMeta('repository');
export const source_url = getMeta('source_url');
export const version = getMeta('version');
export const mascot = getMeta('mascot');
export const profile_directory = getMeta('profile_directory');
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 usePendingItems = getMeta('use_pending_items');
export const useSystemEmojiFont = getMeta('system_emoji_font');
export const showTrends = getMeta('trends');
export const title = getMeta('title');
export const disableSwiping = getMeta('disable_swiping');
export const languages = initialState && initialState.languages;

View File

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

View File

@ -1,7 +1,32 @@
export default function ready(loaded) {
// @ts-check
/**
* @param {(() => void) | (() => Promise<void>)} callback
* @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 { me } from '../initial_state';
import RelativeTimestamp from './relative_timestamp';
import Skeleton from 'mastodon/components/skeleton';
const messages = defineMessages({
follow: { id: 'account.follow', defaultMessage: 'Follow' },
@ -26,7 +27,7 @@ export default @injectIntl
class Account extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
account: ImmutablePropTypes.map,
onFollow: PropTypes.func.isRequired,
onBlock: 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;
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) {

View File

@ -2,11 +2,12 @@ import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { autoPlayGif } from 'mastodon/initial_state';
import Skeleton from 'mastodon/components/skeleton';
export default class DisplayName extends React.PureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
account: ImmutablePropTypes.map,
others: ImmutablePropTypes.list,
localDomain: PropTypes.string,
};
@ -48,7 +49,7 @@ export default class DisplayName extends React.PureComponent {
if (others.size - 2 > 0) {
suffix = `+${others.size - 2}`;
}
} else {
} else if ((others && others.size > 0) || this.props.account) {
if (others && others.size > 0) {
account = others.first();
} 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>;
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 (

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';
const mapStateToProps = state => ({
rules: state.get('rules'),
rules: state.getIn(['server', 'rules']),
});
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 NavigationContainer from 'mastodon/features/compose/containers/navigation_container';
import LinkFooter from './link_footer';
import ServerBanner from 'mastodon/components/server_banner';
import { changeComposing } from 'mastodon/actions/compose';
export default @connect()
@ -35,6 +36,7 @@ class ComposePanel extends React.PureComponent {
{!signedIn && (
<React.Fragment>
<ServerBanner />
<div className='flex-spacer' />
</React.Fragment>
)}

View File

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

View File

@ -13,7 +13,7 @@ import { debounce } from 'lodash';
import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose';
import { expandHomeTimeline } from '../../actions/timelines';
import { expandNotifications } from '../../actions/notifications';
import { fetchRules } from '../../actions/rules';
import { fetchServer } from '../../actions/server';
import { clearHeight } from '../../actions/height_cache';
import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app';
import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers';
@ -389,7 +389,7 @@ class UI extends React.PureComponent {
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) => {

View File

@ -29,6 +29,5 @@ export const title = getMeta('title');
export const cropImages = getMeta('crop_images');
export const disableSwiping = getMeta('disable_swiping');
export const languages = initialState && initialState.languages;
export const server = initialState && initialState.server;
export default initialState;

View File

@ -17,7 +17,7 @@ import status_lists from './status_lists';
import mutes from './mutes';
import blocks from './blocks';
import boosts from './boosts';
import rules from './rules';
import server from './server';
import contexts from './contexts';
import compose from './compose';
import search from './search';
@ -62,7 +62,7 @@ const reducers = {
mutes,
blocks,
boosts,
rules,
server,
contexts,
compose,
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])
elsif path_segments[1] == 'accounts' && path_segments[2] =~ /\d/
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

View File

@ -1,20 +1,52 @@
# frozen_string_literal: true
class InstancePresenter
delegate(
:site_contact_email,
:site_title,
:site_short_description,
:site_description,
:site_extended_description,
:site_terms,
:closed_registrations_message,
to: Setting
)
class InstancePresenter < ActiveModelSerializers::Model
attributes :domain, :title, :version, :source_url,
:description, :languages, :rules, :contact
def contact_account
class ContactPresenter < ActiveModelSerializers::Model
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
def rules
Rule.ordered
@ -40,8 +72,8 @@ class InstancePresenter
Rails.cache.fetch('sample_accounts', expires_in: 12.hours) { Account.local.discoverable.popular.limit(3) }
end
def version_number
Mastodon::Version
def version
Mastodon::Version.to_s
end
def source_url

View File

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

View File

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

View File

@ -1,74 +1,39 @@
# frozen_string_literal: true
class REST::InstanceSerializer < ActiveModel::Serializer
class ContactSerializer < ActiveModel::Serializer
attributes :email
has_one :account, serializer: REST::AccountSerializer
end
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
attributes :domain, :title, :version, :source_url, :description,
:usage, :thumbnail, :languages, :configuration,
:registrations
has_one :contact, serializer: ContactSerializer
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
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
def max_toot_chars
StatusLengthValidator::MAX_CHARS
end
def poll_limits
def usage
{
max_options: PollValidator::MAX_OPTIONS,
max_option_chars: PollValidator::MAX_OPTION_CHARS,
min_expiration: PollValidator::MIN_EXPIRATION,
max_expiration: PollValidator::MAX_EXPIRATION,
users: {
active_month: object.active_user_count(4),
},
}
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
{
urls: {
streaming: Rails.configuration.x.streaming_api_base_url,
},
statuses: {
max_characters: StatusLengthValidator::MAX_CHARS,
max_media_attachments: 4,
@ -93,25 +58,10 @@ class REST::InstanceSerializer < ActiveModel::Serializer
}
end
def languages
[I18n.default_locale]
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
{
enabled: Setting.registrations_mode != 'none' && !Rails.configuration.x.single_user_mode,
approval_required: Setting.registrations_mode == 'approved',
}
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
.public-account-header.public-account-header--no-bar
.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
.landing-page__call-to-action{ dir: 'ltr' }
@ -30,14 +30,14 @@
.contact-widget
%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
= succeed ':' do
= 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
= render 'application/flashes'

View File

@ -53,11 +53,11 @@
.hero-widget
.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
%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
= t('about.learn_more')
= fa_icon 'angle-double-right'
@ -66,7 +66,7 @@
.hero-widget__footer__column
%h4= t 'about.administered_by'
= account_link_to @instance_presenter.contact_account
= account_link_to @instance_presenter.contact.account
.hero-widget__footer__column
%h4= t 'about.server_stats'

View File

@ -1,9 +1,9 @@
.hero-widget
.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
%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)
- trends = Trends.tags.query.allowed.limit(3)

View File

@ -1,10 +1,14 @@
- content_for :header_tags do
- if user_signed_in?
= preload_pack_asset 'features/getting_started.js', crossorigin: 'anonymous'
= preload_pack_asset 'features/compose.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}
= render_initial_state
.notranslate.app-holder#mastodon{ data: { props: Oj.dump(default_props) } }

View File

@ -4,6 +4,6 @@
.grid
.column-0
.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
= render 'application/sidebar'

View File

@ -1,12 +1,12 @@
- 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 }/
= opengraph 'og:site_name', t('about.hosted_on', domain: site_hostname)
= opengraph 'og:url', url_for(only_path: false)
= opengraph 'og:type', 'website'
= opengraph 'og:title', @instance_presenter.site_title
= opengraph 'og:title', @instance_presenter.title
= 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:width', thumbnail ? thumbnail.meta['width'] : '1200'

View File

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

View File

@ -8,9 +8,9 @@ RSpec.describe HomeController, type: :controller do
context 'when not signed in' 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'
is_expected.to redirect_to '/tags/name'
is_expected.to have_http_status(:success)
end
end
@ -23,11 +23,12 @@ RSpec.describe HomeController, type: :controller do
context 'when signed in' do
let(:user) { Fabricate(:user) }
before { sign_in(user) }
before do
sign_in(user)
end
it 'assigns @body_classes' do
subject
expect(assigns(:body_classes)).to eq 'app-body'
it 'returns http success' do
is_expected.to have_http_status(:success)
end
end
end

View File

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

View File

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

View File

@ -14,26 +14,7 @@ describe 'about/show.html.haml', without_verify_partial_doubles: true do
end
it 'has valid open graph tags' do
instance_presenter = double(
: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)
assign(:instance_presenter, InstancePresenter.new)
render
header_tags = view.content_for(:header_tags)