diff options
82 files changed, 1673 insertions, 349 deletions
diff --git a/Gemfile b/Gemfile index 4305c42b0..246450c1b 100644 --- a/Gemfile +++ b/Gemfile @@ -94,7 +94,7 @@ gem 'tzinfo-data', '~> 1.2019' gem 'webpacker', '~> 4.0' gem 'webpush' -gem 'json-ld', git: 'https://github.com/ruby-rdf/json-ld.git', ref: '345b7a5733308af827e8491d284dbafa9128d7a2' +gem 'json-ld', git: 'https://github.com/ruby-rdf/json-ld.git', ref: 'e742697a0906e74e8bb777ef98137bc3955d981d' gem 'json-ld-preloaded', '~> 3.0' gem 'rdf-normalize', '~> 0.3' diff --git a/Gemfile.lock b/Gemfile.lock index 274d4601b..95b65d644 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -7,8 +7,8 @@ GIT GIT remote: https://github.com/ruby-rdf/json-ld.git - revision: 345b7a5733308af827e8491d284dbafa9128d7a2 - ref: 345b7a5733308af827e8491d284dbafa9128d7a2 + revision: e742697a0906e74e8bb777ef98137bc3955d981d + ref: e742697a0906e74e8bb777ef98137bc3955d981d specs: json-ld (3.0.2) htmlentities (~> 4.3) diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index de8fff30e..33df75b37 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -36,6 +36,14 @@ class Api::BaseController < ApplicationController render json: { error: 'This action is not allowed' }, status: 403 end + rescue_from Mastodon::RaceConditionError do + render json: { error: 'There was a temporary problem serving your request, please try again' }, status: 503 + end + + rescue_from ActionController::ParameterMissing do |e| + render json: { error: e.to_s }, status: 400 + end + def doorkeeper_unauthorized_render_options(error: nil) { json: { error: (error.try(:description) || 'Not authorized') } } end diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb index 13cb4caf1..0787cd636 100644 --- a/app/controllers/api/v1/accounts/statuses_controller.rb +++ b/app/controllers/api/v1/accounts/statuses_controller.rb @@ -29,14 +29,13 @@ class Api::V1::Accounts::StatusesController < Api::BaseController def account_statuses statuses = truthy_param?(:pinned) ? pinned_scope : permitted_account_statuses - statuses = statuses.paginate_by_id(limit_param(DEFAULT_STATUSES_LIMIT), params_slice(:max_id, :since_id, :min_id)) statuses.merge!(only_media_scope) if truthy_param?(:only_media) statuses.merge!(no_replies_scope) if truthy_param?(:exclude_replies) statuses.merge!(no_reblogs_scope) if truthy_param?(:exclude_reblogs) statuses.merge!(hashtag_scope) if params[:tagged].present? - statuses + statuses.paginate_by_id(limit_param(DEFAULT_STATUSES_LIMIT), params_slice(:max_id, :since_id, :min_id)) end def permitted_account_statuses diff --git a/app/controllers/api/v1/directories_controller.rb b/app/controllers/api/v1/directories_controller.rb new file mode 100644 index 000000000..c91543e3a --- /dev/null +++ b/app/controllers/api/v1/directories_controller.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class Api::V1::DirectoriesController < Api::BaseController + before_action :require_enabled! + before_action :set_accounts + + def show + render json: @accounts, each_serializer: REST::AccountSerializer + end + + private + + def require_enabled! + return not_found unless Setting.profile_directory + end + + def set_accounts + @accounts = accounts_scope.offset(params[:offset]).limit(limit_param(DEFAULT_ACCOUNTS_LIMIT)) + end + + def accounts_scope + Account.discoverable.tap do |scope| + scope.merge!(Account.local) if truthy_param?(:local) + scope.merge!(Account.by_recent_status) if params[:order].blank? || params[:order] == 'active' + scope.merge!(Account.order(id: :desc)) if params[:order] == 'new' + scope.merge!(Account.not_excluded_by_account(current_account)) if current_account + scope.merge!(Account.not_domain_blocked_by_account(current_account)) if current_account && !truthy_param?(:local) + end + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 5f88838e4..59624cad5 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -22,11 +22,13 @@ class ApplicationController < ActionController::Base helper_method :whitelist_mode? rescue_from ActionController::RoutingError, with: :not_found - rescue_from ActiveRecord::RecordNotFound, with: :not_found rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_entity rescue_from ActionController::UnknownFormat, with: :not_acceptable + rescue_from ActionController::ParameterMissing, with: :bad_request + rescue_from ActiveRecord::RecordNotFound, with: :not_found rescue_from Mastodon::NotPermittedError, with: :forbidden rescue_from HTTP::Error, OpenSSL::SSL::SSLError, with: :internal_server_error + rescue_from Mastodon::RaceConditionError, with: :service_unavailable before_action :store_current_location, except: :raise_not_found, unless: :devise_controller? before_action :require_functional!, if: :user_signed_in? @@ -166,10 +168,18 @@ class ApplicationController < ActionController::Base respond_with_error(406) end + def bad_request + respond_with_error(400) + end + def internal_server_error respond_with_error(500) end + def service_unavailable + respond_with_error(503) + end + def single_user_mode? @single_user_mode ||= Rails.configuration.x.single_user_mode && Account.where('id > 0').exists? end diff --git a/app/controllers/directories_controller.rb b/app/controllers/directories_controller.rb index f2d1f5661..bbfdde8af 100644 --- a/app/controllers/directories_controller.rb +++ b/app/controllers/directories_controller.rb @@ -7,7 +7,6 @@ class DirectoriesController < ApplicationController before_action :require_enabled! before_action :set_instance_presenter before_action :set_tag, only: :show - before_action :set_tags before_action :set_accounts before_action :set_pack @@ -33,13 +32,10 @@ class DirectoriesController < ApplicationController @tag = Tag.discoverable.find_normalized!(params[:id]) end - def set_tags - @tags = Tag.discoverable.limit(30).reject { |tag| tag.cached_sample_accounts.empty? } - end - def set_accounts - @accounts = Account.discoverable.by_recent_status.page(params[:page]).per(40).tap do |query| + @accounts = Account.local.discoverable.by_recent_status.page(params[:page]).per(20).tap do |query| query.merge!(Account.tagged_with(@tag.id)) if @tag + query.merge!(Account.not_excluded_by_account(current_account)) if current_account end end diff --git a/app/controllers/remote_follow_controller.rb b/app/controllers/remote_follow_controller.rb index 46dd444a4..65dfa35db 100644 --- a/app/controllers/remote_follow_controller.rb +++ b/app/controllers/remote_follow_controller.rb @@ -30,7 +30,7 @@ class RemoteFollowController < ApplicationController end def session_params - { acct: session[:remote_follow] } + { acct: session[:remote_follow] || current_account&.username } end def set_pack diff --git a/app/controllers/remote_interaction_controller.rb b/app/controllers/remote_interaction_controller.rb index 5ae72989b..6b797b10f 100644 --- a/app/controllers/remote_interaction_controller.rb +++ b/app/controllers/remote_interaction_controller.rb @@ -33,7 +33,7 @@ class RemoteInteractionController < ApplicationController end def session_params - { acct: session[:remote_follow] } + { acct: session[:remote_follow] || current_account&.username } end def set_status diff --git a/app/controllers/well_known/webfinger_controller.rb b/app/controllers/well_known/webfinger_controller.rb index 50bace217..d60bf98ab 100644 --- a/app/controllers/well_known/webfinger_controller.rb +++ b/app/controllers/well_known/webfinger_controller.rb @@ -11,7 +11,7 @@ module WellKnown expires_in 3.days, public: true render json: @account, serializer: WebfingerSerializer, content_type: 'application/jrd+json' - rescue ActiveRecord::RecordNotFound + rescue ActiveRecord::RecordNotFound, ActionController::ParameterMissing head 404 end diff --git a/app/helpers/statuses_helper.rb b/app/helpers/statuses_helper.rb index 2996631a3..880a4037f 100644 --- a/app/helpers/statuses_helper.rb +++ b/app/helpers/statuses_helper.rb @@ -34,6 +34,26 @@ module StatusesHelper end end + def minimal_account_action_button(account) + if user_signed_in? + return if account.id == current_user.account_id + + if current_account.following?(account) || current_account.requested?(account) + link_to account_unfollow_path(account), class: 'icon-button active', data: { method: :post }, title: t('accounts.unfollow') do + fa_icon('user-times fw') + end + elsif !(account.memorial? || account.moved?) + link_to account_follow_path(account), class: "icon-button#{account.blocking?(current_account) ? ' disabled' : ''}", data: { method: :post }, title: t('accounts.follow') do + fa_icon('user-plus fw') + end + end + elsif !(account.memorial? || account.moved?) + link_to account_remote_follow_path(account), class: 'icon-button modal-button', target: '_new', title: t('accounts.follow') do + fa_icon('user-plus fw') + end + end + end + def svg_logo content_tag(:svg, tag(:use, 'xlink:href' => '#mastodon-svg-logo'), 'viewBox' => '0 0 216.4144 232.00976') end diff --git a/app/javascript/flavours/glitch/actions/directory.js b/app/javascript/flavours/glitch/actions/directory.js new file mode 100644 index 000000000..9fbfb7f5b --- /dev/null +++ b/app/javascript/flavours/glitch/actions/directory.js @@ -0,0 +1,61 @@ +import api from 'flavours/glitch/util/api'; +import { importFetchedAccounts } from './importer'; +import { fetchRelationships } from './accounts'; + +export const DIRECTORY_FETCH_REQUEST = 'DIRECTORY_FETCH_REQUEST'; +export const DIRECTORY_FETCH_SUCCESS = 'DIRECTORY_FETCH_SUCCESS'; +export const DIRECTORY_FETCH_FAIL = 'DIRECTORY_FETCH_FAIL'; + +export const DIRECTORY_EXPAND_REQUEST = 'DIRECTORY_EXPAND_REQUEST'; +export const DIRECTORY_EXPAND_SUCCESS = 'DIRECTORY_EXPAND_SUCCESS'; +export const DIRECTORY_EXPAND_FAIL = 'DIRECTORY_EXPAND_FAIL'; + +export const fetchDirectory = params => (dispatch, getState) => { + dispatch(fetchDirectoryRequest()); + + api(getState).get('/api/v1/directory', { params: { ...params, limit: 20 } }).then(({ data }) => { + dispatch(importFetchedAccounts(data)); + dispatch(fetchDirectorySuccess(data)); + dispatch(fetchRelationships(data.map(x => x.id))); + }).catch(error => dispatch(fetchDirectoryFail(error))); +}; + +export const fetchDirectoryRequest = () => ({ + type: DIRECTORY_FETCH_REQUEST, +}); + +export const fetchDirectorySuccess = accounts => ({ + type: DIRECTORY_FETCH_SUCCESS, + accounts, +}); + +export const fetchDirectoryFail = error => ({ + type: DIRECTORY_FETCH_FAIL, + error, +}); + +export const expandDirectory = params => (dispatch, getState) => { + dispatch(expandDirectoryRequest()); + + const loadedItems = getState().getIn(['user_lists', 'directory', 'items']).size; + + api(getState).get('/api/v1/directory', { params: { ...params, offset: loadedItems, limit: 20 } }).then(({ data }) => { + dispatch(importFetchedAccounts(data)); + dispatch(expandDirectorySuccess(data)); + dispatch(fetchRelationships(data.map(x => x.id))); + }).catch(error => dispatch(expandDirectoryFail(error))); +}; + +export const expandDirectoryRequest = () => ({ + type: DIRECTORY_EXPAND_REQUEST, +}); + +export const expandDirectorySuccess = accounts => ({ + type: DIRECTORY_EXPAND_SUCCESS, + accounts, +}); + +export const expandDirectoryFail = error => ({ + type: DIRECTORY_EXPAND_FAIL, + error, +}); diff --git a/app/javascript/flavours/glitch/actions/polls.js b/app/javascript/flavours/glitch/actions/polls.js index 8e8b82df5..ca94a095f 100644 --- a/app/javascript/flavours/glitch/actions/polls.js +++ b/app/javascript/flavours/glitch/actions/polls.js @@ -1,4 +1,4 @@ -import api from '../api'; +import api from 'flavours/glitch/util/api'; import { importFetchedPoll } from './importer'; export const POLL_VOTE_REQUEST = 'POLL_VOTE_REQUEST'; diff --git a/app/javascript/flavours/glitch/components/poll.js b/app/javascript/flavours/glitch/components/poll.js index 690f9ae5a..36c4b236c 100644 --- a/app/javascript/flavours/glitch/components/poll.js +++ b/app/javascript/flavours/glitch/components/poll.js @@ -4,11 +4,11 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import classNames from 'classnames'; -import { vote, fetchPoll } from 'mastodon/actions/polls'; -import Motion from 'mastodon/features/ui/util/optional_motion'; +import { vote, fetchPoll } from 'flavours/glitch/actions/polls'; +import Motion from 'flavours/glitch/util/optional_motion'; import spring from 'react-motion/lib/spring'; import escapeTextContentForBrowser from 'escape-html'; -import emojify from 'mastodon/features/emoji/emoji'; +import emojify from 'flavours/glitch/util/emoji'; import RelativeTimestamp from './relative_timestamp'; const messages = defineMessages({ diff --git a/app/javascript/flavours/glitch/components/radio_button.js b/app/javascript/flavours/glitch/components/radio_button.js new file mode 100644 index 000000000..0496fa286 --- /dev/null +++ b/app/javascript/flavours/glitch/components/radio_button.js @@ -0,0 +1,35 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +export default class RadioButton extends React.PureComponent { + + static propTypes = { + value: PropTypes.string.isRequired, + checked: PropTypes.bool, + name: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + label: PropTypes.node.isRequired, + }; + + render () { + const { name, value, checked, onChange, label } = this.props; + + return ( + <label className='radio-button'> + <input + name={name} + type='radio' + value={value} + checked={checked} + onChange={onChange} + /> + + <span className={classNames('radio-button__input', { checked })} /> + + <span>{label}</span> + </label> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/account/components/header.js b/app/javascript/flavours/glitch/features/account/components/header.js index b0072533c..2c0ad74db 100644 --- a/app/javascript/flavours/glitch/features/account/components/header.js +++ b/app/javascript/flavours/glitch/features/account/components/header.js @@ -4,6 +4,7 @@ import PropTypes from 'prop-types'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { autoPlayGif, me, isStaff } 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'; import Avatar from 'flavours/glitch/components/avatar'; @@ -69,7 +70,7 @@ class Header extends ImmutablePureComponent { }; openEditProfile = () => { - window.open('/settings/profile', '_blank'); + window.open(profileLink, '_blank'); } _updateEmojis () { @@ -148,7 +149,7 @@ class Header extends ImmutablePureComponent { } else if (account.getIn(['relationship', 'blocking'])) { actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.props.onBlock} />; } - } else { + } else if (profileLink) { actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.edit_profile)} onClick={this.openEditProfile} />; } @@ -172,8 +173,8 @@ class Header extends ImmutablePureComponent { } if (account.get('id') === me) { - menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' }); - menu.push({ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' }); + if (profileLink) menu.push({ text: intl.formatMessage(messages.edit_profile), href: profileLink }); + if (preferencesLink) menu.push({ text: intl.formatMessage(messages.preferences), href: preferencesLink }); menu.push({ text: intl.formatMessage(messages.pins), to: '/pinned' }); menu.push(null); menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' }); @@ -223,9 +224,9 @@ class Header extends ImmutablePureComponent { } } - if (account.get('id') !== me && isStaff) { + if (account.get('id') !== me && isStaff && accountAdminLink) { menu.push(null); - menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: `/admin/accounts/${account.get('id')}` }); + menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: accountAdminLink(account.get('id')) }); } const content = { __html: account.get('note_emojified') }; diff --git a/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js b/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js index 1f714ff83..5c2c1be23 100644 --- a/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js +++ b/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js @@ -1,7 +1,7 @@ import { connect } from 'react-redux'; import SearchResults from '../components/search_results'; -import { fetchSuggestions, dismissSuggestion } from 'mastodon/actions/suggestions'; -import { expandSearch } from 'mastodon/actions/search'; +import { fetchSuggestions, dismissSuggestion } from 'flavours/glitch/actions/suggestions'; +import { expandSearch } from 'flavours/glitch/actions/search'; const mapStateToProps = state => ({ results: state.getIn(['search', 'results']), diff --git a/app/javascript/flavours/glitch/features/compose/containers/warning_container.js b/app/javascript/flavours/glitch/features/compose/containers/warning_container.js index fdd21f114..b9b0a2644 100644 --- a/app/javascript/flavours/glitch/features/compose/containers/warning_container.js +++ b/app/javascript/flavours/glitch/features/compose/containers/warning_container.js @@ -4,6 +4,7 @@ import Warning from '../components/warning'; import PropTypes from 'prop-types'; import { FormattedMessage } from 'react-intl'; import { me } from 'flavours/glitch/util/initial_state'; +import { profileLink, termsLink } from 'flavours/glitch/util/backend_links'; const APPROX_HASHTAG_RE = /(?:^|[^\/\)\w])#(\w*[a-zA-Z·]\w*)/i; @@ -15,7 +16,7 @@ const mapStateToProps = state => ({ const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning }) => { if (needsLockWarning) { - return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <a href='/settings/profile'><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />; + return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <a href={profileLink}><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />; } if (hashtagWarning) { @@ -25,7 +26,7 @@ const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning if (directMessageWarning) { const message = ( <span> - <FormattedMessage id='compose_form.direct_message_warning' defaultMessage='This toot will only be sent to all the mentioned users.' /> <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a> + <FormattedMessage id='compose_form.direct_message_warning' defaultMessage='This toot will only be sent to all the mentioned users.' /> {!!termsLink && <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a>} </span> ); diff --git a/app/javascript/flavours/glitch/features/directory/components/account_card.js b/app/javascript/flavours/glitch/features/directory/components/account_card.js new file mode 100644 index 000000000..5ba263825 --- /dev/null +++ b/app/javascript/flavours/glitch/features/directory/components/account_card.js @@ -0,0 +1,149 @@ +import React from 'react'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { makeGetAccount } from 'flavours/glitch/selectors'; +import Avatar from 'flavours/glitch/components/avatar'; +import DisplayName from 'flavours/glitch/components/display_name'; +import Permalink from 'flavours/glitch/components/permalink'; +import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp'; +import IconButton from 'flavours/glitch/components/icon_button'; +import { FormattedMessage, injectIntl, defineMessages } from 'react-intl'; +import { autoPlayGif, me, unfollowModal } from 'flavours/glitch/util/initial_state'; +import { shortNumberFormat } from 'flavours/glitch/util/numbers'; +import { followAccount, unfollowAccount, blockAccount, unblockAccount, unmuteAccount } from 'flavours/glitch/actions/accounts'; +import { openModal } from 'flavours/glitch/actions/modal'; +import { initMuteModal } from 'flavours/glitch/actions/mutes'; + +const messages = defineMessages({ + follow: { id: 'account.follow', defaultMessage: 'Follow' }, + unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, + requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }, + unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, + unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, +}); + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, { id }) => ({ + account: getAccount(state, id), + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch, { intl }) => ({ + + onFollow (account) { + if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) { + if (unfollowModal) { + dispatch(openModal('CONFIRM', { + message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, + confirm: intl.formatMessage(messages.unfollowConfirm), + onConfirm: () => dispatch(unfollowAccount(account.get('id'))), + })); + } else { + dispatch(unfollowAccount(account.get('id'))); + } + } else { + dispatch(followAccount(account.get('id'))); + } + }, + + onBlock (account) { + if (account.getIn(['relationship', 'blocking'])) { + dispatch(unblockAccount(account.get('id'))); + } else { + dispatch(blockAccount(account.get('id'))); + } + }, + + onMute (account) { + if (account.getIn(['relationship', 'muting'])) { + dispatch(unmuteAccount(account.get('id'))); + } else { + dispatch(initMuteModal(account)); + } + }, + +}); + +export default @injectIntl +@connect(makeMapStateToProps, mapDispatchToProps) +class AccountCard extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + intl: PropTypes.object.isRequired, + onFollow: PropTypes.func.isRequired, + onBlock: PropTypes.func.isRequired, + onMute: PropTypes.func.isRequired, + }; + + handleFollow = () => { + this.props.onFollow(this.props.account); + } + + handleBlock = () => { + this.props.onBlock(this.props.account); + } + + handleMute = () => { + this.props.onMute(this.props.account); + } + + render () { + const { account, intl } = this.props; + + let buttons; + + if (account.get('id') !== me && account.get('relationship', null) !== null) { + const following = account.getIn(['relationship', 'following']); + const requested = account.getIn(['relationship', 'requested']); + const blocking = account.getIn(['relationship', 'blocking']); + const muting = account.getIn(['relationship', 'muting']); + + if (requested) { + buttons = <IconButton disabled icon='hourglass' title={intl.formatMessage(messages.requested)} />; + } else if (blocking) { + buttons = <IconButton active icon='unlock' title={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.handleBlock} />; + } else if (muting) { + buttons = <IconButton active icon='volume-up' title={intl.formatMessage(messages.unmute, { name: account.get('username') })} onClick={this.handleMute} />; + } else if (!account.get('moved') || following) { + buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />; + } + } + + return ( + <div className='directory__card'> + <div className='directory__card__img'> + <img src={autoPlayGif ? account.get('header') : account.get('header_static')} alt='' /> + </div> + + <div className='directory__card__bar'> + <Permalink className='directory__card__bar__name' href={account.get('url')} to={`/accounts/${account.get('id')}`}> + <Avatar account={account} size={48} /> + <DisplayName account={account} /> + </Permalink> + + <div className='directory__card__bar__relationship account__relationship'> + {buttons} + </div> + </div> + + <div className='directory__card__extra'> + <div className='account__header__content' dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }} /> + </div> + + <div className='directory__card__extra'> + <div className='accounts-table__count'>{shortNumberFormat(account.get('statuses_count'))} <small><FormattedMessage id='account.posts' defaultMessage='Toots' /></small></div> + <div className='accounts-table__count'>{account.get('followers_count') < 0 ? '-' : shortNumberFormat(account.get('followers_count'))} <small><FormattedMessage id='account.followers' defaultMessage='Followers' /></small></div> + <div className='accounts-table__count'>{account.get('last_status_at') === null ? <FormattedMessage id='account.never_active' defaultMessage='Never' /> : <RelativeTimestamp timestamp={account.get('last_status_at')} />} <small><FormattedMessage id='account.last_status' defaultMessage='Last active' /></small></div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/directory/index.js b/app/javascript/flavours/glitch/features/directory/index.js new file mode 100644 index 000000000..858a8fa55 --- /dev/null +++ b/app/javascript/flavours/glitch/features/directory/index.js @@ -0,0 +1,171 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { defineMessages, injectIntl } from 'react-intl'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Column from 'flavours/glitch/components/column'; +import ColumnHeader from 'flavours/glitch/components/column_header'; +import { addColumn, removeColumn, moveColumn, changeColumnParams } from 'flavours/glitch/actions/columns'; +import { fetchDirectory, expandDirectory } from 'flavours/glitch/actions/directory'; +import { List as ImmutableList } from 'immutable'; +import AccountCard from './components/account_card'; +import RadioButton from 'flavours/glitch/components/radio_button'; +import classNames from 'classnames'; +import LoadMore from 'flavours/glitch/components/load_more'; +import { ScrollContainer } from 'react-router-scroll-4'; + +const messages = defineMessages({ + title: { id: 'column.directory', defaultMessage: 'Browse profiles' }, + recentlyActive: { id: 'directory.recently_active', defaultMessage: 'Recently active' }, + newArrivals: { id: 'directory.new_arrivals', defaultMessage: 'New arrivals' }, + local: { id: 'directory.local', defaultMessage: 'From {domain} only' }, + federated: { id: 'directory.federated', defaultMessage: 'From known fediverse' }, +}); + +const mapStateToProps = state => ({ + accountIds: state.getIn(['user_lists', 'directory', 'items'], ImmutableList()), + isLoading: state.getIn(['user_lists', 'directory', 'isLoading'], true), + domain: state.getIn(['meta', 'domain']), +}); + +export default @connect(mapStateToProps) +@injectIntl +class Directory extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + isLoading: PropTypes.bool, + accountIds: ImmutablePropTypes.list.isRequired, + dispatch: PropTypes.func.isRequired, + shouldUpdateScroll: PropTypes.func, + columnId: PropTypes.string, + intl: PropTypes.object.isRequired, + multiColumn: PropTypes.bool, + domain: PropTypes.string.isRequired, + params: PropTypes.shape({ + order: PropTypes.string, + local: PropTypes.bool, + }), + }; + + state = { + order: null, + local: null, + }; + + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('DIRECTORY', this.getParams(this.props, this.state))); + } + } + + getParams = (props, state) => ({ + order: state.order === null ? (props.params.order || 'active') : state.order, + local: state.local === null ? (props.params.local || false) : state.local, + }); + + handleMove = dir => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + componentDidMount () { + const { dispatch } = this.props; + dispatch(fetchDirectory(this.getParams(this.props, this.state))); + } + + componentDidUpdate (prevProps, prevState) { + const { dispatch } = this.props; + const paramsOld = this.getParams(prevProps, prevState); + const paramsNew = this.getParams(this.props, this.state); + + if (paramsOld.order !== paramsNew.order || paramsOld.local !== paramsNew.local) { + dispatch(fetchDirectory(paramsNew)); + } + } + + setRef = c => { + this.column = c; + } + + handleChangeOrder = e => { + const { dispatch, columnId } = this.props; + + if (columnId) { + dispatch(changeColumnParams(columnId, ['order'], e.target.value)); + } else { + this.setState({ order: e.target.value }); + } + } + + handleChangeLocal = e => { + const { dispatch, columnId } = this.props; + + if (columnId) { + dispatch(changeColumnParams(columnId, ['local'], e.target.value === '1')); + } else { + this.setState({ local: e.target.value === '1' }); + } + } + + handleLoadMore = () => { + const { dispatch } = this.props; + dispatch(expandDirectory(this.getParams(this.props, this.state))); + } + + render () { + const { isLoading, accountIds, intl, columnId, multiColumn, domain, shouldUpdateScroll } = this.props; + const { order, local } = this.getParams(this.props, this.state); + const pinned = !!columnId; + + const scrollableArea = ( + <div className='scrollable' style={{ background: 'transparent' }}> + <div className='filter-form'> + <div className='filter-form__column' role='group'> + <RadioButton name='order' value='active' label={intl.formatMessage(messages.recentlyActive)} checked={order === 'active'} onChange={this.handleChangeOrder} /> + <RadioButton name='order' value='new' label={intl.formatMessage(messages.newArrivals)} checked={order === 'new'} onChange={this.handleChangeOrder} /> + </div> + + <div className='filter-form__column' role='group'> + <RadioButton name='local' value='1' label={intl.formatMessage(messages.local, { domain })} checked={local} onChange={this.handleChangeLocal} /> + <RadioButton name='local' value='0' label={intl.formatMessage(messages.federated)} checked={!local} onChange={this.handleChangeLocal} /> + </div> + </div> + + <div className={classNames('directory__list', { loading: isLoading })}> + {accountIds.map(accountId => <AccountCard id={accountId} key={accountId} />)} + </div> + + <LoadMore onClick={this.handleLoadMore} visible={!isLoading} /> + </div> + ); + + return ( + <Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}> + <ColumnHeader + icon='address-book-o' + title={intl.formatMessage(messages.title)} + onPin={this.handlePin} + onMove={this.handleMove} + onClick={this.handleHeaderClick} + pinned={pinned} + multiColumn={multiColumn} + /> + + {multiColumn && !pinned ? <ScrollContainer scrollKey='directory' shouldUpdateScroll={shouldUpdateScroll}>{scrollableArea}</ScrollContainer> : scrollableArea} + </Column> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/getting_started/index.js b/app/javascript/flavours/glitch/features/getting_started/index.js index 961c16fbc..0b93d8915 100644 --- a/app/javascript/flavours/glitch/features/getting_started/index.js +++ b/app/javascript/flavours/glitch/features/getting_started/index.js @@ -8,7 +8,7 @@ import { openModal } from 'flavours/glitch/actions/modal'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { me } from 'flavours/glitch/util/initial_state'; +import { me, profile_directory } from 'flavours/glitch/util/initial_state'; import { fetchFollowRequests } from 'flavours/glitch/actions/accounts'; import { List as ImmutableList } from 'immutable'; import { createSelector } from 'reselect'; @@ -36,6 +36,7 @@ const messages = defineMessages({ lists_subheading: { id: 'column_subheading.lists', defaultMessage: 'Lists' }, misc: { id: 'navigation_bar.misc', defaultMessage: 'Misc' }, menu: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, + profile_directory: { id: 'getting_started.directory', defaultMessage: 'Profile directory' }, }); const makeMapStateToProps = () => { @@ -150,13 +151,17 @@ const NAVIGATION_PANEL_BREAKPOINT = 600 + (285 * 2) + (10 * 2); navItems.push(<ColumnLink key='6' icon='user-plus' text={intl.formatMessage(messages.follow_requests)} badge={badgeDisplay(unreadFollowRequests, 40)} to='/follow_requests' />); } - navItems.push(<ColumnLink key='7' icon='ellipsis-h' text={intl.formatMessage(messages.misc)} to='/getting-started-misc' />); + if (profile_directory) { + navItems.push(<ColumnLink key='7' icon='address-book' text={intl.formatMessage(messages.profile_directory)} to='/directory' />); + } + + navItems.push(<ColumnLink key='8' icon='ellipsis-h' text={intl.formatMessage(messages.misc)} to='/getting-started-misc' />); listItems = listItems.concat([ - <div key='8'> - <ColumnLink key='9' icon='bars' text={intl.formatMessage(messages.lists)} to='/lists' /> + <div key='9'> + <ColumnLink key='10' icon='bars' text={intl.formatMessage(messages.lists)} to='/lists' /> {lists.map(list => - <ColumnLink key={(9 + Number(list.get('id'))).toString()} to={`/timelines/list/${list.get('id')}`} icon='list-ul' text={list.get('title')} /> + <ColumnLink key={(11 + Number(list.get('id'))).toString()} to={`/timelines/list/${list.get('id')}`} icon='list-ul' text={list.get('title')} /> )} </div>, ]); diff --git a/app/javascript/flavours/glitch/features/hashtag_timeline/containers/column_settings_container.js b/app/javascript/flavours/glitch/features/hashtag_timeline/containers/column_settings_container.js index 757cd48fb..de1db692d 100644 --- a/app/javascript/flavours/glitch/features/hashtag_timeline/containers/column_settings_container.js +++ b/app/javascript/flavours/glitch/features/hashtag_timeline/containers/column_settings_container.js @@ -20,7 +20,7 @@ const mapDispatchToProps = (dispatch, { columnId }) => ({ }, onLoad (value) { - return api().get('/api/v2/search', { params: { q: value } }).then(response => { + return api().get('/api/v2/search', { params: { q: value, type: 'hashtags' } }).then(response => { return (response.data.hashtags || []).map((tag) => { return { value: tag.name, label: `#${tag.name}` }; }); diff --git a/app/javascript/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js index 40e100fd5..58b8a8cbb 100644 --- a/app/javascript/flavours/glitch/features/status/index.js +++ b/app/javascript/flavours/glitch/features/status/index.js @@ -82,28 +82,38 @@ const makeMapStateToProps = () => { const getDescendantsIds = createSelector([ (_, { id }) => id, state => state.getIn(['contexts', 'replies']), - ], (statusId, contextReplies) => { - let descendantsIds = Immutable.List(); - descendantsIds = descendantsIds.withMutations(mutable => { - const ids = [statusId]; + state => state.get('statuses'), + ], (statusId, contextReplies, statuses) => { + let descendantsIds = []; + const ids = [statusId]; - while (ids.length > 0) { - let id = ids.shift(); - const replies = contextReplies.get(id); + while (ids.length > 0) { + let id = ids.shift(); + const replies = contextReplies.get(id); - if (statusId !== id) { - mutable.push(id); - } + if (statusId !== id) { + descendantsIds.push(id); + } - if (replies) { - replies.reverse().forEach(reply => { - ids.unshift(reply); - }); - } + if (replies) { + replies.reverse().forEach(reply => { + ids.unshift(reply); + }); } - }); + } + + let insertAt = descendantsIds.findIndex((id) => statuses.get(id).get('in_reply_to_account_id') !== statuses.get(id).get('account')); + if (insertAt !== -1) { + descendantsIds.forEach((id, idx) => { + if (idx > insertAt && statuses.get(id).get('in_reply_to_account_id') === statuses.get(id).get('account')) { + descendantsIds.splice(idx, 1); + descendantsIds.splice(insertAt, 0, id); + insertAt += 1; + } + }); + } - return descendantsIds; + return Immutable.List(descendantsIds); }); const mapStateToProps = (state, props) => { 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 30097f064..46df1f4ef 100644 --- a/app/javascript/flavours/glitch/features/ui/components/columns_area.js +++ b/app/javascript/flavours/glitch/features/ui/components/columns_area.js @@ -12,7 +12,19 @@ import BundleContainer from '../containers/bundle_container'; import ColumnLoading from './column_loading'; import DrawerLoading from './drawer_loading'; import BundleColumnError from './bundle_column_error'; -import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, DirectTimeline, FavouritedStatuses, BookmarkedStatuses, ListTimeline } from 'flavours/glitch/util/async-components'; +import { + Compose, + Notifications, + HomeTimeline, + CommunityTimeline, + PublicTimeline, + HashtagTimeline, + DirectTimeline, + FavouritedStatuses, + BookmarkedStatuses, + ListTimeline, + Directory, +} from 'flavours/glitch/util/async-components'; import ComposePanel from './compose_panel'; import NavigationPanel from './navigation_panel'; @@ -30,6 +42,7 @@ const componentMap = { 'FAVOURITES': FavouritedStatuses, 'BOOKMARKS': BookmarkedStatuses, 'LIST': ListTimeline, + 'DIRECTORY': Directory, }; const shouldHideFAB = path => path.match(/^\/statuses\/|^\/search|^\/getting-started/); 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 1712da83e..588e89a6a 100644 --- a/app/javascript/flavours/glitch/features/ui/components/link_footer.js +++ b/app/javascript/flavours/glitch/features/ui/components/link_footer.js @@ -4,7 +4,7 @@ import PropTypes from 'prop-types'; import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; import { Link } from 'react-router-dom'; import { invitesEnabled, version, repository, source_url } from 'flavours/glitch/util/initial_state'; -import { signOutLink } from 'flavours/glitch/util/backend_links'; +import { signOutLink, securityLink } from 'flavours/glitch/util/backend_links'; import { logOut } from 'flavours/glitch/util/log_out'; import { openModal } from 'flavours/glitch/actions/modal'; @@ -46,7 +46,7 @@ class LinkFooter extends React.PureComponent { <div className='getting-started__footer'> <ul> {invitesEnabled && <li><a href='/invites' target='_blank'><FormattedMessage id='getting_started.invite' defaultMessage='Invite people' /></a> · </li>} - <li><a href='/auth/edit'><FormattedMessage id='getting_started.security' defaultMessage='Security' /></a> · </li> + {!!securityLink && <li><a href='/auth/edit'><FormattedMessage id='getting_started.security' defaultMessage='Security' /></a> · </li>} <li><a href='/about/more' target='_blank'><FormattedMessage id='navigation_bar.info' defaultMessage='About this server' /></a> · </li> <li><a href='https://joinmastodon.org/apps' target='_blank'><FormattedMessage id='navigation_bar.apps' defaultMessage='Mobile apps' /></a> · </li> <li><a href='/terms' target='_blank'><FormattedMessage id='getting_started.terms' defaultMessage='Terms of service' /></a> · </li> 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 4688c7766..df02cafd1 100644 --- a/app/javascript/flavours/glitch/features/ui/components/navigation_panel.js +++ b/app/javascript/flavours/glitch/features/ui/components/navigation_panel.js @@ -3,6 +3,7 @@ import { NavLink, withRouter } from 'react-router-dom'; import { FormattedMessage } from 'react-intl'; import Icon from 'flavours/glitch/components/icon'; import { profile_directory } from 'flavours/glitch/util/initial_state'; +import { preferencesLink, relationshipsLink } from 'flavours/glitch/util/backend_links'; import NotificationsCounterIcon from './notifications_counter_icon'; import FollowRequestsNavLink from './follow_requests_nav_link'; import ListPanel from './list_panel'; @@ -16,16 +17,16 @@ const NavigationPanel = ({ onOpenSettings }) => ( <NavLink className='column-link column-link--transparent' exact to='/timelines/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon className='column-link__icon' icon='globe' fixedWidth /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink> <NavLink className='column-link column-link--transparent' to='/timelines/direct'><Icon className='column-link__icon' icon='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' icon='bookmark' fixedWidth /><FormattedMessage id='navigation_bar.bookmarks' defaultMessage='Bookmarks' /></NavLink> + {profile_directory && <NavLink className='column-link column-link--transparent' to='/directory'><Icon className='column-link__icon' icon='address-book-o' fixedWidth /><FormattedMessage id='getting_started.profile_directory' defaultMessage='Profile directory' /></NavLink>} <NavLink className='column-link column-link--transparent' to='/lists'><Icon className='column-link__icon' icon='list-ul' fixedWidth /><FormattedMessage id='navigation_bar.lists' defaultMessage='Lists' /></NavLink> <ListPanel /> <hr /> - <a className='column-link column-link--transparent' href='/settings/preferences' target='_blank'><Icon className='column-link__icon' icon='cog' fixedWidth /><FormattedMessage id='navigation_bar.preferences' defaultMessage='Preferences' /></a> + {!!preferencesLink && <a className='column-link column-link--transparent' href={preferencesLink} target='_blank'><Icon className='column-link__icon' icon='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' icon='cogs' fixedWidth /><FormattedMessage id='navigation_bar.app_settings' defaultMessage='App settings' /></a> - <a className='column-link column-link--transparent' href='/relationships' target='_blank'><Icon className='column-link__icon' icon='users' fixedWidth /><FormattedMessage id='navigation_bar.follows_and_followers' defaultMessage='Follows and followers' /></a> - {!!profile_directory && <a className='column-link column-link--transparent' href='/explore'><Icon className='column-link__icon' id='address-book-o' fixedWidth /><FormattedMessage id='navigation_bar.profile_directory' defaultMessage='Profile directory' /></a>} + {!!relationshipsLink && <a className='column-link column-link--transparent' href={relationshipsLink} target='_blank'><Icon className='column-link__icon' icon='users' fixedWidth /><FormattedMessage id='navigation_bar.follows_and_followers' defaultMessage='Follows and followers' /></a>} </div> ); diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js index 33625581d..1feda0b97 100644 --- a/app/javascript/flavours/glitch/features/ui/index.js +++ b/app/javascript/flavours/glitch/features/ui/index.js @@ -46,6 +46,7 @@ import { Lists, Search, GettingStartedMisc, + Directory, } from 'flavours/glitch/util/async-components'; import { HotKeys } from 'react-hotkeys'; import { me } from 'flavours/glitch/util/initial_state'; @@ -185,6 +186,7 @@ class SwitchingColumnsArea extends React.PureComponent { <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} /> <WrappedRoute path='/search' component={Search} content={children} /> + <WrappedRoute path='/directory' component={Directory} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} /> <WrappedRoute path='/statuses/new' component={Compose} content={children} /> <WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} /> diff --git a/app/javascript/flavours/glitch/reducers/polls.js b/app/javascript/flavours/glitch/reducers/polls.js index 9956cf83f..595f340bc 100644 --- a/app/javascript/flavours/glitch/reducers/polls.js +++ b/app/javascript/flavours/glitch/reducers/polls.js @@ -1,4 +1,4 @@ -import { POLLS_IMPORT } from 'mastodon/actions/importer'; +import { POLLS_IMPORT } from 'flavours/glitch/actions/importer'; import { Map as ImmutableMap, fromJS } from 'immutable'; const importPolls = (state, polls) => state.withMutations(map => polls.forEach(poll => map.set(poll.id, fromJS(poll)))); diff --git a/app/javascript/flavours/glitch/reducers/suggestions.js b/app/javascript/flavours/glitch/reducers/suggestions.js index 834be728f..a08fedc25 100644 --- a/app/javascript/flavours/glitch/reducers/suggestions.js +++ b/app/javascript/flavours/glitch/reducers/suggestions.js @@ -4,8 +4,8 @@ import { SUGGESTIONS_FETCH_FAIL, SUGGESTIONS_DISMISS, } from '../actions/suggestions'; -import { ACCOUNT_BLOCK_SUCCESS, ACCOUNT_MUTE_SUCCESS } from 'mastodon/actions/accounts'; -import { DOMAIN_BLOCK_SUCCESS } from 'mastodon/actions/domain_blocks'; +import { ACCOUNT_BLOCK_SUCCESS, ACCOUNT_MUTE_SUCCESS } from 'flavours/glitch/actions/accounts'; +import { DOMAIN_BLOCK_SUCCESS } from 'flavours/glitch/actions/domain_blocks'; import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; const initialState = ImmutableMap({ diff --git a/app/javascript/flavours/glitch/reducers/user_lists.js b/app/javascript/flavours/glitch/reducers/user_lists.js index a4df9ec8d..b4e1d1eae 100644 --- a/app/javascript/flavours/glitch/reducers/user_lists.js +++ b/app/javascript/flavours/glitch/reducers/user_lists.js @@ -20,6 +20,14 @@ import { MUTES_FETCH_SUCCESS, MUTES_EXPAND_SUCCESS, } from 'flavours/glitch/actions/mutes'; +import { + DIRECTORY_FETCH_REQUEST, + DIRECTORY_FETCH_SUCCESS, + DIRECTORY_FETCH_FAIL, + DIRECTORY_EXPAND_REQUEST, + DIRECTORY_EXPAND_SUCCESS, + DIRECTORY_EXPAND_FAIL, +} from 'flavours/glitch/actions/directory'; import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; const initialState = ImmutableMap({ @@ -74,6 +82,16 @@ export default function userLists(state = initialState, action) { return state.setIn(['mutes', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next); case MUTES_EXPAND_SUCCESS: return state.updateIn(['mutes', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next); + case DIRECTORY_FETCH_SUCCESS: + return state.setIn(['directory', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['directory', 'isLoading'], false); + case DIRECTORY_EXPAND_SUCCESS: + return state.updateIn(['directory', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['directory', 'isLoading'], false); + case DIRECTORY_FETCH_REQUEST: + case DIRECTORY_EXPAND_REQUEST: + return state.setIn(['directory', 'isLoading'], true); + case DIRECTORY_FETCH_FAIL: + case DIRECTORY_EXPAND_FAIL: + return state.setIn(['directory', 'isLoading'], false); default: return state; } diff --git a/app/javascript/flavours/glitch/styles/components/accounts.scss b/app/javascript/flavours/glitch/styles/components/accounts.scss index d2233207d..dc49e083c 100644 --- a/app/javascript/flavours/glitch/styles/components/accounts.scss +++ b/app/javascript/flavours/glitch/styles/components/accounts.scss @@ -415,6 +415,24 @@ } } } + + &.directory__section-headline { + background: darken($ui-base-color, 2%); + border-bottom-color: transparent; + + a, + button { + &.active { + &::before { + display: none; + } + + &::after { + border-color: transparent transparent darken($ui-base-color, 7%); + } + } + } + } } .account__moved-note { diff --git a/app/javascript/flavours/glitch/styles/components/directory.scss b/app/javascript/flavours/glitch/styles/components/directory.scss new file mode 100644 index 000000000..b0ad5a88a --- /dev/null +++ b/app/javascript/flavours/glitch/styles/components/directory.scss @@ -0,0 +1,180 @@ +.directory { + &__list { + width: 100%; + margin: 10px 0; + transition: opacity 100ms ease-in; + + &.loading { + opacity: 0.7; + } + + @media screen and (max-width: $no-gap-breakpoint) { + margin: 0; + } + } + + &__card { + box-sizing: border-box; + margin-bottom: 10px; + + &__img { + height: 125px; + position: relative; + background: darken($ui-base-color, 12%); + overflow: hidden; + + img { + display: block; + width: 100%; + height: 100%; + margin: 0; + object-fit: cover; + } + } + + &__bar { + display: flex; + align-items: center; + background: lighten($ui-base-color, 4%); + padding: 10px; + + &__name { + flex: 1 1 auto; + display: flex; + align-items: center; + text-decoration: none; + overflow: hidden; + } + + &__relationship { + width: 23px; + min-height: 1px; + flex: 0 0 auto; + } + + .avatar { + flex: 0 0 auto; + width: 48px; + height: 48px; + padding-top: 2px; + + img { + width: 100%; + height: 100%; + display: block; + margin: 0; + border-radius: 4px; + background: darken($ui-base-color, 8%); + object-fit: cover; + } + } + + .display-name { + margin-left: 15px; + text-align: left; + + strong { + font-size: 15px; + color: $primary-text-color; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + } + + span { + display: block; + font-size: 14px; + color: $darker-text-color; + font-weight: 400; + overflow: hidden; + text-overflow: ellipsis; + } + } + } + + &__extra { + background: $ui-base-color; + display: flex; + align-items: center; + justify-content: center; + + .accounts-table__count { + width: 33.33%; + flex: 0 0 auto; + padding: 15px 0; + } + + .account__header__content { + box-sizing: border-box; + padding: 15px 10px; + border-bottom: 1px solid lighten($ui-base-color, 8%); + width: 100%; + min-height: 18px + 30px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + p { + display: none; + + &:first-child { + display: inline; + } + } + + br { + display: none; + } + } + } + } +} + +.filter-form { + background: $ui-base-color; + + &__column { + padding: 10px 15px; + } + + .radio-button { + display: block; + } +} + +.radio-button { + font-size: 14px; + position: relative; + display: inline-block; + padding: 6px 0; + line-height: 18px; + cursor: default; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; + + input[type=radio], + input[type=checkbox] { + display: none; + } + + &__input { + display: inline-block; + position: relative; + border: 1px solid $ui-primary-color; + box-sizing: border-box; + width: 18px; + height: 18px; + flex: 0 0 auto; + margin-right: 10px; + top: -1px; + border-radius: 50%; + vertical-align: middle; + + &.checked { + border-color: lighten($ui-highlight-color, 8%); + background: lighten($ui-highlight-color, 8%); + } + } +} diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss index f453a046e..9f59c81ff 100644 --- a/app/javascript/flavours/glitch/styles/components/index.scss +++ b/app/javascript/flavours/glitch/styles/components/index.scss @@ -1467,6 +1467,7 @@ noscript { @import 'composer'; @import 'columns'; @import 'regeneration_indicator'; +@import 'directory'; @import 'search'; @import 'emoji'; @import 'doodle'; diff --git a/app/javascript/flavours/glitch/styles/components/media.scss b/app/javascript/flavours/glitch/styles/components/media.scss index 6dee7725c..85982d938 100644 --- a/app/javascript/flavours/glitch/styles/components/media.scss +++ b/app/javascript/flavours/glitch/styles/components/media.scss @@ -669,38 +669,13 @@ } } -&.detailed, -&.fullscreen { - .video-player__buttons { - button { - padding-top: 10px; - padding-bottom: 10px; + &.detailed, + &.fullscreen { + .video-player__buttons { + button { + padding-top: 10px; + padding-bottom: 10px; + } } } } -} - -.media-spoiler-video { - background-size: cover; - background-repeat: no-repeat; - background-position: center; - cursor: pointer; - margin-top: 8px; - position: relative; - - @include fullwidth-gallery; - - border: 0; - display: block; -} - -.media-spoiler-video-play-icon { - border-radius: 100px; - color: rgba($primary-text-color, 0.8); - font-size: 36px; - left: 50%; - padding: 5px; - position: absolute; - top: 50%; - transform: translate(-50%, -50%); -} diff --git a/app/javascript/flavours/glitch/styles/components/single_column.scss b/app/javascript/flavours/glitch/styles/components/single_column.scss index d22cd4a8b..aeb0abb55 100644 --- a/app/javascript/flavours/glitch/styles/components/single_column.scss +++ b/app/javascript/flavours/glitch/styles/components/single_column.scss @@ -83,6 +83,24 @@ padding: 0; } + .directory__list { + display: grid; + grid-gap: 10px; + grid-template-columns: minmax(0, 50%) minmax(0, 50%); + + @media screen and (max-width: $no-gap-breakpoint) { + display: block; + } + } + + .directory__card { + margin-bottom: 0; + } + + .filter-form { + display: flex; + } + .autosuggest-textarea__textarea { font-size: 16px; } diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss index 2c7c1e8aa..24ab71969 100644 --- a/app/javascript/flavours/glitch/styles/components/status.scss +++ b/app/javascript/flavours/glitch/styles/components/status.scss @@ -886,67 +886,6 @@ a.status-card.compact:hover { background-position: center center; } -.status__video-player { - display: flex; - align-items: center; - background: $base-shadow-color; - box-sizing: border-box; - cursor: default; /* May not be needed */ - margin-top: 8px; - overflow: hidden; - position: relative; - - @include fullwidth-gallery; -} - -.status__video-player-video { - height: 100%; - object-fit: contain; - position: relative; - top: 50%; - transform: translateY(-50%); - width: 100%; - z-index: 1; - - &:not(.letterbox) { - height: 100%; - object-fit: cover; - } -} - -.status__video-player-expand, -.status__video-player-mute { - color: $primary-text-color; - opacity: 0.8; - position: absolute; - right: 4px; - text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color; -} - -.status__video-player-spoiler { - display: none; - color: $primary-text-color; - left: 4px; - position: absolute; - text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color; - top: 4px; - z-index: 100; - - &.status__video-player-spoiler--visible { - display: block; - } -} - -.status__video-player-expand { - bottom: 4px; - z-index: 100; -} - -.status__video-player-mute { - top: 4px; - z-index: 5; -} - .attachment-list { display: flex; font-size: 14px; diff --git a/app/javascript/flavours/glitch/styles/containers.scss b/app/javascript/flavours/glitch/styles/containers.scss index 130e1461c..45eb5a9d0 100644 --- a/app/javascript/flavours/glitch/styles/containers.scss +++ b/app/javascript/flavours/glitch/styles/containers.scss @@ -769,6 +769,24 @@ } } + .directory__list { + display: grid; + grid-gap: 10px; + grid-template-columns: minmax(0, 50%) minmax(0, 50%); + + @media screen and (max-width: $no-gap-breakpoint) { + display: block; + } + + .icon-button { + font-size: 18px; + } + } + + .directory__card { + margin-bottom: 0; + } + .card-grid { display: flex; flex-wrap: wrap; diff --git a/app/javascript/flavours/glitch/util/async-components.js b/app/javascript/flavours/glitch/util/async-components.js index 5050f0ff7..6c0acdb27 100644 --- a/app/javascript/flavours/glitch/util/async-components.js +++ b/app/javascript/flavours/glitch/util/async-components.js @@ -161,3 +161,7 @@ export function Search () { export function Tesseract () { return import(/*webpackChunkName: "tesseract" */'tesseract.js'); } + +export function Directory () { + return import(/* webpackChunkName: "features/glitch/async/directory" */'flavours/glitch/features/directory'); +} diff --git a/app/javascript/flavours/glitch/util/backend_links.js b/app/javascript/flavours/glitch/util/backend_links.js index bc82197be..0fb378cc1 100644 --- a/app/javascript/flavours/glitch/util/backend_links.js +++ b/app/javascript/flavours/glitch/util/backend_links.js @@ -5,3 +5,5 @@ export const termsLink = '/terms'; export const accountAdminLink = (id) => `/admin/accounts/${id}`; export const statusAdminLink = (account_id, status_id) => `/admin/accounts/${account_id}/statuses/${status_id}`; export const filterEditLink = (id) => `/filters/${id}/edit`; +export const relationshipsLink = '/relationships'; +export const securityLink = '/auth/edit'; diff --git a/app/javascript/flavours/glitch/util/initial_state.js b/app/javascript/flavours/glitch/util/initial_state.js index 4b6227cac..a537b0df9 100644 --- a/app/javascript/flavours/glitch/util/initial_state.js +++ b/app/javascript/flavours/glitch/util/initial_state.js @@ -26,6 +26,7 @@ export const pollLimits = (initialState && initialState.poll_limits); export const invitesEnabled = getMeta('invites_enabled'); export const version = getMeta('version'); export const mascot = getMeta('mascot'); +export const profile_directory = getMeta('profile_directory'); export const isStaff = getMeta('is_staff'); export const defaultContentType = getMeta('default_content_type'); export const forceSingleColumn = getMeta('advanced_layout') === false; diff --git a/app/javascript/mastodon/actions/directory.js b/app/javascript/mastodon/actions/directory.js new file mode 100644 index 000000000..4b2b6dd56 --- /dev/null +++ b/app/javascript/mastodon/actions/directory.js @@ -0,0 +1,61 @@ +import api from '../api'; +import { importFetchedAccounts } from './importer'; +import { fetchRelationships } from './accounts'; + +export const DIRECTORY_FETCH_REQUEST = 'DIRECTORY_FETCH_REQUEST'; +export const DIRECTORY_FETCH_SUCCESS = 'DIRECTORY_FETCH_SUCCESS'; +export const DIRECTORY_FETCH_FAIL = 'DIRECTORY_FETCH_FAIL'; + +export const DIRECTORY_EXPAND_REQUEST = 'DIRECTORY_EXPAND_REQUEST'; +export const DIRECTORY_EXPAND_SUCCESS = 'DIRECTORY_EXPAND_SUCCESS'; +export const DIRECTORY_EXPAND_FAIL = 'DIRECTORY_EXPAND_FAIL'; + +export const fetchDirectory = params => (dispatch, getState) => { + dispatch(fetchDirectoryRequest()); + + api(getState).get('/api/v1/directory', { params: { ...params, limit: 20 } }).then(({ data }) => { + dispatch(importFetchedAccounts(data)); + dispatch(fetchDirectorySuccess(data)); + dispatch(fetchRelationships(data.map(x => x.id))); + }).catch(error => dispatch(fetchDirectoryFail(error))); +}; + +export const fetchDirectoryRequest = () => ({ + type: DIRECTORY_FETCH_REQUEST, +}); + +export const fetchDirectorySuccess = accounts => ({ + type: DIRECTORY_FETCH_SUCCESS, + accounts, +}); + +export const fetchDirectoryFail = error => ({ + type: DIRECTORY_FETCH_FAIL, + error, +}); + +export const expandDirectory = params => (dispatch, getState) => { + dispatch(expandDirectoryRequest()); + + const loadedItems = getState().getIn(['user_lists', 'directory', 'items']).size; + + api(getState).get('/api/v1/directory', { params: { ...params, offset: loadedItems, limit: 20 } }).then(({ data }) => { + dispatch(importFetchedAccounts(data)); + dispatch(expandDirectorySuccess(data)); + dispatch(fetchRelationships(data.map(x => x.id))); + }).catch(error => dispatch(expandDirectoryFail(error))); +}; + +export const expandDirectoryRequest = () => ({ + type: DIRECTORY_EXPAND_REQUEST, +}); + +export const expandDirectorySuccess = accounts => ({ + type: DIRECTORY_EXPAND_SUCCESS, + accounts, +}); + +export const expandDirectoryFail = error => ({ + type: DIRECTORY_EXPAND_FAIL, + error, +}); diff --git a/app/javascript/mastodon/components/radio_button.js b/app/javascript/mastodon/components/radio_button.js new file mode 100644 index 000000000..0496fa286 --- /dev/null +++ b/app/javascript/mastodon/components/radio_button.js @@ -0,0 +1,35 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +export default class RadioButton extends React.PureComponent { + + static propTypes = { + value: PropTypes.string.isRequired, + checked: PropTypes.bool, + name: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + label: PropTypes.node.isRequired, + }; + + render () { + const { name, value, checked, onChange, label } = this.props; + + return ( + <label className='radio-button'> + <input + name={name} + type='radio' + value={value} + checked={checked} + onChange={onChange} + /> + + <span className={classNames('radio-button__input', { checked })} /> + + <span>{label}</span> + </label> + ); + } + +} diff --git a/app/javascript/mastodon/features/directory/components/account_card.js b/app/javascript/mastodon/features/directory/components/account_card.js new file mode 100644 index 000000000..cb23a02ba --- /dev/null +++ b/app/javascript/mastodon/features/directory/components/account_card.js @@ -0,0 +1,149 @@ +import React from 'react'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { makeGetAccount } from 'mastodon/selectors'; +import Avatar from 'mastodon/components/avatar'; +import DisplayName from 'mastodon/components/display_name'; +import Permalink from 'mastodon/components/permalink'; +import RelativeTimestamp from 'mastodon/components/relative_timestamp'; +import IconButton from 'mastodon/components/icon_button'; +import { FormattedMessage, injectIntl, defineMessages } from 'react-intl'; +import { autoPlayGif, me, unfollowModal } from 'mastodon/initial_state'; +import { shortNumberFormat } from 'mastodon/utils/numbers'; +import { followAccount, unfollowAccount, blockAccount, unblockAccount, unmuteAccount } from 'mastodon/actions/accounts'; +import { openModal } from 'mastodon/actions/modal'; +import { initMuteModal } from 'mastodon/actions/mutes'; + +const messages = defineMessages({ + follow: { id: 'account.follow', defaultMessage: 'Follow' }, + unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, + requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }, + unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, + unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, +}); + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, { id }) => ({ + account: getAccount(state, id), + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch, { intl }) => ({ + + onFollow (account) { + if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) { + if (unfollowModal) { + dispatch(openModal('CONFIRM', { + message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, + confirm: intl.formatMessage(messages.unfollowConfirm), + onConfirm: () => dispatch(unfollowAccount(account.get('id'))), + })); + } else { + dispatch(unfollowAccount(account.get('id'))); + } + } else { + dispatch(followAccount(account.get('id'))); + } + }, + + onBlock (account) { + if (account.getIn(['relationship', 'blocking'])) { + dispatch(unblockAccount(account.get('id'))); + } else { + dispatch(blockAccount(account.get('id'))); + } + }, + + onMute (account) { + if (account.getIn(['relationship', 'muting'])) { + dispatch(unmuteAccount(account.get('id'))); + } else { + dispatch(initMuteModal(account)); + } + }, + +}); + +export default @injectIntl +@connect(makeMapStateToProps, mapDispatchToProps) +class AccountCard extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + intl: PropTypes.object.isRequired, + onFollow: PropTypes.func.isRequired, + onBlock: PropTypes.func.isRequired, + onMute: PropTypes.func.isRequired, + }; + + handleFollow = () => { + this.props.onFollow(this.props.account); + } + + handleBlock = () => { + this.props.onBlock(this.props.account); + } + + handleMute = () => { + this.props.onMute(this.props.account); + } + + render () { + const { account, intl } = this.props; + + let buttons; + + if (account.get('id') !== me && account.get('relationship', null) !== null) { + const following = account.getIn(['relationship', 'following']); + const requested = account.getIn(['relationship', 'requested']); + const blocking = account.getIn(['relationship', 'blocking']); + const muting = account.getIn(['relationship', 'muting']); + + if (requested) { + buttons = <IconButton disabled icon='hourglass' title={intl.formatMessage(messages.requested)} />; + } else if (blocking) { + buttons = <IconButton active icon='unlock' title={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.handleBlock} />; + } else if (muting) { + buttons = <IconButton active icon='volume-up' title={intl.formatMessage(messages.unmute, { name: account.get('username') })} onClick={this.handleMute} />; + } else if (!account.get('moved') || following) { + buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />; + } + } + + return ( + <div className='directory__card'> + <div className='directory__card__img'> + <img src={autoPlayGif ? account.get('header') : account.get('header_static')} alt='' /> + </div> + + <div className='directory__card__bar'> + <Permalink className='directory__card__bar__name' href={account.get('url')} to={`/accounts/${account.get('id')}`}> + <Avatar account={account} size={48} /> + <DisplayName account={account} /> + </Permalink> + + <div className='directory__card__bar__relationship account__relationship'> + {buttons} + </div> + </div> + + <div className='directory__card__extra'> + <div className='account__header__content' dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }} /> + </div> + + <div className='directory__card__extra'> + <div className='accounts-table__count'>{shortNumberFormat(account.get('statuses_count'))} <small><FormattedMessage id='account.posts' defaultMessage='Toots' /></small></div> + <div className='accounts-table__count'>{shortNumberFormat(account.get('followers_count'))} <small><FormattedMessage id='account.followers' defaultMessage='Followers' /></small></div> + <div className='accounts-table__count'>{account.get('last_status_at') === null ? <FormattedMessage id='account.never_active' defaultMessage='Never' /> : <RelativeTimestamp timestamp={account.get('last_status_at')} />} <small><FormattedMessage id='account.last_status' defaultMessage='Last active' /></small></div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/mastodon/features/directory/index.js b/app/javascript/mastodon/features/directory/index.js new file mode 100644 index 000000000..2f91e759b --- /dev/null +++ b/app/javascript/mastodon/features/directory/index.js @@ -0,0 +1,171 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { defineMessages, injectIntl } from 'react-intl'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Column from 'mastodon/components/column'; +import ColumnHeader from 'mastodon/components/column_header'; +import { addColumn, removeColumn, moveColumn, changeColumnParams } from 'mastodon/actions/columns'; +import { fetchDirectory, expandDirectory } from 'mastodon/actions/directory'; +import { List as ImmutableList } from 'immutable'; +import AccountCard from './components/account_card'; +import RadioButton from 'mastodon/components/radio_button'; +import classNames from 'classnames'; +import LoadMore from 'mastodon/components/load_more'; +import { ScrollContainer } from 'react-router-scroll-4'; + +const messages = defineMessages({ + title: { id: 'column.directory', defaultMessage: 'Browse profiles' }, + recentlyActive: { id: 'directory.recently_active', defaultMessage: 'Recently active' }, + newArrivals: { id: 'directory.new_arrivals', defaultMessage: 'New arrivals' }, + local: { id: 'directory.local', defaultMessage: 'From {domain} only' }, + federated: { id: 'directory.federated', defaultMessage: 'From known fediverse' }, +}); + +const mapStateToProps = state => ({ + accountIds: state.getIn(['user_lists', 'directory', 'items'], ImmutableList()), + isLoading: state.getIn(['user_lists', 'directory', 'isLoading'], true), + domain: state.getIn(['meta', 'domain']), +}); + +export default @connect(mapStateToProps) +@injectIntl +class Directory extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + isLoading: PropTypes.bool, + accountIds: ImmutablePropTypes.list.isRequired, + dispatch: PropTypes.func.isRequired, + shouldUpdateScroll: PropTypes.func, + columnId: PropTypes.string, + intl: PropTypes.object.isRequired, + multiColumn: PropTypes.bool, + domain: PropTypes.string.isRequired, + params: PropTypes.shape({ + order: PropTypes.string, + local: PropTypes.bool, + }), + }; + + state = { + order: null, + local: null, + }; + + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('DIRECTORY', this.getParams(this.props, this.state))); + } + } + + getParams = (props, state) => ({ + order: state.order === null ? (props.params.order || 'active') : state.order, + local: state.local === null ? (props.params.local || false) : state.local, + }); + + handleMove = dir => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + componentDidMount () { + const { dispatch } = this.props; + dispatch(fetchDirectory(this.getParams(this.props, this.state))); + } + + componentDidUpdate (prevProps, prevState) { + const { dispatch } = this.props; + const paramsOld = this.getParams(prevProps, prevState); + const paramsNew = this.getParams(this.props, this.state); + + if (paramsOld.order !== paramsNew.order || paramsOld.local !== paramsNew.local) { + dispatch(fetchDirectory(paramsNew)); + } + } + + setRef = c => { + this.column = c; + } + + handleChangeOrder = e => { + const { dispatch, columnId } = this.props; + + if (columnId) { + dispatch(changeColumnParams(columnId, ['order'], e.target.value)); + } else { + this.setState({ order: e.target.value }); + } + } + + handleChangeLocal = e => { + const { dispatch, columnId } = this.props; + + if (columnId) { + dispatch(changeColumnParams(columnId, ['local'], e.target.value === '1')); + } else { + this.setState({ local: e.target.value === '1' }); + } + } + + handleLoadMore = () => { + const { dispatch } = this.props; + dispatch(expandDirectory(this.getParams(this.props, this.state))); + } + + render () { + const { isLoading, accountIds, intl, columnId, multiColumn, domain, shouldUpdateScroll } = this.props; + const { order, local } = this.getParams(this.props, this.state); + const pinned = !!columnId; + + const scrollableArea = ( + <div className='scrollable' style={{ background: 'transparent' }}> + <div className='filter-form'> + <div className='filter-form__column' role='group'> + <RadioButton name='order' value='active' label={intl.formatMessage(messages.recentlyActive)} checked={order === 'active'} onChange={this.handleChangeOrder} /> + <RadioButton name='order' value='new' label={intl.formatMessage(messages.newArrivals)} checked={order === 'new'} onChange={this.handleChangeOrder} /> + </div> + + <div className='filter-form__column' role='group'> + <RadioButton name='local' value='1' label={intl.formatMessage(messages.local, { domain })} checked={local} onChange={this.handleChangeLocal} /> + <RadioButton name='local' value='0' label={intl.formatMessage(messages.federated)} checked={!local} onChange={this.handleChangeLocal} /> + </div> + </div> + + <div className={classNames('directory__list', { loading: isLoading })}> + {accountIds.map(accountId => <AccountCard id={accountId} key={accountId} />)} + </div> + + <LoadMore onClick={this.handleLoadMore} visible={!isLoading} /> + </div> + ); + + return ( + <Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}> + <ColumnHeader + icon='address-book-o' + title={intl.formatMessage(messages.title)} + onPin={this.handlePin} + onMove={this.handleMove} + onClick={this.handleHeaderClick} + pinned={pinned} + multiColumn={multiColumn} + /> + + {multiColumn && !pinned ? <ScrollContainer scrollKey='directory' shouldUpdateScroll={shouldUpdateScroll}>{scrollableArea}</ScrollContainer> : scrollableArea} + </Column> + ); + } + +} diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js index 6a122a750..f6d90580b 100644 --- a/app/javascript/mastodon/features/getting_started/index.js +++ b/app/javascript/mastodon/features/getting_started/index.js @@ -107,7 +107,7 @@ class GettingStarted extends ImmutablePureComponent { if (profile_directory) { navItems.push( - <ColumnLink key={i++} icon='address-book' text={intl.formatMessage(messages.profile_directory)} href='/explore' /> + <ColumnLink key={i++} icon='address-book' text={intl.formatMessage(messages.profile_directory)} to='/directory' /> ); height += 48; @@ -120,7 +120,7 @@ class GettingStarted extends ImmutablePureComponent { height += 34; } else if (profile_directory) { navItems.push( - <ColumnLink key={i++} icon='address-book' text={intl.formatMessage(messages.profile_directory)} href='/explore' /> + <ColumnLink key={i++} icon='address-book' text={intl.formatMessage(messages.profile_directory)} to='/directory' /> ); height += 48; diff --git a/app/javascript/mastodon/features/hashtag_timeline/containers/column_settings_container.js b/app/javascript/mastodon/features/hashtag_timeline/containers/column_settings_container.js index c5098052c..5914bbeaf 100644 --- a/app/javascript/mastodon/features/hashtag_timeline/containers/column_settings_container.js +++ b/app/javascript/mastodon/features/hashtag_timeline/containers/column_settings_container.js @@ -20,7 +20,7 @@ const mapDispatchToProps = (dispatch, { columnId }) => ({ }, onLoad (value) { - return api().get('/api/v2/search', { params: { q: value } }).then(response => { + return api().get('/api/v2/search', { params: { q: value, type: 'hashtags' } }).then(response => { return (response.data.hashtags || []).map((tag) => { return { value: tag.name, label: `#${tag.name}` }; }); diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js index ad4f75820..f78a9489a 100644 --- a/app/javascript/mastodon/features/status/index.js +++ b/app/javascript/mastodon/features/status/index.js @@ -84,28 +84,38 @@ const makeMapStateToProps = () => { const getDescendantsIds = createSelector([ (_, { id }) => id, state => state.getIn(['contexts', 'replies']), - ], (statusId, contextReplies) => { - let descendantsIds = Immutable.List(); - descendantsIds = descendantsIds.withMutations(mutable => { - const ids = [statusId]; + state => state.get('statuses'), + ], (statusId, contextReplies, statuses) => { + let descendantsIds = []; + const ids = [statusId]; - while (ids.length > 0) { - let id = ids.shift(); - const replies = contextReplies.get(id); + while (ids.length > 0) { + let id = ids.shift(); + const replies = contextReplies.get(id); - if (statusId !== id) { - mutable.push(id); - } + if (statusId !== id) { + descendantsIds.push(id); + } - if (replies) { - replies.reverse().forEach(reply => { - ids.unshift(reply); - }); - } + if (replies) { + replies.reverse().forEach(reply => { + ids.unshift(reply); + }); } - }); + } + + let insertAt = descendantsIds.findIndex((id) => statuses.get(id).get('in_reply_to_account_id') !== statuses.get(id).get('account')); + if (insertAt !== -1) { + descendantsIds.forEach((id, idx) => { + if (idx > insertAt && statuses.get(id).get('in_reply_to_account_id') === statuses.get(id).get('account')) { + descendantsIds.splice(idx, 1); + descendantsIds.splice(insertAt, 0, id); + insertAt += 1; + } + }); + } - return descendantsIds; + return Immutable.List(descendantsIds); }); const mapStateToProps = (state, props) => { diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js index 042e44e43..8a4e89b3d 100644 --- a/app/javascript/mastodon/features/ui/components/columns_area.js +++ b/app/javascript/mastodon/features/ui/components/columns_area.js @@ -12,7 +12,18 @@ import BundleContainer from '../containers/bundle_container'; import ColumnLoading from './column_loading'; import DrawerLoading from './drawer_loading'; import BundleColumnError from './bundle_column_error'; -import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, DirectTimeline, FavouritedStatuses, ListTimeline } from '../../ui/util/async-components'; +import { + Compose, + Notifications, + HomeTimeline, + CommunityTimeline, + PublicTimeline, + HashtagTimeline, + DirectTimeline, + FavouritedStatuses, + ListTimeline, + Directory, +} from '../../ui/util/async-components'; import Icon from 'mastodon/components/icon'; import ComposePanel from './compose_panel'; import NavigationPanel from './navigation_panel'; @@ -30,6 +41,7 @@ const componentMap = { 'DIRECT': DirectTimeline, 'FAVOURITES': FavouritedStatuses, 'LIST': ListTimeline, + 'DIRECTORY': Directory, }; const messages = defineMessages({ diff --git a/app/javascript/mastodon/features/ui/components/navigation_panel.js b/app/javascript/mastodon/features/ui/components/navigation_panel.js index 64a40a9da..6f07778f2 100644 --- a/app/javascript/mastodon/features/ui/components/navigation_panel.js +++ b/app/javascript/mastodon/features/ui/components/navigation_panel.js @@ -18,6 +18,7 @@ const NavigationPanel = () => ( <NavLink className='column-link column-link--transparent' to='/timelines/direct'><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='/favourites'><Icon className='column-link__icon' id='star' fixedWidth /><FormattedMessage id='navigation_bar.favourites' defaultMessage='Favourites' /></NavLink> <NavLink className='column-link column-link--transparent' to='/lists'><Icon className='column-link__icon' id='list-ul' fixedWidth /><FormattedMessage id='navigation_bar.lists' defaultMessage='Lists' /></NavLink> + {profile_directory && <NavLink className='column-link column-link--transparent' to='/directory'><Icon className='column-link__icon' id='address-book-o' fixedWidth /><FormattedMessage id='getting_started.profile_directory' defaultMessage='Profile directory' /></NavLink>} <ListPanel /> @@ -25,7 +26,6 @@ const NavigationPanel = () => ( <a className='column-link column-link--transparent' href='/settings/preferences'><Icon className='column-link__icon' id='cog' fixedWidth /><FormattedMessage id='navigation_bar.preferences' defaultMessage='Preferences' /></a> <a className='column-link column-link--transparent' href='/relationships'><Icon className='column-link__icon' id='users' fixedWidth /><FormattedMessage id='navigation_bar.follows_and_followers' defaultMessage='Follows and followers' /></a> - {!!profile_directory && <a className='column-link column-link--transparent' href='/explore'><Icon className='column-link__icon' id='address-book-o' fixedWidth /><FormattedMessage id='navigation_bar.profile_directory' defaultMessage='Profile directory' /></a>} {showTrends && <div className='flex-spacer' />} {showTrends && <TrendsContainer />} diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index 9d284c221..49c5c8d0e 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -47,6 +47,7 @@ import { PinnedStatuses, Lists, Search, + Directory, } from './util/async-components'; import { me, forceSingleColumn } from '../../initial_state'; import { previewState as previewMediaState } from './components/media_modal'; @@ -188,6 +189,7 @@ class SwitchingColumnsArea extends React.PureComponent { <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} /> <WrappedRoute path='/search' component={Search} content={children} /> + <WrappedRoute path='/directory' component={Directory} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} /> <WrappedRoute path='/statuses/new' component={Compose} content={children} /> <WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} /> diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index a9b95c7b8..0084c1510 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -141,3 +141,7 @@ export function Tesseract () { export function Audio () { return import(/* webpackChunkName: "features/audio" */'../../audio'); } + +export function Directory () { + return import(/* webpackChunkName: "features/directory" */'../../directory'); +} diff --git a/app/javascript/mastodon/reducers/user_lists.js b/app/javascript/mastodon/reducers/user_lists.js index 8db18c5dc..08e94022f 100644 --- a/app/javascript/mastodon/reducers/user_lists.js +++ b/app/javascript/mastodon/reducers/user_lists.js @@ -20,6 +20,14 @@ import { MUTES_FETCH_SUCCESS, MUTES_EXPAND_SUCCESS, } from '../actions/mutes'; +import { + DIRECTORY_FETCH_REQUEST, + DIRECTORY_FETCH_SUCCESS, + DIRECTORY_FETCH_FAIL, + DIRECTORY_EXPAND_REQUEST, + DIRECTORY_EXPAND_SUCCESS, + DIRECTORY_EXPAND_FAIL, +} from 'mastodon/actions/directory'; import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; const initialState = ImmutableMap({ @@ -74,6 +82,16 @@ export default function userLists(state = initialState, action) { return state.setIn(['mutes', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next); case MUTES_EXPAND_SUCCESS: return state.updateIn(['mutes', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next); + case DIRECTORY_FETCH_SUCCESS: + return state.setIn(['directory', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['directory', 'isLoading'], false); + case DIRECTORY_EXPAND_SUCCESS: + return state.updateIn(['directory', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['directory', 'isLoading'], false); + case DIRECTORY_FETCH_REQUEST: + case DIRECTORY_EXPAND_REQUEST: + return state.setIn(['directory', 'isLoading'], true); + case DIRECTORY_FETCH_FAIL: + case DIRECTORY_EXPAND_FAIL: + return state.setIn(['directory', 'isLoading'], false); default: return state; } diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 8aaa068d3..fd2180d6f 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -2092,13 +2092,23 @@ a.account__display-name { padding: 0; } - //.column { - // margin-top: 0; + .directory__list { + display: grid; + grid-gap: 10px; + grid-template-columns: minmax(0, 50%) minmax(0, 50%); - // @media screen and (min-width: $no-gap-breakpoint) { - // margin-top: 10px; - // } - //} + @media screen and (max-width: $no-gap-breakpoint) { + display: block; + } + } + + .directory__card { + margin-bottom: 0; + } + + .filter-form { + display: flex; + } .autosuggest-textarea__textarea { font-size: 16px; @@ -4982,59 +4992,6 @@ a.status-card.compact:hover { } /* End Media Gallery */ -/* Status Video Player */ -.status__video-player { - background: $base-overlay-background; - box-sizing: border-box; - cursor: default; /* May not be needed */ - margin-top: 8px; - overflow: hidden; - position: relative; -} - -.status__video-player-video { - height: 100%; - object-fit: cover; - position: relative; - top: 50%; - transform: translateY(-50%); - width: 100%; - z-index: 1; -} - -.status__video-player-expand, -.status__video-player-mute { - color: $primary-text-color; - opacity: 0.8; - position: absolute; - right: 4px; - text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color; -} - -.status__video-player-spoiler { - display: none; - color: $primary-text-color; - left: 4px; - position: absolute; - text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color; - top: 4px; - z-index: 100; - - &.status__video-player-spoiler--visible { - display: block; - } -} - -.status__video-player-expand { - bottom: 4px; - z-index: 100; -} - -.status__video-player-mute { - top: 4px; - z-index: 5; -} - .detailed, .fullscreen { .video-player__volume__current, @@ -5387,28 +5344,137 @@ a.status-card.compact:hover { } } -.media-spoiler-video { - background-size: cover; - background-repeat: no-repeat; - background-position: center; - cursor: pointer; - margin-top: 8px; - position: relative; - border: 0; - display: block; -} +.directory { + &__list { + width: 100%; + margin: 10px 0; + transition: opacity 100ms ease-in; -.media-spoiler-video-play-icon { - border-radius: 100px; - color: rgba($primary-text-color, 0.8); - font-size: 36px; - left: 50%; - padding: 5px; - position: absolute; - top: 50%; - transform: translate(-50%, -50%); + &.loading { + opacity: 0.7; + } + + @media screen and (max-width: $no-gap-breakpoint) { + margin: 0; + } + } + + &__card { + box-sizing: border-box; + margin-bottom: 10px; + + &__img { + height: 125px; + position: relative; + background: darken($ui-base-color, 12%); + overflow: hidden; + + img { + display: block; + width: 100%; + height: 100%; + margin: 0; + object-fit: cover; + } + } + + &__bar { + display: flex; + align-items: center; + background: lighten($ui-base-color, 4%); + padding: 10px; + + &__name { + flex: 1 1 auto; + display: flex; + align-items: center; + text-decoration: none; + overflow: hidden; + } + + &__relationship { + width: 23px; + min-height: 1px; + flex: 0 0 auto; + } + + .avatar { + flex: 0 0 auto; + width: 48px; + height: 48px; + padding-top: 2px; + + img { + width: 100%; + height: 100%; + display: block; + margin: 0; + border-radius: 4px; + background: darken($ui-base-color, 8%); + object-fit: cover; + } + } + + .display-name { + margin-left: 15px; + text-align: left; + + strong { + font-size: 15px; + color: $primary-text-color; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + } + + span { + display: block; + font-size: 14px; + color: $darker-text-color; + font-weight: 400; + overflow: hidden; + text-overflow: ellipsis; + } + } + } + + &__extra { + background: $ui-base-color; + display: flex; + align-items: center; + justify-content: center; + + .accounts-table__count { + width: 33.33%; + flex: 0 0 auto; + padding: 15px 0; + } + + .account__header__content { + box-sizing: border-box; + padding: 15px 10px; + border-bottom: 1px solid lighten($ui-base-color, 8%); + width: 100%; + min-height: 18px + 30px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + p { + display: none; + + &:first-child { + display: inline; + } + } + + br { + display: none; + } + } + } + } } -/* End Video Player */ .account-gallery__container { display: flex; @@ -5484,6 +5550,73 @@ a.status-card.compact:hover { } } } + + &.directory__section-headline { + background: darken($ui-base-color, 2%); + border-bottom-color: transparent; + + a, + button { + &.active { + &::before { + display: none; + } + + &::after { + border-color: transparent transparent darken($ui-base-color, 7%); + } + } + } + } +} + +.filter-form { + background: $ui-base-color; + + &__column { + padding: 10px 15px; + } + + .radio-button { + display: block; + } +} + +.radio-button { + font-size: 14px; + position: relative; + display: inline-block; + padding: 6px 0; + line-height: 18px; + cursor: default; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; + + input[type=radio], + input[type=checkbox] { + display: none; + } + + &__input { + display: inline-block; + position: relative; + border: 1px solid $ui-primary-color; + box-sizing: border-box; + width: 18px; + height: 18px; + flex: 0 0 auto; + margin-right: 10px; + top: -1px; + border-radius: 50%; + vertical-align: middle; + + &.checked { + border-color: lighten($ui-highlight-color, 8%); + background: lighten($ui-highlight-color, 8%); + } + } } ::-webkit-scrollbar-thumb { diff --git a/app/javascript/styles/mastodon/containers.scss b/app/javascript/styles/mastodon/containers.scss index 2b6794ee2..e769c495b 100644 --- a/app/javascript/styles/mastodon/containers.scss +++ b/app/javascript/styles/mastodon/containers.scss @@ -763,6 +763,24 @@ } } + .directory__list { + display: grid; + grid-gap: 10px; + grid-template-columns: minmax(0, 50%) minmax(0, 50%); + + @media screen and (max-width: $no-gap-breakpoint) { + display: block; + } + + .icon-button { + font-size: 18px; + } + } + + .directory__card { + margin-bottom: 0; + } + .card-grid { display: flex; flex-wrap: wrap; diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb index a1d84de2f..1c58be8c0 100644 --- a/app/lib/activitypub/adapter.rb +++ b/app/lib/activitypub/adapter.rb @@ -20,6 +20,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base focal_point: { 'toot' => 'http://joinmastodon.org/ns#', 'focalPoint' => { '@container' => '@list', '@id' => 'toot:focalPoint' } }, identity_proof: { 'toot' => 'http://joinmastodon.org/ns#', 'IdentityProof' => 'toot:IdentityProof' }, blurhash: { 'toot' => 'http://joinmastodon.org/ns#', 'blurhash' => 'toot:blurhash' }, + discoverable: { 'toot' => 'http://joinmastodon.org/ns#', 'discoverable' => 'toot:discoverable' }, }.freeze def self.default_key_transform diff --git a/app/models/account.rb b/app/models/account.rb index 9d938c55d..918b17430 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -51,7 +51,6 @@ class Account < ApplicationRecord USERNAME_RE = /[a-z0-9_]+([a-z0-9_\.-]+[a-z0-9_]+)?/i MENTION_RE = /(?<=^|[^\/[:word:]])@((#{USERNAME_RE})(?:@[a-z0-9\.\-]+[a-z0-9]+)?)/i - MIN_FOLLOWERS_DISCOVERY = 10 include AccountAssociations include AccountAvatar @@ -104,11 +103,13 @@ class Account < ApplicationRecord scope :matches_display_name, ->(value) { where(arel_table[:display_name].matches("#{value}%")) } scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) } scope :searchable, -> { without_suspended.where(moved_to_account_id: nil) } - scope :discoverable, -> { searchable.without_silenced.where(discoverable: true).joins(:account_stat).where(AccountStat.arel_table[:followers_count].gteq(MIN_FOLLOWERS_DISCOVERY)) } + scope :discoverable, -> { searchable.without_silenced.where(discoverable: true).left_outer_joins(:account_stat) } scope :tagged_with, ->(tag) { joins(:accounts_tags).where(accounts_tags: { tag_id: tag }) } - scope :by_recent_status, -> { order(Arel.sql('(case when account_stats.last_status_at is null then 1 else 0 end) asc, account_stats.last_status_at desc')) } + scope :by_recent_status, -> { order(Arel.sql('(case when account_stats.last_status_at is null then 1 else 0 end) asc, account_stats.last_status_at desc, accounts.id desc')) } scope :popular, -> { order('account_stats.followers_count desc') } scope :by_domain_and_subdomains, ->(domain) { where(domain: domain).or(where(arel_table[:domain].matches('%.' + domain))) } + scope :not_excluded_by_account, ->(account) { where.not(id: account.excluded_from_timeline_account_ids) } + scope :not_domain_blocked_by_account, ->(account) { where(arel_table[:domain].eq(nil).or(arel_table[:domain].not_in(account.excluded_from_timeline_domains))) } delegate :email, :unconfirmed_email, diff --git a/app/models/feed.rb b/app/models/feed.rb index 0e8943ff8..36e0c1e0a 100644 --- a/app/models/feed.rb +++ b/app/models/feed.rb @@ -9,6 +9,11 @@ class Feed end def get(limit, max_id = nil, since_id = nil, min_id = nil) + limit = limit.to_i + max_id = max_id.to_i if max_id.present? + since_id = since_id.to_i if since_id.present? + min_id = min_id.to_i if min_id.present? + from_redis(limit, max_id, since_id, min_id) end diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb index d03751fd3..83d1858aa 100644 --- a/app/models/media_attachment.rb +++ b/app/models/media_attachment.rb @@ -28,12 +28,12 @@ class MediaAttachment < ApplicationRecord IMAGE_FILE_EXTENSIONS = %w(.jpg .jpeg .png .gif).freeze VIDEO_FILE_EXTENSIONS = %w(.webm .mp4 .m4v .mov).freeze - AUDIO_FILE_EXTENSIONS = %w(.ogg .oga .mp3 .wav .flac .opus .aac .m4a .3gp).freeze + AUDIO_FILE_EXTENSIONS = %w(.ogg .oga .mp3 .wav .flac .opus .aac .m4a .3gp .wma).freeze IMAGE_MIME_TYPES = %w(image/jpeg image/png image/gif).freeze VIDEO_MIME_TYPES = %w(video/webm video/mp4 video/quicktime video/ogg).freeze VIDEO_CONVERTIBLE_MIME_TYPES = %w(video/webm video/quicktime).freeze - AUDIO_MIME_TYPES = %w(audio/wave audio/wav audio/x-wav audio/x-pn-wave audio/ogg audio/mpeg audio/mp3 audio/webm audio/flac audio/aac audio/m4a audio/3gpp).freeze + AUDIO_MIME_TYPES = %w(audio/wave audio/wav audio/x-wav audio/x-pn-wave audio/ogg audio/mpeg audio/mp3 audio/webm audio/flac audio/aac audio/m4a audio/x-m4a audio/mp4 audio/3gpp video/x-ms-asf).freeze BLURHASH_OPTIONS = { x_comp: 4, diff --git a/app/models/remote_follow.rb b/app/models/remote_follow.rb index 93df11724..52dd3f67b 100644 --- a/app/models/remote_follow.rb +++ b/app/models/remote_follow.rb @@ -6,7 +6,7 @@ class RemoteFollow attr_accessor :acct, :addressable_template - validates :acct, presence: true + validates :acct, presence: true, domain: { acct: true } def initialize(attrs = {}) @acct = normalize_acct(attrs[:acct]) @@ -21,7 +21,7 @@ class RemoteFollow end def subscribe_address_for(account) - addressable_template.expand(uri: account.local_username_and_domain).to_s + addressable_template.expand(uri: ActivityPub::TagManager.instance.uri_for(account)).to_s end def interact_address_for(status) @@ -44,6 +44,8 @@ class RemoteFollow end [username, domain].compact.join('@') + rescue Addressable::URI::InvalidURIError + value end def fetch_template! diff --git a/app/serializers/activitypub/actor_serializer.rb b/app/serializers/activitypub/actor_serializer.rb index 0bd7aed2e..222e17c99 100644 --- a/app/serializers/activitypub/actor_serializer.rb +++ b/app/serializers/activitypub/actor_serializer.rb @@ -6,12 +6,14 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer context :security context_extensions :manually_approves_followers, :featured, :also_known_as, - :moved_to, :property_value, :hashtag, :emoji, :identity_proof + :moved_to, :property_value, :hashtag, :emoji, :identity_proof, + :discoverable attributes :id, :type, :following, :followers, :inbox, :outbox, :featured, :preferred_username, :name, :summary, - :url, :manually_approves_followers + :url, :manually_approves_followers, + :discoverable has_one :public_key, serializer: ActivityPub::PublicKeySerializer diff --git a/app/serializers/rest/account_serializer.rb b/app/serializers/rest/account_serializer.rb index 3ecce8f0a..63b84a0b9 100644 --- a/app/serializers/rest/account_serializer.rb +++ b/app/serializers/rest/account_serializer.rb @@ -5,7 +5,7 @@ class REST::AccountSerializer < ActiveModel::Serializer attributes :id, :username, :acct, :display_name, :locked, :bot, :created_at, :note, :url, :avatar, :avatar_static, :header, :header_static, - :followers_count, :following_count, :statuses_count + :followers_count, :following_count, :statuses_count, :last_status_at has_one :moved_to_account, key: :moved, serializer: REST::AccountSerializer, if: :moved_and_not_nested? has_many :emojis, serializer: REST::CustomEmojiSerializer diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb index 603e27ed9..cef658e19 100644 --- a/app/services/activitypub/process_account_service.rb +++ b/app/services/activitypub/process_account_service.rb @@ -83,6 +83,7 @@ class ActivityPub::ProcessAccountService < BaseService @account.fields = property_values || {} @account.also_known_as = as_array(@json['alsoKnownAs'] || []).map { |item| value_or_id(item) } @account.actor_type = actor_type + @account.discoverable = @json['discoverable'] || false end def set_fetchable_attributes! diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index b36471339..5d17f111b 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -49,7 +49,13 @@ class PostStatusService < BaseService def preprocess_attributes! if @text.blank? && @options[:spoiler_text].present? @text = '.' - @text = @media.find(&:video?) ? '📹' : '🖼' if @media.size > 0 + if @media.find(&:video?) || @media.find(&:gifv?) + @text = '📹' + elsif @media.find(&:audio?) + @text = '🎵' + elsif @media.find(&:image?) + @text = '🖼' + end end @visibility = @options[:visibility] || @account.user&.setting_default_privacy @visibility = :unlisted if @visibility == :public && @account.silenced? diff --git a/app/validators/domain_validator.rb b/app/validators/domain_validator.rb index ae07f1798..6e4a854ff 100644 --- a/app/validators/domain_validator.rb +++ b/app/validators/domain_validator.rb @@ -4,14 +4,22 @@ class DomainValidator < ActiveModel::EachValidator def validate_each(record, attribute, value) return if value.blank? - record.errors.add(attribute, I18n.t('domain_validator.invalid_domain')) unless compliant?(value) + domain = begin + if options[:acct] + value.split('@').last + else + value + end + end + + record.errors.add(attribute, I18n.t('domain_validator.invalid_domain')) unless compliant?(domain) end private def compliant?(value) Addressable::URI.new.tap { |uri| uri.host = value } - rescue Addressable::URI::InvalidURIError + rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError false end end diff --git a/app/validators/email_mx_validator.rb b/app/validators/email_mx_validator.rb index 96fbedcfc..9b5009966 100644 --- a/app/validators/email_mx_validator.rb +++ b/app/validators/email_mx_validator.rb @@ -14,6 +14,7 @@ class EmailMxValidator < ActiveModel::Validator return true if domain.nil? + domain = TagManager.instance.normalize_domain(domain) hostnames = [] ips = [] @@ -29,6 +30,8 @@ class EmailMxValidator < ActiveModel::Validator end ips.empty? || on_blacklist?(hostnames + ips) + rescue Addressable::URI::InvalidURIError + true end def on_blacklist?(values) diff --git a/app/views/application/_card.html.haml b/app/views/application/_card.html.haml index 00254c40c..8719ce484 100644 --- a/app/views/application/_card.html.haml +++ b/app/views/application/_card.html.haml @@ -9,7 +9,7 @@ = image_tag account.avatar.url, alt: '', width: 48, height: 48, class: 'u-photo' .display-name - %span{id: "default_account_display_name", style: "display:none;"}= account.username + %span{ id: "default_account_display_name", style: "display: none" }= account.username %bdi %strong.emojify.p-name= display_name(account, custom_emojify: true) %span diff --git a/app/views/directories/index.html.haml b/app/views/directories/index.html.haml index 6608a5dcb..811080eb4 100644 --- a/app/views/directories/index.html.haml +++ b/app/views/directories/index.html.haml @@ -14,58 +14,43 @@ %h1= t('directories.explore_mastodon', title: site_title) %p= t('directories.explanation') -.grid - .column-0 - - if @accounts.empty? - = nothing_here - - else - .directory - %table.accounts-table - %tbody - - @accounts.each do |account| - %tr - %td= account_link_to account - %td.accounts-table__count.optional - = number_to_human account.statuses_count, strip_insignificant_zeros: true - %small= t('accounts.posts', count: account.statuses_count).downcase - %td.accounts-table__count.optional - = hide_followers_count?(account) ? '-' : (number_to_human account.followers_count, strip_insignificant_zeros: true) - %small= t('accounts.followers', count: account.followers_count).downcase - %td.accounts-table__count - - if account.last_status_at.present? - %time.time-ago{ datetime: account.last_status_at.iso8601, title: l(account.last_status_at) }= l account.last_status_at - - else - \- - %small= t('accounts.last_active') - - = paginate @accounts - - .column-1 - - if user_signed_in? - .box-widget.notice-widget - - if current_account.discoverable? - - if current_account.followers_count < Account::MIN_FOLLOWERS_DISCOVERY - %p= t('directories.enabled_but_waiting', min_followers: Account::MIN_FOLLOWERS_DISCOVERY) - - else - %p= t('directories.enabled') - - else - %p= t('directories.how_to_enable') - - = link_to settings_profile_path do - = t('settings.edit_profile') - = fa_icon 'chevron-right fw' - - - if @tags.empty? && !user_signed_in? - .nothing-here - - else - - @tags.each do |tag| - .directory__tag{ class: tag.id == @tag&.id ? 'active' : nil } - = link_to explore_hashtag_path(tag) do - %h4 - = fa_icon 'hashtag' - = tag.name - %small= t('directories.people', count: tag.accounts_count) - - .avatar-stack - - tag.cached_sample_accounts.each do |account| - = image_tag current_account&.user&.setting_auto_play_gif ? account.avatar_original_url : account.avatar_static_url, width: 48, height: 48, alt: '', class: 'account__avatar' +- if @accounts.empty? + = nothing_here +- else + .directory__list + - @accounts.each do |account| + .directory__card + .directory__card__img + = image_tag account.header.url, alt: '' + .directory__card__bar + = link_to TagManager.instance.url_for(account), class: 'directory__card__bar__name' do + .avatar + = image_tag account.avatar.url, alt: '', width: 48, height: 48, class: 'u-photo' + + .display-name + %span{ id: "default_account_display_name", style: "display: none" }= account.username + %bdi + %strong.emojify.p-name= display_name(account, custom_emojify: true) + %span= acct(account) + .directory__card__bar__relationship.account__relationship + = minimal_account_action_button(account) + + .directory__card__extra + .account__header__content.emojify= Formatter.instance.simplified_format(account, custom_emojify: true) + + .directory__card__extra + .accounts-table__count + = number_to_human account.statuses_count, strip_insignificant_zeros: true + %small= t('accounts.posts', count: account.statuses_count).downcase + .accounts-table__count + = hide_followers_count?(account) ? '-' : (number_to_human account.followers_count, strip_insignificant_zeros: true) + %small= t('accounts.followers', count: account.followers_count).downcase + .accounts-table__count + - if account.last_status_at.present? + %time.time-ago{ datetime: account.last_status_at.iso8601, title: l(account.last_status_at) }= l account.last_status_at + - else + = t('invites.expires_in_prompt') + + %small= t('accounts.last_active') + + = paginate @accounts diff --git a/app/views/errors/400.html.haml b/app/views/errors/400.html.haml new file mode 100644 index 000000000..11fbdd40c --- /dev/null +++ b/app/views/errors/400.html.haml @@ -0,0 +1,5 @@ +- content_for :page_title do + = t('errors.400') + +- content_for :content do + = t('errors.400') diff --git a/app/views/errors/406.html.haml b/app/views/errors/406.html.haml new file mode 100644 index 000000000..0ef815df3 --- /dev/null +++ b/app/views/errors/406.html.haml @@ -0,0 +1,5 @@ +- content_for :page_title do + = t('errors.406') + +- content_for :content do + = t('errors.406') diff --git a/app/views/errors/503.html.haml b/app/views/errors/503.html.haml new file mode 100644 index 000000000..b0c895aa5 --- /dev/null +++ b/app/views/errors/503.html.haml @@ -0,0 +1,5 @@ +- content_for :page_title do + = t('errors.503') + +- content_for :content do + = t('errors.503') diff --git a/app/views/settings/profiles/show.html.haml b/app/views/settings/profiles/show.html.haml index 9f794ca6b..1f62e07d8 100644 --- a/app/views/settings/profiles/show.html.haml +++ b/app/views/settings/profiles/show.html.haml @@ -28,7 +28,7 @@ - if Setting.profile_directory .fields-group - = f.input :discoverable, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.discoverable_html', min_followers: Account::MIN_FOLLOWERS_DISCOVERY, path: explore_path), recommended: true + = f.input :discoverable, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.discoverable'), recommended: true %hr.spacer/ diff --git a/app/views/user_mailer/warning.html.haml b/app/views/user_mailer/warning.html.haml index 030a57bb4..1105f2062 100644 --- a/app/views/user_mailer/warning.html.haml +++ b/app/views/user_mailer/warning.html.haml @@ -42,11 +42,11 @@ - unless @warning.text.blank? = Formatter.instance.linkify(@warning.text) - - unless @statuses.empty? + - unless @statuses&.empty? %p %strong= t('user_mailer.warning.statuses') -- unless @statuses.empty? +- unless @statuses&.empty? - @statuses.each_with_index do |status, i| = render 'notification_mailer/status', status: status, i: i + 1, highlighted: true diff --git a/app/views/user_mailer/warning.text.erb b/app/views/user_mailer/warning.text.erb index 24c1f86f2..45ad3b64d 100644 --- a/app/views/user_mailer/warning.text.erb +++ b/app/views/user_mailer/warning.text.erb @@ -7,7 +7,7 @@ <% end %> <%= @warning.text %> -<% unless @statuses.empty? %> +<% unless @statuses&.empty? %> <%= t('user_mailer.warning.statuses') %> <% @statuses.each do |status| %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 8e5ee8543..98783da45 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -643,14 +643,8 @@ en: warning_title: Disseminated content availability directories: directory: Profile directory - enabled: You are currently listed in the directory. - enabled_but_waiting: You have opted-in to be listed in the directory, but you do not have the minimum number of followers (%{min_followers}) to be listed yet. explanation: Discover users based on their interests explore_mastodon: Explore %{title} - how_to_enable: You are not currently opted-in to the directory. You can opt-in below. Use hashtags in your bio text to be listed under specific hashtags! - people: - one: "%{count} person" - other: "%{count} people" domain_blocks: blocked_domains: List of limited and blocked domains description: This is the list of servers that %{instance} limits or reject federation with. @@ -671,8 +665,10 @@ en: domain_validator: invalid_domain: is not a valid domain name errors: + '400': The request you submitted was invalid or malformed. '403': You don't have permission to view this page. '404': The page you are looking for isn't here. + '406': This page is not available in the requested format. '410': The page you were looking for doesn't exist here anymore. '422': content: Security verification failed. Are you blocking cookies? @@ -681,6 +677,7 @@ en: '500': content: We're sorry, but something went wrong on our end. title: This page is not correct + '503': The page could not be served due to a temporary server failure. noscript_html: To use the Mastodon web application, please enable JavaScript. Alternatively, try one of the <a href="%{apps_path}">native apps</a> for Mastodon for your platform. existing_username_validator: not_found: could not find a local user with that username diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index 14378b7bd..6c315b0ed 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -16,7 +16,7 @@ en: bot: This account mainly performs automated actions and might not be monitored context: One or multiple contexts where the filter should apply digest: Only sent after a long period of inactivity and only if you have received any personal messages in your absence - discoverable_html: The <a href="%{path}" target="_blank">directory</a> lets people find accounts based on interests and activity. Requires at least %{min_followers} followers + discoverable: The profile directory is another way by which your account can reach a wider audience email: You will be sent a confirmation e-mail fields: You can have up to 4 items displayed as a table on your profile header: PNG, GIF or JPG. At most %{size}. Will be downscaled to %{dimensions}px diff --git a/config/routes.rb b/config/routes.rb index 789b5f502..a7e65b034 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -6,6 +6,8 @@ require 'sidekiq-scheduler/web' Sidekiq::Web.set :session_secret, Rails.application.secrets[:secret_key_base] Rails.application.routes.draw do + root 'home#index' + mount LetterOpenerWeb::Engine, at: 'letter_opener' if Rails.env.development? authenticate :user, lambda { |u| u.admin? } do @@ -336,6 +338,7 @@ Rails.application.routes.draw do end resource :domain_blocks, only: [:show, :create, :destroy] + resource :directory, only: [:show] resources :follow_requests, only: [:index] do member do @@ -440,10 +443,6 @@ Rails.application.routes.draw do get '/about/blocks', to: 'about#blocks' get '/terms', to: 'about#terms' - root 'home#index' - - match '*unmatched_route', - via: :all, - to: 'application#raise_not_found', - format: false + match '/', via: [:post, :put, :patch, :delete], to: 'application#raise_not_found', format: false + match '*unmatched_route', via: :all, to: 'application#raise_not_found', format: false end diff --git a/dist/nginx.conf b/dist/nginx.conf index 7c429bad4..b6591e897 100644 --- a/dist/nginx.conf +++ b/dist/nginx.conf @@ -19,7 +19,7 @@ server { listen [::]:443 ssl http2; server_name example.com; - ssl_protocols TLSv1.2; + ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!MEDIUM:!LOW:!aNULL:!NULL:!SHA; ssl_prefer_server_ciphers on; ssl_session_cache shared:SSL:10m; diff --git a/package.json b/package.json index cba13911f..11dbc57a7 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "mastodon", "license": "AGPL-3.0-or-later", "engines": { - "node": ">=8.12 <12" + "node": ">=8.12 <13" }, "scripts": { "postversion": "git push --tags", diff --git a/spec/controllers/remote_follow_controller_spec.rb b/spec/controllers/remote_follow_controller_spec.rb index 5088c2e65..d79dd2949 100644 --- a/spec/controllers/remote_follow_controller_spec.rb +++ b/spec/controllers/remote_follow_controller_spec.rb @@ -66,9 +66,7 @@ describe RemoteFollowController do end it 'redirects to the remote location' do - address = "http://example.com/follow_me?acct=test_user%40#{Rails.configuration.x.local_domain}" - - expect(response).to redirect_to(address) + expect(response).to redirect_to("http://example.com/follow_me?acct=https%3A%2F%2F#{Rails.configuration.x.local_domain}%2Fusers%2Ftest_user") end end end diff --git a/spec/controllers/settings/two_factor_authentication/confirmations_controller_spec.rb b/spec/controllers/settings/two_factor_authentication/confirmations_controller_spec.rb index 478f24585..2222a7559 100644 --- a/spec/controllers/settings/two_factor_authentication/confirmations_controller_spec.rb +++ b/spec/controllers/settings/two_factor_authentication/confirmations_controller_spec.rb @@ -50,7 +50,8 @@ describe Settings::TwoFactorAuthentication::ConfirmationsController do describe 'when form_two_factor_confirmation parameter is not provided' do it 'raises ActionController::ParameterMissing' do - expect { post :create, params: {} }.to raise_error(ActionController::ParameterMissing) + post :create, params: {} + expect(response).to have_http_status(400) end end diff --git a/spec/controllers/settings/two_factor_authentications_controller_spec.rb b/spec/controllers/settings/two_factor_authentications_controller_spec.rb index 9f27222ad..f7c628756 100644 --- a/spec/controllers/settings/two_factor_authentications_controller_spec.rb +++ b/spec/controllers/settings/two_factor_authentications_controller_spec.rb @@ -112,7 +112,8 @@ describe Settings::TwoFactorAuthenticationsController do end it 'raises ActionController::ParameterMissing if code is missing' do - expect { post :destroy }.to raise_error(ActionController::ParameterMissing) + post :destroy + expect(response).to have_http_status(400) end end diff --git a/spec/models/remote_follow_spec.rb b/spec/models/remote_follow_spec.rb index ed2667b28..5b4c19b5b 100644 --- a/spec/models/remote_follow_spec.rb +++ b/spec/models/remote_follow_spec.rb @@ -61,7 +61,7 @@ RSpec.describe RemoteFollow do subject { remote_follow.subscribe_address_for(account) } it 'returns subscribe address' do - is_expected.to eq 'https://quitter.no/main/ostatussub?profile=alice%40cb6e6126.ngrok.io' + is_expected.to eq 'https://quitter.no/main/ostatussub?profile=https%3A%2F%2Fcb6e6126.ngrok.io%2Fusers%2Falice' end end end |