diff options
author | Claire <claire.github-309c@sitedethib.com> | 2022-10-09 23:26:02 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2022-10-09 23:26:02 +0200 |
commit | 94713940c7f28e9aff50071cf63d897c8e355ee6 (patch) | |
tree | 109be3b0d61e50789f69704d9e1c9ffdc7e66141 | |
parent | 44486db912ac6064419680dbc3dcd3843a02a144 (diff) | |
parent | d04fbe6fe06113041662bbd9816198256d3cd385 (diff) |
Merge pull request #1861 from ClearlyClaire/glitch-soc/features/logged-out-webui
Port logged-out Web UI to glitch-soc
93 files changed, 1803 insertions, 506 deletions
diff --git a/app/controllers/about_controller.rb b/app/controllers/about_controller.rb index 654f2d92c..d3f03374f 100644 --- a/app/controllers/about_controller.rb +++ b/app/controllers/about_controller.rb @@ -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 diff --git a/app/controllers/api/v1/instances_controller.rb b/app/controllers/api/v1/instances_controller.rb index 5b5058a7b..913319a86 100644 --- a/app/controllers/api/v1/instances_controller.rb +++ b/app/controllers/api/v1/instances_controller.rb @@ -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 diff --git a/app/controllers/api/v2/instances_controller.rb b/app/controllers/api/v2/instances_controller.rb new file mode 100644 index 000000000..bcd90cff2 --- /dev/null +++ b/app/controllers/api/v2/instances_controller.rb @@ -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 diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index 450f92bd4..61b1690fa 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -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 diff --git a/app/javascript/flavours/glitch/actions/accounts.js b/app/javascript/flavours/glitch/actions/accounts.js index f5871beb3..750740879 100644 --- a/app/javascript/flavours/glitch/actions/accounts.js +++ b/app/javascript/flavours/glitch/actions/accounts.js @@ -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; } diff --git a/app/javascript/flavours/glitch/actions/markers.js b/app/javascript/flavours/glitch/actions/markers.js index a086def97..6a0549f7f 100644 --- a/app/javascript/flavours/glitch/actions/markers.js +++ b/app/javascript/flavours/glitch/actions/markers.js @@ -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 params = _buildParams(getState()); + const accessToken = getState().getIn(['meta', 'access_token'], ''); + const params = _buildParams(getState()); - if (Object.keys(params).length === 0) { + if (Object.keys(params).length === 0 || accessToken === '') { return; } diff --git a/app/javascript/flavours/glitch/actions/push_notifications/index.js b/app/javascript/flavours/glitch/actions/push_notifications/index.js index 2ffec500a..9dcc4bd4b 100644 --- a/app/javascript/flavours/glitch/actions/push_notifications/index.js +++ b/app/javascript/flavours/glitch/actions/push_notifications/index.js @@ -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'; diff --git a/app/javascript/flavours/glitch/actions/rules.js b/app/javascript/flavours/glitch/actions/rules.js deleted file mode 100644 index b95045e81..000000000 --- a/app/javascript/flavours/glitch/actions/rules.js +++ /dev/null @@ -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, -}); diff --git a/app/javascript/flavours/glitch/actions/server.js b/app/javascript/flavours/glitch/actions/server.js new file mode 100644 index 000000000..215dfeffa --- /dev/null +++ b/app/javascript/flavours/glitch/actions/server.js @@ -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, +}); diff --git a/app/javascript/flavours/glitch/components/account.js b/app/javascript/flavours/glitch/components/account.js index 489f60736..24d3f65ef 100644 --- a/app/javascript/flavours/glitch/components/account.js +++ b/app/javascript/flavours/glitch/components/account.js @@ -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) { diff --git a/app/javascript/flavours/glitch/components/column_header.js b/app/javascript/flavours/glitch/components/column_header.js index 500612093..024068c58 100644 --- a/app/javascript/flavours/glitch/components/column_header.js +++ b/app/javascript/flavours/glitch/components/column_header.js @@ -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} diff --git a/app/javascript/flavours/glitch/components/display_name.js b/app/javascript/flavours/glitch/components/display_name.js index 9c7da744e..7cb0c9133 100644 --- a/app/javascript/flavours/glitch/components/display_name.js +++ b/app/javascript/flavours/glitch/components/display_name.js @@ -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,14 +47,15 @@ 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 (acct.indexOf('@') === -1 && localDomain) { + acct = `${acct}@${localDomain}`; + } } if (others && others.size > 0) { @@ -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 ( diff --git a/app/javascript/flavours/glitch/components/logo.js b/app/javascript/flavours/glitch/components/logo.js index d1c7f08a9..3570b3644 100644 --- a/app/javascript/flavours/glitch/components/logo.js +++ b/app/javascript/flavours/glitch/components/logo.js @@ -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> ); diff --git a/app/javascript/flavours/glitch/components/not_signed_in_indicator.js b/app/javascript/flavours/glitch/components/not_signed_in_indicator.js new file mode 100644 index 000000000..b440c6be2 --- /dev/null +++ b/app/javascript/flavours/glitch/components/not_signed_in_indicator.js @@ -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; diff --git a/app/javascript/flavours/glitch/components/poll.js b/app/javascript/flavours/glitch/components/poll.js index 970b00705..bcd9fe341 100644 --- a/app/javascript/flavours/glitch/components/poll.js +++ b/app/javascript/flavours/glitch/components/poll.js @@ -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>} diff --git a/app/javascript/flavours/glitch/components/server_banner.js b/app/javascript/flavours/glitch/components/server_banner.js new file mode 100644 index 000000000..e29876d4b --- /dev/null +++ b/app/javascript/flavours/glitch/components/server_banner.js @@ -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> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js index d3b6bd7e9..9b656b2b3 100644 --- a/app/javascript/flavours/glitch/components/status.js +++ b/app/javascript/flavours/glitch/components/status.js @@ -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, diff --git a/app/javascript/flavours/glitch/components/status_action_bar.js b/app/javascript/flavours/glitch/components/status_action_bar.js index 78964e882..4e5e5121a 100644 --- a/app/javascript/flavours/glitch/components/status_action_bar.js +++ b/app/javascript/flavours/glitch/components/status_action_bar.js @@ -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,28 +104,28 @@ 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); } } - handleBookmarkClick = (e) => { - this.props.onBookmark(this.props.status, e); - } - handleReblogClick = e => { - if (me) { + const { signedIn } = this.context.identity; + + if (signedIn) { this.props.onReblog(this.props.status, e); } else { - this._openInteractionDialog('reblog'); + this.props.onInteractionModal('reblog', this.props.status); } } - _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'); - } + handleBookmarkClick = (e) => { + this.props.onBookmark(this.props.status, e); + } handleDeleteClick = () => { this.props.onDelete(this.props.status, this.context.router.history); diff --git a/app/javascript/flavours/glitch/containers/mastodon.js b/app/javascript/flavours/glitch/containers/mastodon.js index d07b2b3d0..a6ec5845d 100644 --- a/app/javascript/flavours/glitch/containers/mastodon.js +++ b/app/javascript/flavours/glitch/containers/mastodon.js @@ -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 { diff --git a/app/javascript/flavours/glitch/containers/status_container.js b/app/javascript/flavours/glitch/containers/status_container.js index 370308043..a1b4c57c6 100644 --- a/app/javascript/flavours/glitch/containers/status_container.js +++ b/app/javascript/flavours/glitch/containers/status_container.js @@ -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)); diff --git a/app/javascript/flavours/glitch/features/account/components/header.js b/app/javascript/flavours/glitch/features/account/components/header.js index cc2a7d4d4..fc80446b1 100644 --- a/app/javascript/flavours/glitch/features/account/components/header.js +++ b/app/javascript/flavours/glitch/features/account/components/header.js @@ -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> ); } diff --git a/app/javascript/flavours/glitch/features/account_timeline/components/header.js b/app/javascript/flavours/glitch/features/account_timeline/components/header.js index 783d318f9..eb332e296 100644 --- a/app/javascript/flavours/glitch/features/account_timeline/components/header.js +++ b/app/javascript/flavours/glitch/features/account_timeline/components/header.js @@ -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} /> diff --git a/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js b/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js index dc8b2d55a..659dbc3fa 100644 --- a/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js +++ b/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js @@ -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'))); diff --git a/app/javascript/flavours/glitch/features/community_timeline/index.js b/app/javascript/flavours/glitch/features/community_timeline/index.js index 64030e195..b921e22e9 100644 --- a/app/javascript/flavours/glitch/features/community_timeline/index.js +++ b/app/javascript/flavours/glitch/features/community_timeline/index.js @@ -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,18 +75,30 @@ class CommunityTimeline extends React.PureComponent { componentDidMount () { const { dispatch, onlyMedia } = this.props; + const { signedIn } = this.context.identity; dispatch(expandCommunityTimeline({ onlyMedia })); - this.disconnect = dispatch(connectCommunityStream({ 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; - this.disconnect(); + if (this.disconnect) { + this.disconnect(); + } + dispatch(expandCommunityTimeline({ onlyMedia })); - this.disconnect = dispatch(connectCommunityStream({ onlyMedia })); + + if (signedIn) { + this.disconnect = dispatch(connectCommunityStream({ onlyMedia })); + } } } @@ -132,6 +147,10 @@ class CommunityTimeline extends React.PureComponent { bindToDocument={!multiColumn} regex={this.props.regex} /> + + <Helmet> + <title>{intl.formatMessage(messages.title)} - {title}</title> + </Helmet> </Column> ); } diff --git a/app/javascript/flavours/glitch/features/directory/index.js b/app/javascript/flavours/glitch/features/directory/index.js index 87d9b3625..1aa8a330d 100644 --- a/app/javascript/flavours/glitch/features/directory/index.js +++ b/app/javascript/flavours/glitch/features/directory/index.js @@ -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> ); } diff --git a/app/javascript/flavours/glitch/features/explore/index.js b/app/javascript/flavours/glitch/features/explore/index.js index c89463e7e..388123c38 100644 --- a/app/javascript/flavours/glitch/features/explore/index.js +++ b/app/javascript/flavours/glitch/features/explore/index.js @@ -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} - /> - )} + <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> diff --git a/app/javascript/flavours/glitch/features/explore/links.js b/app/javascript/flavours/glitch/features/explore/links.js index 1d8cd8efc..6adc2f6fb 100644 --- a/app/javascript/flavours/glitch/features/explore/links.js +++ b/app/javascript/flavours/glitch/features/explore/links.js @@ -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 => ( diff --git a/app/javascript/flavours/glitch/features/explore/results.js b/app/javascript/flavours/glitch/features/explore/results.js index 8eac63c2c..3f43b9ee1 100644 --- a/app/javascript/flavours/glitch/features/explore/results.js +++ b/app/javascript/flavours/glitch/features/explore/results.js @@ -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> ); } diff --git a/app/javascript/flavours/glitch/features/explore/suggestions.js b/app/javascript/flavours/glitch/features/explore/suggestions.js index ccd2c950a..52e5ce62b 100644 --- a/app/javascript/flavours/glitch/features/explore/suggestions.js +++ b/app/javascript/flavours/glitch/features/explore/suggestions.js @@ -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 => ( diff --git a/app/javascript/flavours/glitch/features/explore/tags.js b/app/javascript/flavours/glitch/features/explore/tags.js index 0ec1eb88b..465fad0df 100644 --- a/app/javascript/flavours/glitch/features/explore/tags.js +++ b/app/javascript/flavours/glitch/features/explore/tags.js @@ -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 => ( diff --git a/app/javascript/flavours/glitch/features/follow_recommendations/index.js b/app/javascript/flavours/glitch/features/follow_recommendations/index.js index d050e3cc7..f934aeb35 100644 --- a/app/javascript/flavours/glitch/features/follow_recommendations/index.js +++ b/app/javascript/flavours/glitch/features/follow_recommendations/index.js @@ -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> diff --git a/app/javascript/flavours/glitch/features/hashtag_timeline/index.js b/app/javascript/flavours/glitch/features/hashtag_timeline/index.js index 87a52b269..38dda85b2 100644 --- a/app/javascript/flavours/glitch/features/hashtag_timeline/index.js +++ b/app/javascript/flavours/glitch/features/hashtag_timeline/index.js @@ -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> ); } diff --git a/app/javascript/flavours/glitch/features/home_timeline/index.js b/app/javascript/flavours/glitch/features/home_timeline/index.js index 51e932307..7ca28da35 100644 --- a/app/javascript/flavours/glitch/features/home_timeline/index.js +++ b/app/javascript/flavours/glitch/features/home_timeline/index.js @@ -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,15 +157,21 @@ class HomeTimeline extends React.PureComponent { <ColumnSettingsContainer /> </ColumnHeader> - <StatusListContainer - trackScroll={!pinned} - scrollKey={`home_timeline-${columnId}`} - onLoadMore={this.handleLoadMore} - timelineId='home' - emptyMessage={<FormattedMessage id='empty_column.home' defaultMessage='Your home timeline is empty! Follow more people to fill it up. {suggestions}' values={{ suggestions: <Link to='/start'><FormattedMessage id='empty_column.home.suggestions' defaultMessage='See some suggestions' /></Link> }} />} - bindToDocument={!multiColumn} - regex={this.props.regex} - /> + {signedIn ? ( + <StatusListContainer + trackScroll={!pinned} + scrollKey={`home_timeline-${columnId}`} + onLoadMore={this.handleLoadMore} + timelineId='home' + emptyMessage={<FormattedMessage id='empty_column.home' defaultMessage='Your home timeline is empty! Follow more people to fill it up. {suggestions}' values={{ suggestions: <Link to='/start'><FormattedMessage id='empty_column.home.suggestions' defaultMessage='See some suggestions' /></Link> }} />} + bindToDocument={!multiColumn} + regex={this.props.regex} + /> + ) : <NotSignedInIndicator />} + + <Helmet> + <title>{intl.formatMessage(messages.title)} - {title}</title> + </Helmet> </Column> ); } diff --git a/app/javascript/flavours/glitch/features/interaction_modal/index.js b/app/javascript/flavours/glitch/features/interaction_modal/index.js new file mode 100644 index 000000000..d2260c4f0 --- /dev/null +++ b/app/javascript/flavours/glitch/features/interaction_modal/index.js @@ -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> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/notifications/index.js b/app/javascript/flavours/glitch/features/notifications/index.js index 2682dd29b..6cfafb61e 100644 --- a/app/javascript/flavours/glitch/features/notifications/index.js +++ b/app/javascript/flavours/glitch/features/notifications/index.js @@ -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,26 +265,32 @@ class Notifications extends React.PureComponent { this.scrollableContent = scrollableContent; - const scrollContainer = ( - <ScrollableList - scrollKey={`notifications-${columnId}`} - trackScroll={!pinned} - isLoading={isLoading} - showLoading={isLoading && notifications.size === 0} - hasMore={hasMore} - numPending={numPending} - prepend={needsNotificationPermission && <NotificationsPermissionBanner />} - alwaysPrepend - emptyMessage={emptyMessage} - onLoadMore={this.handleLoadOlder} - onLoadPending={this.handleLoadPending} - onScrollToTop={this.handleScrollToTop} - onScroll={this.handleScroll} - bindToDocument={!multiColumn} - > - {scrollableContent} - </ScrollableList> - ); + let scrollContainer; + + if (signedIn) { + scrollContainer = ( + <ScrollableList + scrollKey={`notifications-${columnId}`} + trackScroll={!pinned} + isLoading={isLoading} + showLoading={isLoading && notifications.size === 0} + hasMore={hasMore} + numPending={numPending} + prepend={needsNotificationPermission && <NotificationsPermissionBanner />} + alwaysPrepend + emptyMessage={emptyMessage} + onLoadMore={this.handleLoadOlder} + onLoadPending={this.handleLoadPending} + onScrollToTop={this.handleScrollToTop} + onScroll={this.handleScroll} + bindToDocument={!multiColumn} + > + {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> ); } diff --git a/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.js b/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.js index 0408105ae..2a5adf71d 100644 --- a/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.js +++ b/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.js @@ -44,6 +44,7 @@ class Footer extends ImmutablePureComponent { static contextTypes = { router: PropTypes.object, + identity: PropTypes.object, }; static propTypes = { @@ -69,26 +70,44 @@ class Footer extends ImmutablePureComponent { }; handleReplyClick = () => { - const { dispatch, askReplyConfirmation, intl } = this.props; - - if (askReplyConfirmation) { - dispatch(openModal('CONFIRM', { - message: intl.formatMessage(messages.replyMessage), - confirm: intl.formatMessage(messages.replyConfirm), - onConfirm: this._performReply, - })); + const { dispatch, askReplyConfirmation, status, intl } = this.props; + const { signedIn } = this.context.identity; + + if (signedIn) { + if (askReplyConfirmation) { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.replyMessage), + confirm: intl.formatMessage(messages.replyConfirm), + onConfirm: this._performReply, + })); + } else { + this._performReply(); + } } else { - this._performReply(); + dispatch(openModal('INTERACTION', { + type: 'reply', + accountId: status.getIn(['account', 'id']), + url: status.get('url'), + })); } }; handleFavouriteClick = () => { const { dispatch, status } = this.props; - - if (status.get('favourited')) { - dispatch(unfavourite(status)); + const { signedIn } = this.context.identity; + + if (signedIn) { + if (status.get('favourited')) { + dispatch(unfavourite(status)); + } else { + dispatch(favourite(status)); + } } else { - dispatch(favourite(status)); + dispatch(openModal('INTERACTION', { + type: 'favourite', + accountId: status.getIn(['account', 'id']), + url: status.get('url'), + })); } }; @@ -99,13 +118,22 @@ class Footer extends ImmutablePureComponent { handleReblogClick = e => { const { dispatch, status } = this.props; - - if (status.get('reblogged')) { - dispatch(unreblog(status)); - } else if ((e && e.shiftKey) || !boostModal) { - this._performReblog(); + const { signedIn } = this.context.identity; + + if (signedIn) { + if (status.get('reblogged')) { + dispatch(unreblog(status)); + } else if ((e && e.shiftKey) || !boostModal) { + this._performReblog(); + } else { + dispatch(initBoostModal({ status, onReblog: this._performReblog })); + } } else { - dispatch(initBoostModal({ status, onReblog: this._performReblog })); + dispatch(openModal('INTERACTION', { + type: 'reblog', + accountId: status.getIn(['account', 'id']), + url: status.get('url'), + })); } }; diff --git a/app/javascript/flavours/glitch/features/public_timeline/index.js b/app/javascript/flavours/glitch/features/public_timeline/index.js index 9f31cf922..6e1fb8ced 100644 --- a/app/javascript/flavours/glitch/features/public_timeline/index.js +++ b/app/javascript/flavours/glitch/features/public_timeline/index.js @@ -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,18 +81,29 @@ class PublicTimeline extends React.PureComponent { componentDidMount () { const { dispatch, onlyMedia, onlyRemote, allowLocalOnly } = this.props; + const { signedIn } = this.context.identity; dispatch(expandPublicTimeline({ onlyMedia, onlyRemote, allowLocalOnly })); - this.disconnect = dispatch(connectPublicStream({ 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; - this.disconnect(); + if (this.disconnect) { + this.disconnect(); + } + dispatch(expandPublicTimeline({ onlyMedia, onlyRemote, allowLocalOnly })); - this.disconnect = dispatch(connectPublicStream({ onlyMedia, onlyRemote, allowLocalOnly })); + + if (signedIn) { + this.disconnect = dispatch(connectPublicStream({ onlyMedia, onlyRemote, allowLocalOnly })); + } } } @@ -138,6 +152,10 @@ class PublicTimeline extends React.PureComponent { bindToDocument={!multiColumn} regex={this.props.regex} /> + + <Helmet> + <title>{intl.formatMessage(messages.title)} - {title}</title> + </Helmet> </Column> ); } diff --git a/app/javascript/flavours/glitch/features/report/category.js b/app/javascript/flavours/glitch/features/report/category.js index bea21b1b7..43fb7a17c 100644 --- a/app/javascript/flavours/glitch/features/report/category.js +++ b/app/javascript/flavours/glitch/features/report/category.js @@ -20,7 +20,7 @@ const messages = defineMessages({ }); const mapStateToProps = state => ({ - rules: state.get('rules'), + rules: state.getIn(['server', 'rules']), }); export default @connect(mapStateToProps) diff --git a/app/javascript/flavours/glitch/features/report/rules.js b/app/javascript/flavours/glitch/features/report/rules.js index 4772e04a2..599c04dbd 100644 --- a/app/javascript/flavours/glitch/features/report/rules.js +++ b/app/javascript/flavours/glitch/features/report/rules.js @@ -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) diff --git a/app/javascript/flavours/glitch/features/status/components/action_bar.js b/app/javascript/flavours/glitch/features/status/components/action_bar.js index ef0f0f2b7..75ad462f0 100644 --- a/app/javascript/flavours/glitch/features/status/components/action_bar.js +++ b/app/javascript/flavours/glitch/features/status/components/action_bar.js @@ -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)} /> diff --git a/app/javascript/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js index 9c86d54db..8e783af80 100644 --- a/app/javascript/flavours/glitch/features/status/index.js +++ b/app/javascript/flavours/glitch/features/status/index.js @@ -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,14 +264,25 @@ class Status extends ImmutablePureComponent { } handleFavouriteClick = (status, e) => { - if (status.get('favourited')) { - this.props.dispatch(unfavourite(status)); - } else { - if ((e && e.shiftKey) || !favouriteModal) { - this.handleModalFavourite(status); + const { dispatch } = this.props; + const { signedIn } = this.context.identity; + + if (signedIn) { + if (status.get('favourited')) { + dispatch(unfavourite(status)); } else { - this.props.dispatch(openModal('FAVOURITE', { status, onFavourite: this.handleModalFavourite })); + if ((e && e.shiftKey) || !favouriteModal) { + this.handleModalFavourite(status); + } else { + dispatch(openModal('FAVOURITE', { status, onFavourite: this.handleModalFavourite })); + } } + } else { + dispatch(openModal('INTERACTION', { + type: 'favourite', + accountId: status.getIn(['account', 'id']), + url: status.get('url'), + })); } } @@ -265,16 +295,26 @@ class Status extends ImmutablePureComponent { } handleReplyClick = (status) => { - let { askReplyConfirmation, dispatch, intl } = this.props; - if (askReplyConfirmation) { - dispatch(openModal('CONFIRM', { - message: intl.formatMessage(messages.replyMessage), - confirm: intl.formatMessage(messages.replyConfirm), - onDoNotAsk: () => dispatch(changeLocalSetting(['confirm_before_clearing_draft'], false)), - onConfirm: () => dispatch(replyCompose(status, this.context.router.history)), - })); + const { askReplyConfirmation, dispatch, intl } = this.props; + const { signedIn } = this.context.identity; + + if (signedIn) { + if (askReplyConfirmation) { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.replyMessage), + confirm: intl.formatMessage(messages.replyConfirm), + onDoNotAsk: () => dispatch(changeLocalSetting(['confirm_before_clearing_draft'], false)), + onConfirm: () => dispatch(replyCompose(status, this.context.router.history)), + })); + } else { + dispatch(replyCompose(status, this.context.router.history)); + } } else { - dispatch(replyCompose(status, this.context.router.history)); + dispatch(openModal('INTERACTION', { + type: 'reply', + accountId: status.getIn(['account', 'id']), + url: status.get('url'), + })); } } @@ -290,13 +330,22 @@ class Status extends ImmutablePureComponent { handleReblogClick = (status, e) => { const { settings, dispatch } = this.props; + const { signedIn } = this.context.identity; - if (settings.get('confirm_boost_missing_media_description') && status.get('media_attachments').some(item => !item.get('description')) && !status.get('reblogged')) { - dispatch(initBoostModal({ status, onReblog: this.handleModalReblog, missingMediaDescription: true })); - } else if ((e && e.shiftKey) || !boostModal) { - this.handleModalReblog(status); + 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) { + this.handleModalReblog(status); + } else { + dispatch(initBoostModal({ status, onReblog: this.handleModalReblog })); + } } else { - dispatch(initBoostModal({ status, onReblog: this.handleModalReblog })); + dispatch(openModal('INTERACTION', { + type: 'reblog', + accountId: status.getIn(['account', 'id']), + url: status.get('url'), + })); } } @@ -633,6 +682,10 @@ class Status extends ImmutablePureComponent { {descendants} </div> </ScrollContainer> + + <Helmet> + <title>{titleFromStatus(status)} - {title}</title> + </Helmet> </Column> ); } diff --git a/app/javascript/flavours/glitch/features/ui/components/columns_area.js b/app/javascript/flavours/glitch/features/ui/components/columns_area.js index 048251fa6..5f5018105 100644 --- a/app/javascript/flavours/glitch/features/ui/components/columns_area.js +++ b/app/javascript/flavours/glitch/features/ui/components/columns_area.js @@ -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}> diff --git a/app/javascript/flavours/glitch/features/ui/components/compose_panel.js b/app/javascript/flavours/glitch/features/ui/components/compose_panel.js index 498f09ab6..6e1c51d74 100644 --- a/app/javascript/flavours/glitch/features/ui/components/compose_panel.js +++ b/app/javascript/flavours/glitch/features/ui/components/compose_panel.js @@ -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 = () => ( - <div className='compose-panel'> - <SearchContainer openInRoute /> - <NavigationContainer /> - <ComposeFormContainer singleColumn /> - <LinkFooter withHotkeys /> - </div> -); +export default +class ComposePanel extends React.PureComponent { -export default ComposePanel; + static contextTypes = { + identity: PropTypes.object.isRequired, + }; + + render() { + const { signedIn } = this.context.identity; + + return ( + <div className='compose-panel'> + <SearchContainer openInRoute /> + + {!signedIn && ( + <React.Fragment> + <ServerBanner /> + <div className='flex-spacer' /> + </React.Fragment> + )} + + {signedIn && ( + <React.Fragment> + <NavigationContainer /> + <ComposeFormContainer singleColumn /> + </React.Fragment> + )} + + <LinkFooter withHotkeys /> + </div> + ); + } + +}; diff --git a/app/javascript/flavours/glitch/features/ui/components/link_footer.js b/app/javascript/flavours/glitch/features/ui/components/link_footer.js index 67fa067f9..6f4a8c2de 100644 --- a/app/javascript/flavours/glitch/features/ui/components/link_footer.js +++ b/app/javascript/flavours/glitch/features/ui/components/link_footer.js @@ -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> diff --git a/app/javascript/flavours/glitch/features/ui/components/modal_root.js b/app/javascript/flavours/glitch/features/ui/components/modal_root.js index 7e8082f99..92768caeb 100644 --- a/app/javascript/flavours/glitch/features/ui/components/modal_root.js +++ b/app/javascript/flavours/glitch/features/ui/components/modal_root.js @@ -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 { diff --git a/app/javascript/flavours/glitch/features/ui/components/navigation_panel.js b/app/javascript/flavours/glitch/features/ui/components/navigation_panel.js index f4d649456..453276775 100644 --- a/app/javascript/flavours/glitch/features/ui/components/navigation_panel.js +++ b/app/javascript/flavours/glitch/features/ui/components/navigation_panel.js @@ -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,30 +9,70 @@ 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 }) => ( - <div className='navigation-panel'> - <NavLink className='column-link column-link--transparent' to='/home' data-preview-title-id='column.home' data-preview-icon='home' ><Icon className='column-link__icon' id='home' fixedWidth /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink> - <NavLink className='column-link column-link--transparent' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><NotificationsCounterIcon className='column-link__icon' /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></NavLink> - <FollowRequestsNavLink /> - { showTrends && <NavLink className='column-link column-link--transparent' to='/explore' data-preview-title-id='explore.title' data-preview-icon='hashtag'><Icon className='column-link__icon' id='hashtag' fixedWidth /><FormattedMessage id='explore.title' defaultMessage='Explore' /></NavLink> } - <NavLink className='column-link column-link--transparent' to='/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon className='column-link__icon' id='users' fixedWidth /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink> - <NavLink className='column-link column-link--transparent' exact to='/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon className='column-link__icon' id='globe' fixedWidth /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink> - <NavLink className='column-link column-link--transparent' to='/conversations'><Icon className='column-link__icon' id='envelope' fixedWidth /><FormattedMessage id='navigation_bar.direct' defaultMessage='Direct messages' /></NavLink> - <NavLink className='column-link column-link--transparent' to='/bookmarks'><Icon className='column-link__icon' id='bookmark' fixedWidth /><FormattedMessage id='navigation_bar.bookmarks' defaultMessage='Bookmarks' /></NavLink> - <NavLink className='column-link column-link--transparent' to='/lists'><Icon className='column-link__icon' id='list-ul' fixedWidth /><FormattedMessage id='navigation_bar.lists' defaultMessage='Lists' /></NavLink> +export default class NavigationPanel extends React.Component { - <ListPanel /> + static contextTypes = { + router: PropTypes.object.isRequired, + identity: PropTypes.object.isRequired, + }; - <hr /> + static propTypes = { + onOpenSettings: PropTypes.func, + }; - {!!preferencesLink && <a className='column-link column-link--transparent' href={preferencesLink} target='_blank'><Icon className='column-link__icon' id='cog' fixedWidth /><FormattedMessage id='navigation_bar.preferences' defaultMessage='Preferences' /></a>} - <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>} + render() { + const { signedIn } = this.context.identity; + const { onOpenSettings } = this.props; - {showTrends && <div className='flex-spacer' />} - {showTrends && <TrendsContainer />} - </div> -); + 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> + )} -export default withRouter(NavigationPanel); + { showTrends && <NavLink className='column-link column-link--transparent' to='/explore' data-preview-title-id='explore.title' data-preview-icon='hashtag'><Icon className='column-link__icon' id='hashtag' fixedWidth /><FormattedMessage id='explore.title' defaultMessage='Explore' /></NavLink> } + + <NavLink className='column-link column-link--transparent' to='/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon className='column-link__icon' id='users' fixedWidth /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink> + <NavLink className='column-link column-link--transparent' exact to='/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon className='column-link__icon' id='globe' fixedWidth /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink> + + {!signedIn && ( + <React.Fragment> + <hr /> + <SignInBanner /> + </React.Fragment> + )} + + {signedIn && ( + <React.Fragment> + <NavLink className='column-link column-link--transparent' to='/conversations'><Icon className='column-link__icon' id='envelope' fixedWidth /><FormattedMessage id='navigation_bar.direct' defaultMessage='Direct messages' /></NavLink> + <NavLink className='column-link column-link--transparent' to='/bookmarks'><Icon className='column-link__icon' id='bookmark' fixedWidth /><FormattedMessage id='navigation_bar.bookmarks' defaultMessage='Bookmarks' /></NavLink> + <NavLink className='column-link column-link--transparent' to='/lists'><Icon className='column-link__icon' id='list-ul' fixedWidth /><FormattedMessage id='navigation_bar.lists' defaultMessage='Lists' /></NavLink> + + <ListPanel /> + + <hr /> + + {!!preferencesLink && <a className='column-link column-link--transparent' href={preferencesLink} target='_blank'><Icon className='column-link__icon' id='cog' fixedWidth /><FormattedMessage id='navigation_bar.preferences' defaultMessage='Preferences' /></a>} + <a className='column-link column-link--transparent' href='#' onClick={onOpenSettings}><Icon className='column-link__icon' id='cogs' fixedWidth /><FormattedMessage id='navigation_bar.app_settings' defaultMessage='App settings' /></a> + {!!relationshipsLink && <a className='column-link column-link--transparent' href={relationshipsLink} target='_blank'><Icon className='column-link__icon' id='users' fixedWidth /><FormattedMessage id='navigation_bar.follows_and_followers' defaultMessage='Follows and followers' /></a>} + </React.Fragment> + )} + + {showTrends && ( + <React.Fragment> + <div className='flex-spacer' /> + <TrendsContainer /> + </React.Fragment> + )} + + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/report_modal.js b/app/javascript/flavours/glitch/features/ui/components/report_modal.js index dcbe94929..7b6a4a784 100644 --- a/app/javascript/flavours/glitch/features/ui/components/report_modal.js +++ b/app/javascript/flavours/glitch/features/ui/components/report_modal.js @@ -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 () { diff --git a/app/javascript/flavours/glitch/features/ui/components/sign_in_banner.js b/app/javascript/flavours/glitch/features/ui/components/sign_in_banner.js new file mode 100644 index 000000000..e08a1ea67 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/sign_in_banner.js @@ -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; diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js index 7b547fd5b..735623e3d 100644 --- a/app/javascript/flavours/glitch/features/ui/index.js +++ b/app/javascript/flavours/glitch/features/ui/index.js @@ -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()); } - this.props.dispatch(fetchMarkers()); - this.props.dispatch(expandHomeTimeline()); - this.props.dispatch(expandNotifications()); + 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> ); diff --git a/app/javascript/flavours/glitch/packs/home.js b/app/javascript/flavours/glitch/packs/home.js index d06688985..7c87c515c 100644 --- a/app/javascript/flavours/glitch/packs/home.js +++ b/app/javascript/flavours/glitch/packs/home.js @@ -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); }); diff --git a/app/javascript/flavours/glitch/reducers/index.js b/app/javascript/flavours/glitch/reducers/index.js index 991b4aa79..09c08a362 100644 --- a/app/javascript/flavours/glitch/reducers/index.js +++ b/app/javascript/flavours/glitch/reducers/index.js @@ -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, diff --git a/app/javascript/flavours/glitch/reducers/rules.js b/app/javascript/flavours/glitch/reducers/rules.js deleted file mode 100644 index 6cc2230bc..000000000 --- a/app/javascript/flavours/glitch/reducers/rules.js +++ /dev/null @@ -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; - } -} diff --git a/app/javascript/flavours/glitch/reducers/server.js b/app/javascript/flavours/glitch/reducers/server.js new file mode 100644 index 000000000..977574148 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/server.js @@ -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; + } +} diff --git a/app/javascript/flavours/glitch/styles/_mixins.scss b/app/javascript/flavours/glitch/styles/_mixins.scss index c92bc8608..9f6314f3f 100644 --- a/app/javascript/flavours/glitch/styles/_mixins.scss +++ b/app/javascript/flavours/glitch/styles/_mixins.scss @@ -60,6 +60,7 @@ font-family: inherit; background: $ui-base-color; color: $darker-text-color; + border-radius: 4px; font-size: 14px; margin: 0; } diff --git a/app/javascript/flavours/glitch/styles/components/columns.scss b/app/javascript/flavours/glitch/styles/components/columns.scss index 1440682f3..762e5dbb9 100644 --- a/app/javascript/flavours/glitch/styles/components/columns.scss +++ b/app/javascript/flavours/glitch/styles/components/columns.scss @@ -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 { diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss index 14fbc61b5..1c3540b33 100644 --- a/app/javascript/flavours/glitch/styles/components/index.scss +++ b/app/javascript/flavours/glitch/styles/components/index.scss @@ -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'; diff --git a/app/javascript/flavours/glitch/styles/components/modal.scss b/app/javascript/flavours/glitch/styles/components/modal.scss index e95bea0d7..cd96f83d6 100644 --- a/app/javascript/flavours/glitch/styles/components/modal.scss +++ b/app/javascript/flavours/glitch/styles/components/modal.scss @@ -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; + } + } +} diff --git a/app/javascript/flavours/glitch/styles/components/signed_out.scss b/app/javascript/flavours/glitch/styles/components/signed_out.scss new file mode 100644 index 000000000..1051b2f32 --- /dev/null +++ b/app/javascript/flavours/glitch/styles/components/signed_out.scss @@ -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; + } +} diff --git a/app/javascript/flavours/glitch/styles/components/single_column.scss b/app/javascript/flavours/glitch/styles/components/single_column.scss index 3843bcd68..bbb8b456c 100644 --- a/app/javascript/flavours/glitch/styles/components/single_column.scss +++ b/app/javascript/flavours/glitch/styles/components/single_column.scss @@ -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; diff --git a/app/javascript/flavours/glitch/util/initial_state.js b/app/javascript/flavours/glitch/util/initial_state.js index 90dada4b3..99ee6bc69 100644 --- a/app/javascript/flavours/glitch/util/initial_state.js +++ b/app/javascript/flavours/glitch/util/initial_state.js @@ -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; diff --git a/app/javascript/flavours/glitch/util/main.js b/app/javascript/flavours/glitch/util/main.js index 9e76774b6..b4e6bc151 100644 --- a/app/javascript/flavours/glitch/util/main.js +++ b/app/javascript/flavours/glitch/util/main.js @@ -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 wb = new Workbox('/sw.js'); - - return wb.register(); - }) - .then(() => { - store.dispatch(registerPushNotifications.register()); - }) - .catch(err => { - console.error(err); - }); + const [{ Workbox }, { me }] = await Promise.all([ + import('workbox-window'), + import('mastodon/initial_state'), + ]); + + const wb = new Workbox('/sw.js'); + + 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()'); }); } diff --git a/app/javascript/flavours/glitch/util/ready.js b/app/javascript/flavours/glitch/util/ready.js index dd543910b..e769cc756 100644 --- a/app/javascript/flavours/glitch/util/ready.js +++ b/app/javascript/flavours/glitch/util/ready.js @@ -1,7 +1,32 @@ -export default function ready(loaded) { - if (['interactive', 'complete'].includes(document.readyState)) { - loaded(); - } else { - document.addEventListener('DOMContentLoaded', 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); + } + }); } diff --git a/app/javascript/mastodon/actions/rules.js b/app/javascript/mastodon/actions/rules.js deleted file mode 100644 index 34e60a121..000000000 --- a/app/javascript/mastodon/actions/rules.js +++ /dev/null @@ -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, -}); diff --git a/app/javascript/mastodon/actions/server.js b/app/javascript/mastodon/actions/server.js new file mode 100644 index 000000000..af8fef780 --- /dev/null +++ b/app/javascript/mastodon/actions/server.js @@ -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, +}); diff --git a/app/javascript/mastodon/components/account.js b/app/javascript/mastodon/components/account.js index af9f119c8..36429e647 100644 --- a/app/javascript/mastodon/components/account.js +++ b/app/javascript/mastodon/components/account.js @@ -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) { diff --git a/app/javascript/mastodon/components/display_name.js b/app/javascript/mastodon/components/display_name.js index 7ccfbd0cd..e9139ab0f 100644 --- a/app/javascript/mastodon/components/display_name.js +++ b/app/javascript/mastodon/components/display_name.js @@ -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 ( diff --git a/app/javascript/mastodon/components/server_banner.js b/app/javascript/mastodon/components/server_banner.js new file mode 100644 index 000000000..bdd7f7380 --- /dev/null +++ b/app/javascript/mastodon/components/server_banner.js @@ -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> + ); + } + +} diff --git a/app/javascript/mastodon/features/report/rules.js b/app/javascript/mastodon/features/report/rules.js index f2db0d9e4..2cb4a95b5 100644 --- a/app/javascript/mastodon/features/report/rules.js +++ b/app/javascript/mastodon/features/report/rules.js @@ -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) diff --git a/app/javascript/mastodon/features/ui/components/compose_panel.js b/app/javascript/mastodon/features/ui/components/compose_panel.js index 1c128188f..c8bc79a67 100644 --- a/app/javascript/mastodon/features/ui/components/compose_panel.js +++ b/app/javascript/mastodon/features/ui/components/compose_panel.js @@ -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> )} diff --git a/app/javascript/mastodon/features/ui/components/report_modal.js b/app/javascript/mastodon/features/ui/components/report_modal.js index 744dd248b..264da07ce 100644 --- a/app/javascript/mastodon/features/ui/components/report_modal.js +++ b/app/javascript/mastodon/features/ui/components/report_modal.js @@ -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 () { diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index 5825db1e4..efe460fab 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -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) => { diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js index 3587bb32a..08121005a 100644 --- a/app/javascript/mastodon/initial_state.js +++ b/app/javascript/mastodon/initial_state.js @@ -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; diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js index d3d0303df..bccdc1865 100644 --- a/app/javascript/mastodon/reducers/index.js +++ b/app/javascript/mastodon/reducers/index.js @@ -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, diff --git a/app/javascript/mastodon/reducers/rules.js b/app/javascript/mastodon/reducers/rules.js deleted file mode 100644 index c1180b520..000000000 --- a/app/javascript/mastodon/reducers/rules.js +++ /dev/null @@ -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; - } -} diff --git a/app/javascript/mastodon/reducers/server.js b/app/javascript/mastodon/reducers/server.js new file mode 100644 index 000000000..68131c6dd --- /dev/null +++ b/app/javascript/mastodon/reducers/server.js @@ -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; + } +} diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index a75437b1c..b906117db 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -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; + } +} diff --git a/app/lib/permalink_redirector.rb b/app/lib/permalink_redirector.rb index e48bce060..6d15f3963 100644 --- a/app/lib/permalink_redirector.rb +++ b/app/lib/permalink_redirector.rb @@ -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 diff --git a/app/presenters/instance_presenter.rb b/app/presenters/instance_presenter.rb index 3e85faa92..c461ac55f 100644 --- a/app/presenters/instance_presenter.rb +++ b/app/presenters/instance_presenter.rb @@ -1,19 +1,51 @@ # 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 - ) - - def contact_account - Account.find_local(Setting.site_contact_username.strip.gsub(/\A@/, '')) +class InstancePresenter < ActiveModelSerializers::Model + attributes :domain, :title, :version, :source_url, + :description, :languages, :rules, :contact + + 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 @@ -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 diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index 45a5cda6f..aa36f82a1 100644 --- a/app/serializers/initial_state_serializer.rb +++ b/app/serializers/initial_state_serializer.rb @@ -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 diff --git a/app/serializers/manifest_serializer.rb b/app/serializers/manifest_serializer.rb index 9827323a8..6b5296480 100644 --- a/app/serializers/manifest_serializer.rb +++ b/app/serializers/manifest_serializer.rb @@ -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 diff --git a/app/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb index 575c6214e..f4ea49427 100644 --- a/app/serializers/rest/instance_serializer.rb +++ b/app/serializers/rest/instance_serializer.rb @@ -1,74 +1,39 @@ # frozen_string_literal: true class REST::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 - - delegate :contact_account, :rules, to: :instance_presenter - - def uri - Rails.configuration.x.local_domain - end - - def title - Setting.site_title - end + class ContactSerializer < ActiveModel::Serializer + attributes :email - def short_description - Setting.site_short_description + has_one :account, serializer: REST::AccountSerializer end - def description - Setting.site_description - end + include RoutingHelper - def email - Setting.site_contact_email - end + attributes :domain, :title, :version, :source_url, :description, + :usage, :thumbnail, :languages, :configuration, + :registrations - def version - Mastodon::Version.to_s - end + has_one :contact, serializer: ContactSerializer + has_many :rules, serializer: REST::RuleSerializer 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, - } - end - - def stats - { - user_count: instance_presenter.user_count, - status_count: instance_presenter.status_count, - domain_count: instance_presenter.domain_count, + users: { + active_month: object.active_user_count(4), + }, } 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 diff --git a/app/serializers/rest/v1/instance_serializer.rb b/app/serializers/rest/v1/instance_serializer.rb new file mode 100644 index 000000000..fefbed8ee --- /dev/null +++ b/app/serializers/rest/v1/instance_serializer.rb @@ -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 diff --git a/app/views/about/more.html.haml b/app/views/about/more.html.haml index 0b75f159a..a75549120 100644 --- a/app/views/about/more.html.haml +++ b/app/views/about/more.html.haml @@ -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' diff --git a/app/views/about/show.html.haml b/app/views/about/show.html.haml index fb292941b..8d09a2938 100644 --- a/app/views/about/show.html.haml +++ b/app/views/about/show.html.haml @@ -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' diff --git a/app/views/application/_sidebar.html.haml b/app/views/application/_sidebar.html.haml index cc157bf47..eb2813dd0 100644 --- a/app/views/application/_sidebar.html.haml +++ b/app/views/application/_sidebar.html.haml @@ -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) diff --git a/app/views/home/index.html.haml b/app/views/home/index.html.haml index 568b23eff..437c33715 100644 --- a/app/views/home/index.html.haml +++ b/app/views/home/index.html.haml @@ -1,10 +1,14 @@ - content_for :header_tags do - = 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' + - 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) } } diff --git a/app/views/privacy/show.html.haml b/app/views/privacy/show.html.haml index 9d076a91b..cdd38a595 100644 --- a/app/views/privacy/show.html.haml +++ b/app/views/privacy/show.html.haml @@ -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' diff --git a/app/views/shared/_og.html.haml b/app/views/shared/_og.html.haml index 7feae1b8b..b54ab2429 100644 --- a/app/views/shared/_og.html.haml +++ b/app/views/shared/_og.html.haml @@ -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' diff --git a/config/routes.rb b/config/routes.rb index 7330bb8df..8df1e45b7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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] diff --git a/spec/controllers/home_controller_spec.rb b/spec/controllers/home_controller_spec.rb index 70c5c42c5..914ca6307 100644 --- a/spec/controllers/home_controller_spec.rb +++ b/spec/controllers/home_controller_spec.rb @@ -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 diff --git a/spec/lib/permalink_redirector_spec.rb b/spec/lib/permalink_redirector_spec.rb index b916b33b2..abda57da4 100644 --- a/spec/lib/permalink_redirector_spec.rb +++ b/spec/lib/permalink_redirector_spec.rb @@ -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 diff --git a/spec/presenters/instance_presenter_spec.rb b/spec/presenters/instance_presenter_spec.rb index 81d8d0e98..4baad03f4 100644 --- a/spec/presenters/instance_presenter_spec.rb +++ b/spec/presenters/instance_presenter_spec.rb @@ -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 diff --git a/spec/views/about/show.html.haml_spec.rb b/spec/views/about/show.html.haml_spec.rb index 140f3fd41..15802799a 100644 --- a/spec/views/about/show.html.haml_spec.rb +++ b/spec/views/about/show.html.haml_spec.rb @@ -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) |