Merge pull request #1209 from ThibG/glitch-soc/merge-upstream
Merge upstream changes
This commit is contained in:
		
						commit
						147e90f35d
					
				
					 82 changed files with 1670 additions and 346 deletions
				
			
		
							
								
								
									
										2
									
								
								Gemfile
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								Gemfile
									
									
									
									
									
								
							|  | @ -94,7 +94,7 @@ gem 'tzinfo-data', '~> 1.2019' | ||||||
| gem 'webpacker', '~> 4.0' | gem 'webpacker', '~> 4.0' | ||||||
| gem 'webpush' | 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 'json-ld-preloaded', '~> 3.0' | ||||||
| gem 'rdf-normalize', '~> 0.3' | gem 'rdf-normalize', '~> 0.3' | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -7,8 +7,8 @@ GIT | ||||||
| 
 | 
 | ||||||
| GIT | GIT | ||||||
|   remote: https://github.com/ruby-rdf/json-ld.git |   remote: https://github.com/ruby-rdf/json-ld.git | ||||||
|   revision: 345b7a5733308af827e8491d284dbafa9128d7a2 |   revision: e742697a0906e74e8bb777ef98137bc3955d981d | ||||||
|   ref: 345b7a5733308af827e8491d284dbafa9128d7a2 |   ref: e742697a0906e74e8bb777ef98137bc3955d981d | ||||||
|   specs: |   specs: | ||||||
|     json-ld (3.0.2) |     json-ld (3.0.2) | ||||||
|       htmlentities (~> 4.3) |       htmlentities (~> 4.3) | ||||||
|  |  | ||||||
|  | @ -36,6 +36,14 @@ class Api::BaseController < ApplicationController | ||||||
|     render json: { error: 'This action is not allowed' }, status: 403 |     render json: { error: 'This action is not allowed' }, status: 403 | ||||||
|   end |   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) |   def doorkeeper_unauthorized_render_options(error: nil) | ||||||
|     { json: { error: (error.try(:description) || 'Not authorized') } } |     { json: { error: (error.try(:description) || 'Not authorized') } } | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  | @ -29,14 +29,13 @@ class Api::V1::Accounts::StatusesController < Api::BaseController | ||||||
| 
 | 
 | ||||||
|   def account_statuses |   def account_statuses | ||||||
|     statuses = truthy_param?(:pinned) ? pinned_scope : permitted_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!(only_media_scope) if truthy_param?(:only_media) | ||||||
|     statuses.merge!(no_replies_scope) if truthy_param?(:exclude_replies) |     statuses.merge!(no_replies_scope) if truthy_param?(:exclude_replies) | ||||||
|     statuses.merge!(no_reblogs_scope) if truthy_param?(:exclude_reblogs) |     statuses.merge!(no_reblogs_scope) if truthy_param?(:exclude_reblogs) | ||||||
|     statuses.merge!(hashtag_scope)    if params[:tagged].present? |     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 |   end | ||||||
| 
 | 
 | ||||||
|   def permitted_account_statuses |   def permitted_account_statuses | ||||||
|  |  | ||||||
							
								
								
									
										30
									
								
								app/controllers/api/v1/directories_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								app/controllers/api/v1/directories_controller.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -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 | ||||||
|  | @ -22,11 +22,13 @@ class ApplicationController < ActionController::Base | ||||||
|   helper_method :whitelist_mode? |   helper_method :whitelist_mode? | ||||||
| 
 | 
 | ||||||
|   rescue_from ActionController::RoutingError, with: :not_found |   rescue_from ActionController::RoutingError, with: :not_found | ||||||
|   rescue_from ActiveRecord::RecordNotFound, with: :not_found |  | ||||||
|   rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_entity |   rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_entity | ||||||
|   rescue_from ActionController::UnknownFormat, with: :not_acceptable |   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 Mastodon::NotPermittedError, with: :forbidden | ||||||
|   rescue_from HTTP::Error, OpenSSL::SSL::SSLError, with: :internal_server_error |   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 :store_current_location, except: :raise_not_found, unless: :devise_controller? | ||||||
|   before_action :require_functional!, if: :user_signed_in? |   before_action :require_functional!, if: :user_signed_in? | ||||||
|  | @ -166,10 +168,18 @@ class ApplicationController < ActionController::Base | ||||||
|     respond_with_error(406) |     respond_with_error(406) | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  |   def bad_request | ||||||
|  |     respond_with_error(400) | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|   def internal_server_error |   def internal_server_error | ||||||
|     respond_with_error(500) |     respond_with_error(500) | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  |   def service_unavailable | ||||||
|  |     respond_with_error(503) | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|   def single_user_mode? |   def single_user_mode? | ||||||
|     @single_user_mode ||= Rails.configuration.x.single_user_mode && Account.where('id > 0').exists? |     @single_user_mode ||= Rails.configuration.x.single_user_mode && Account.where('id > 0').exists? | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  | @ -7,7 +7,6 @@ class DirectoriesController < ApplicationController | ||||||
|   before_action :require_enabled! |   before_action :require_enabled! | ||||||
|   before_action :set_instance_presenter |   before_action :set_instance_presenter | ||||||
|   before_action :set_tag, only: :show |   before_action :set_tag, only: :show | ||||||
|   before_action :set_tags |  | ||||||
|   before_action :set_accounts |   before_action :set_accounts | ||||||
|   before_action :set_pack |   before_action :set_pack | ||||||
| 
 | 
 | ||||||
|  | @ -33,13 +32,10 @@ class DirectoriesController < ApplicationController | ||||||
|     @tag = Tag.discoverable.find_normalized!(params[:id]) |     @tag = Tag.discoverable.find_normalized!(params[:id]) | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def set_tags |  | ||||||
|     @tags = Tag.discoverable.limit(30).reject { |tag| tag.cached_sample_accounts.empty? } |  | ||||||
|   end |  | ||||||
| 
 |  | ||||||
|   def set_accounts |   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.tagged_with(@tag.id)) if @tag | ||||||
|  |       query.merge!(Account.not_excluded_by_account(current_account)) if current_account | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -30,7 +30,7 @@ class RemoteFollowController < ApplicationController | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def session_params |   def session_params | ||||||
|     { acct: session[:remote_follow] } |     { acct: session[:remote_follow] || current_account&.username } | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def set_pack |   def set_pack | ||||||
|  |  | ||||||
|  | @ -33,7 +33,7 @@ class RemoteInteractionController < ApplicationController | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def session_params |   def session_params | ||||||
|     { acct: session[:remote_follow] } |     { acct: session[:remote_follow] || current_account&.username } | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def set_status |   def set_status | ||||||
|  |  | ||||||
|  | @ -11,7 +11,7 @@ module WellKnown | ||||||
| 
 | 
 | ||||||
|       expires_in 3.days, public: true |       expires_in 3.days, public: true | ||||||
|       render json: @account, serializer: WebfingerSerializer, content_type: 'application/jrd+json' |       render json: @account, serializer: WebfingerSerializer, content_type: 'application/jrd+json' | ||||||
|     rescue ActiveRecord::RecordNotFound |     rescue ActiveRecord::RecordNotFound, ActionController::ParameterMissing | ||||||
|       head 404 |       head 404 | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -34,6 +34,26 @@ module StatusesHelper | ||||||
|     end |     end | ||||||
|   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 |   def svg_logo | ||||||
|     content_tag(:svg, tag(:use, 'xlink:href' => '#mastodon-svg-logo'), 'viewBox' => '0 0 216.4144 232.00976') |     content_tag(:svg, tag(:use, 'xlink:href' => '#mastodon-svg-logo'), 'viewBox' => '0 0 216.4144 232.00976') | ||||||
|   end |   end | ||||||
|  |  | ||||||
							
								
								
									
										61
									
								
								app/javascript/flavours/glitch/actions/directory.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								app/javascript/flavours/glitch/actions/directory.js
									
									
									
									
									
										Normal file
									
								
							|  | @ -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, | ||||||
|  | }); | ||||||
|  | @ -1,4 +1,4 @@ | ||||||
| import api from '../api'; | import api from 'flavours/glitch/util/api'; | ||||||
| import { importFetchedPoll } from './importer'; | import { importFetchedPoll } from './importer'; | ||||||
| 
 | 
 | ||||||
| export const POLL_VOTE_REQUEST = 'POLL_VOTE_REQUEST'; | export const POLL_VOTE_REQUEST = 'POLL_VOTE_REQUEST'; | ||||||
|  |  | ||||||
|  | @ -4,11 +4,11 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||||
| import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | ||||||
| import classNames from 'classnames'; | import classNames from 'classnames'; | ||||||
| import { vote, fetchPoll } from 'mastodon/actions/polls'; | import { vote, fetchPoll } from 'flavours/glitch/actions/polls'; | ||||||
| import Motion from 'mastodon/features/ui/util/optional_motion'; | import Motion from 'flavours/glitch/util/optional_motion'; | ||||||
| import spring from 'react-motion/lib/spring'; | import spring from 'react-motion/lib/spring'; | ||||||
| import escapeTextContentForBrowser from 'escape-html'; | import escapeTextContentForBrowser from 'escape-html'; | ||||||
| import emojify from 'mastodon/features/emoji/emoji'; | import emojify from 'flavours/glitch/util/emoji'; | ||||||
| import RelativeTimestamp from './relative_timestamp'; | import RelativeTimestamp from './relative_timestamp'; | ||||||
| 
 | 
 | ||||||
| const messages = defineMessages({ | const messages = defineMessages({ | ||||||
|  |  | ||||||
							
								
								
									
										35
									
								
								app/javascript/flavours/glitch/components/radio_button.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								app/javascript/flavours/glitch/components/radio_button.js
									
									
									
									
									
										Normal file
									
								
							|  | @ -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> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | @ -4,6 +4,7 @@ import PropTypes from 'prop-types'; | ||||||
| import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | ||||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||||
| import { autoPlayGif, me, isStaff } from 'flavours/glitch/util/initial_state'; | 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 classNames from 'classnames'; | ||||||
| import Icon from 'flavours/glitch/components/icon'; | import Icon from 'flavours/glitch/components/icon'; | ||||||
| import Avatar from 'flavours/glitch/components/avatar'; | import Avatar from 'flavours/glitch/components/avatar'; | ||||||
|  | @ -69,7 +70,7 @@ class Header extends ImmutablePureComponent { | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   openEditProfile = () => { |   openEditProfile = () => { | ||||||
|     window.open('/settings/profile', '_blank'); |     window.open(profileLink, '_blank'); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   _updateEmojis () { |   _updateEmojis () { | ||||||
|  | @ -148,7 +149,7 @@ class Header extends ImmutablePureComponent { | ||||||
|       } else if (account.getIn(['relationship', 'blocking'])) { |       } else if (account.getIn(['relationship', 'blocking'])) { | ||||||
|         actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.props.onBlock} />; |         actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.props.onBlock} />; | ||||||
|       } |       } | ||||||
|     } else { |     } else if (profileLink) { | ||||||
|       actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.edit_profile)} onClick={this.openEditProfile} />; |       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) { |     if (account.get('id') === me) { | ||||||
|       menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' }); |       if (profileLink) menu.push({ text: intl.formatMessage(messages.edit_profile), href: profileLink }); | ||||||
|       menu.push({ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' }); |       if (preferencesLink) menu.push({ text: intl.formatMessage(messages.preferences), href: preferencesLink }); | ||||||
|       menu.push({ text: intl.formatMessage(messages.pins), to: '/pinned' }); |       menu.push({ text: intl.formatMessage(messages.pins), to: '/pinned' }); | ||||||
|       menu.push(null); |       menu.push(null); | ||||||
|       menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' }); |       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(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') }; |     const content          = { __html: account.get('note_emojified') }; | ||||||
|  |  | ||||||
|  | @ -1,7 +1,7 @@ | ||||||
| import { connect } from 'react-redux'; | import { connect } from 'react-redux'; | ||||||
| import SearchResults from '../components/search_results'; | import SearchResults from '../components/search_results'; | ||||||
| import { fetchSuggestions, dismissSuggestion } from 'mastodon/actions/suggestions'; | import { fetchSuggestions, dismissSuggestion } from 'flavours/glitch/actions/suggestions'; | ||||||
| import { expandSearch } from 'mastodon/actions/search'; | import { expandSearch } from 'flavours/glitch/actions/search'; | ||||||
| 
 | 
 | ||||||
| const mapStateToProps = state => ({ | const mapStateToProps = state => ({ | ||||||
|   results: state.getIn(['search', 'results']), |   results: state.getIn(['search', 'results']), | ||||||
|  |  | ||||||
|  | @ -4,6 +4,7 @@ import Warning from '../components/warning'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
| import { FormattedMessage } from 'react-intl'; | import { FormattedMessage } from 'react-intl'; | ||||||
| import { me } from 'flavours/glitch/util/initial_state'; | 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; | const APPROX_HASHTAG_RE = /(?:^|[^\/\)\w])#(\w*[a-zA-Z·]\w*)/i; | ||||||
| 
 | 
 | ||||||
|  | @ -15,7 +16,7 @@ const mapStateToProps = state => ({ | ||||||
| 
 | 
 | ||||||
| const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning }) => { | const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning }) => { | ||||||
|   if (needsLockWarning) { |   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) { |   if (hashtagWarning) { | ||||||
|  | @ -25,7 +26,7 @@ const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning | ||||||
|   if (directMessageWarning) { |   if (directMessageWarning) { | ||||||
|     const message = ( |     const message = ( | ||||||
|       <span> |       <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> |       </span> | ||||||
|     ); |     ); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -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> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  | } | ||||||
							
								
								
									
										171
									
								
								app/javascript/flavours/glitch/features/directory/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										171
									
								
								app/javascript/flavours/glitch/features/directory/index.js
									
									
									
									
									
										Normal file
									
								
							|  | @ -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> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | @ -8,7 +8,7 @@ import { openModal } from 'flavours/glitch/actions/modal'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | 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 { fetchFollowRequests } from 'flavours/glitch/actions/accounts'; | ||||||
| import { List as ImmutableList } from 'immutable'; | import { List as ImmutableList } from 'immutable'; | ||||||
| import { createSelector } from 'reselect'; | import { createSelector } from 'reselect'; | ||||||
|  | @ -36,6 +36,7 @@ const messages = defineMessages({ | ||||||
|   lists_subheading: { id: 'column_subheading.lists', defaultMessage: 'Lists' }, |   lists_subheading: { id: 'column_subheading.lists', defaultMessage: 'Lists' }, | ||||||
|   misc: { id: 'navigation_bar.misc', defaultMessage: 'Misc' }, |   misc: { id: 'navigation_bar.misc', defaultMessage: 'Misc' }, | ||||||
|   menu: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, |   menu: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, | ||||||
|  |   profile_directory: { id: 'getting_started.directory', defaultMessage: 'Profile directory' }, | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| const makeMapStateToProps = () => { | 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='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([ |     listItems = listItems.concat([ | ||||||
|       <div key='8'> |       <div key='9'> | ||||||
|         <ColumnLink key='9' icon='bars' text={intl.formatMessage(messages.lists)} to='/lists' /> |         <ColumnLink key='10' icon='bars' text={intl.formatMessage(messages.lists)} to='/lists' /> | ||||||
|         {lists.map(list => |         {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>, |       </div>, | ||||||
|     ]); |     ]); | ||||||
|  |  | ||||||
|  | @ -20,7 +20,7 @@ const mapDispatchToProps = (dispatch, { columnId }) => ({ | ||||||
|   }, |   }, | ||||||
| 
 | 
 | ||||||
|   onLoad (value) { |   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 (response.data.hashtags || []).map((tag) => { | ||||||
|         return { value: tag.name, label: `#${tag.name}` }; |         return { value: tag.name, label: `#${tag.name}` }; | ||||||
|       }); |       }); | ||||||
|  |  | ||||||
|  | @ -82,9 +82,9 @@ const makeMapStateToProps = () => { | ||||||
|   const getDescendantsIds = createSelector([ |   const getDescendantsIds = createSelector([ | ||||||
|     (_, { id }) => id, |     (_, { id }) => id, | ||||||
|     state => state.getIn(['contexts', 'replies']), |     state => state.getIn(['contexts', 'replies']), | ||||||
|   ], (statusId, contextReplies) => { |     state => state.get('statuses'), | ||||||
|     let descendantsIds = Immutable.List(); |   ], (statusId, contextReplies, statuses) => { | ||||||
|     descendantsIds = descendantsIds.withMutations(mutable => { |     let descendantsIds = []; | ||||||
|     const ids = [statusId]; |     const ids = [statusId]; | ||||||
| 
 | 
 | ||||||
|     while (ids.length > 0) { |     while (ids.length > 0) { | ||||||
|  | @ -92,7 +92,7 @@ const makeMapStateToProps = () => { | ||||||
|       const replies = contextReplies.get(id); |       const replies = contextReplies.get(id); | ||||||
| 
 | 
 | ||||||
|       if (statusId !== id) { |       if (statusId !== id) { | ||||||
|           mutable.push(id); |         descendantsIds.push(id); | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       if (replies) { |       if (replies) { | ||||||
|  | @ -101,9 +101,19 @@ const makeMapStateToProps = () => { | ||||||
|         }); |         }); | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|     }); |  | ||||||
| 
 | 
 | ||||||
|     return descendantsIds; |     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 Immutable.List(descendantsIds); | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   const mapStateToProps = (state, props) => { |   const mapStateToProps = (state, props) => { | ||||||
|  |  | ||||||
|  | @ -12,7 +12,19 @@ import BundleContainer from '../containers/bundle_container'; | ||||||
| import ColumnLoading from './column_loading'; | import ColumnLoading from './column_loading'; | ||||||
| import DrawerLoading from './drawer_loading'; | import DrawerLoading from './drawer_loading'; | ||||||
| import BundleColumnError from './bundle_column_error'; | 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 ComposePanel from './compose_panel'; | ||||||
| import NavigationPanel from './navigation_panel'; | import NavigationPanel from './navigation_panel'; | ||||||
| 
 | 
 | ||||||
|  | @ -30,6 +42,7 @@ const componentMap = { | ||||||
|   'FAVOURITES': FavouritedStatuses, |   'FAVOURITES': FavouritedStatuses, | ||||||
|   'BOOKMARKS': BookmarkedStatuses, |   'BOOKMARKS': BookmarkedStatuses, | ||||||
|   'LIST': ListTimeline, |   'LIST': ListTimeline, | ||||||
|  |   'DIRECTORY': Directory, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| const shouldHideFAB = path => path.match(/^\/statuses\/|^\/search|^\/getting-started/); | const shouldHideFAB = path => path.match(/^\/statuses\/|^\/search|^\/getting-started/); | ||||||
|  |  | ||||||
|  | @ -4,7 +4,7 @@ import PropTypes from 'prop-types'; | ||||||
| import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; | import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; | ||||||
| import { Link } from 'react-router-dom'; | import { Link } from 'react-router-dom'; | ||||||
| import { invitesEnabled, version, repository, source_url } from 'flavours/glitch/util/initial_state'; | 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 { logOut } from 'flavours/glitch/util/log_out'; | ||||||
| import { openModal } from 'flavours/glitch/actions/modal'; | import { openModal } from 'flavours/glitch/actions/modal'; | ||||||
| 
 | 
 | ||||||
|  | @ -46,7 +46,7 @@ class LinkFooter extends React.PureComponent { | ||||||
|       <div className='getting-started__footer'> |       <div className='getting-started__footer'> | ||||||
|         <ul> |         <ul> | ||||||
|           {invitesEnabled && <li><a href='/invites' target='_blank'><FormattedMessage id='getting_started.invite' defaultMessage='Invite people' /></a> · </li>} |           {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='/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='https://joinmastodon.org/apps' target='_blank'><FormattedMessage id='navigation_bar.apps' defaultMessage='Mobile apps' /></a> · </li> | ||||||
|           <li><a href='/terms' target='_blank'><FormattedMessage id='getting_started.terms' defaultMessage='Terms of service' /></a> · </li> |           <li><a href='/terms' target='_blank'><FormattedMessage id='getting_started.terms' defaultMessage='Terms of service' /></a> · </li> | ||||||
|  |  | ||||||
|  | @ -3,6 +3,7 @@ import { NavLink, withRouter } from 'react-router-dom'; | ||||||
| import { FormattedMessage } from 'react-intl'; | import { FormattedMessage } from 'react-intl'; | ||||||
| import Icon from 'flavours/glitch/components/icon'; | import Icon from 'flavours/glitch/components/icon'; | ||||||
| import { profile_directory } from 'flavours/glitch/util/initial_state'; | 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 NotificationsCounterIcon from './notifications_counter_icon'; | ||||||
| import FollowRequestsNavLink from './follow_requests_nav_link'; | import FollowRequestsNavLink from './follow_requests_nav_link'; | ||||||
| import ListPanel from './list_panel'; | import ListPanel from './list_panel'; | ||||||
|  | @ -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' 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='/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> |     <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> |     <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 /> |     <ListPanel /> | ||||||
| 
 | 
 | ||||||
|     <hr /> |     <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='#' 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> |     {!!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>} | ||||||
|     {!!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>} |  | ||||||
|   </div> |   </div> | ||||||
| ); | ); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -46,6 +46,7 @@ import { | ||||||
|   Lists, |   Lists, | ||||||
|   Search, |   Search, | ||||||
|   GettingStartedMisc, |   GettingStartedMisc, | ||||||
|  |   Directory, | ||||||
| } from 'flavours/glitch/util/async-components'; | } from 'flavours/glitch/util/async-components'; | ||||||
| import { HotKeys } from 'react-hotkeys'; | import { HotKeys } from 'react-hotkeys'; | ||||||
| import { me } from 'flavours/glitch/util/initial_state'; | import { me } from 'flavours/glitch/util/initial_state'; | ||||||
|  | @ -185,6 +186,7 @@ class SwitchingColumnsArea extends React.PureComponent { | ||||||
|           <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} /> |           <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} /> | ||||||
| 
 | 
 | ||||||
|           <WrappedRoute path='/search' component={Search} 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/new' component={Compose} content={children} /> | ||||||
|           <WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} /> |           <WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} /> | ||||||
|  |  | ||||||
|  | @ -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'; | import { Map as ImmutableMap, fromJS } from 'immutable'; | ||||||
| 
 | 
 | ||||||
| const importPolls = (state, polls) => state.withMutations(map => polls.forEach(poll => map.set(poll.id, fromJS(poll)))); | const importPolls = (state, polls) => state.withMutations(map => polls.forEach(poll => map.set(poll.id, fromJS(poll)))); | ||||||
|  |  | ||||||
|  | @ -4,8 +4,8 @@ import { | ||||||
|   SUGGESTIONS_FETCH_FAIL, |   SUGGESTIONS_FETCH_FAIL, | ||||||
|   SUGGESTIONS_DISMISS, |   SUGGESTIONS_DISMISS, | ||||||
| } from '../actions/suggestions'; | } from '../actions/suggestions'; | ||||||
| import { ACCOUNT_BLOCK_SUCCESS, ACCOUNT_MUTE_SUCCESS } from 'mastodon/actions/accounts'; | import { ACCOUNT_BLOCK_SUCCESS, ACCOUNT_MUTE_SUCCESS } from 'flavours/glitch/actions/accounts'; | ||||||
| import { DOMAIN_BLOCK_SUCCESS } from 'mastodon/actions/domain_blocks'; | import { DOMAIN_BLOCK_SUCCESS } from 'flavours/glitch/actions/domain_blocks'; | ||||||
| import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; | import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; | ||||||
| 
 | 
 | ||||||
| const initialState = ImmutableMap({ | const initialState = ImmutableMap({ | ||||||
|  |  | ||||||
|  | @ -20,6 +20,14 @@ import { | ||||||
|   MUTES_FETCH_SUCCESS, |   MUTES_FETCH_SUCCESS, | ||||||
|   MUTES_EXPAND_SUCCESS, |   MUTES_EXPAND_SUCCESS, | ||||||
| } from 'flavours/glitch/actions/mutes'; | } 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'; | import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; | ||||||
| 
 | 
 | ||||||
| const initialState = ImmutableMap({ | 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); |     return state.setIn(['mutes', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next); | ||||||
|   case MUTES_EXPAND_SUCCESS: |   case MUTES_EXPAND_SUCCESS: | ||||||
|     return state.updateIn(['mutes', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next); |     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: |   default: | ||||||
|     return state; |     return state; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  | @ -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 { | .account__moved-note { | ||||||
|  |  | ||||||
							
								
								
									
										180
									
								
								app/javascript/flavours/glitch/styles/components/directory.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										180
									
								
								app/javascript/flavours/glitch/styles/components/directory.scss
									
									
									
									
									
										Normal file
									
								
							|  | @ -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%); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | @ -1467,6 +1467,7 @@ noscript { | ||||||
| @import 'composer'; | @import 'composer'; | ||||||
| @import 'columns'; | @import 'columns'; | ||||||
| @import 'regeneration_indicator'; | @import 'regeneration_indicator'; | ||||||
|  | @import 'directory'; | ||||||
| @import 'search'; | @import 'search'; | ||||||
| @import 'emoji'; | @import 'emoji'; | ||||||
| @import 'doodle'; | @import 'doodle'; | ||||||
|  |  | ||||||
|  | @ -669,38 +669,13 @@ | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
| &.detailed, |   &.detailed, | ||||||
| &.fullscreen { |   &.fullscreen { | ||||||
|     .video-player__buttons { |     .video-player__buttons { | ||||||
|       button { |       button { | ||||||
|         padding-top: 10px; |         padding-top: 10px; | ||||||
|         padding-bottom: 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%); |  | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -83,6 +83,24 @@ | ||||||
|     padding: 0; |     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 { |   .autosuggest-textarea__textarea { | ||||||
|     font-size: 16px; |     font-size: 16px; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  | @ -886,67 +886,6 @@ a.status-card.compact:hover { | ||||||
|   background-position: center center; |   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 { | .attachment-list { | ||||||
|   display: flex; |   display: flex; | ||||||
|   font-size: 14px; |   font-size: 14px; | ||||||
|  |  | ||||||
|  | @ -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 { |   .card-grid { | ||||||
|     display: flex; |     display: flex; | ||||||
|     flex-wrap: wrap; |     flex-wrap: wrap; | ||||||
|  |  | ||||||
|  | @ -161,3 +161,7 @@ export function Search () { | ||||||
| export function Tesseract () { | export function Tesseract () { | ||||||
|   return import(/*webpackChunkName: "tesseract" */'tesseract.js'); |   return import(/*webpackChunkName: "tesseract" */'tesseract.js'); | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | export function Directory () { | ||||||
|  |   return import(/* webpackChunkName: "features/glitch/async/directory" */'flavours/glitch/features/directory'); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -5,3 +5,5 @@ export const termsLink = '/terms'; | ||||||
| export const accountAdminLink = (id) => `/admin/accounts/${id}`; | export const accountAdminLink = (id) => `/admin/accounts/${id}`; | ||||||
| export const statusAdminLink = (account_id, status_id) => `/admin/accounts/${account_id}/statuses/${status_id}`; | export const statusAdminLink = (account_id, status_id) => `/admin/accounts/${account_id}/statuses/${status_id}`; | ||||||
| export const filterEditLink = (id) => `/filters/${id}/edit`; | export const filterEditLink = (id) => `/filters/${id}/edit`; | ||||||
|  | export const relationshipsLink = '/relationships'; | ||||||
|  | export const securityLink = '/auth/edit'; | ||||||
|  |  | ||||||
|  | @ -26,6 +26,7 @@ export const pollLimits = (initialState && initialState.poll_limits); | ||||||
| export const invitesEnabled = getMeta('invites_enabled'); | export const invitesEnabled = getMeta('invites_enabled'); | ||||||
| export const version = getMeta('version'); | export const version = getMeta('version'); | ||||||
| export const mascot = getMeta('mascot'); | export const mascot = getMeta('mascot'); | ||||||
|  | export const profile_directory = getMeta('profile_directory'); | ||||||
| export const isStaff = getMeta('is_staff'); | export const isStaff = getMeta('is_staff'); | ||||||
| export const defaultContentType = getMeta('default_content_type'); | export const defaultContentType = getMeta('default_content_type'); | ||||||
| export const forceSingleColumn = getMeta('advanced_layout') === false; | export const forceSingleColumn = getMeta('advanced_layout') === false; | ||||||
|  |  | ||||||
							
								
								
									
										61
									
								
								app/javascript/mastodon/actions/directory.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								app/javascript/mastodon/actions/directory.js
									
									
									
									
									
										Normal file
									
								
							|  | @ -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, | ||||||
|  | }); | ||||||
							
								
								
									
										35
									
								
								app/javascript/mastodon/components/radio_button.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								app/javascript/mastodon/components/radio_button.js
									
									
									
									
									
										Normal file
									
								
							|  | @ -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> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | @ -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> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  | } | ||||||
							
								
								
									
										171
									
								
								app/javascript/mastodon/features/directory/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										171
									
								
								app/javascript/mastodon/features/directory/index.js
									
									
									
									
									
										Normal file
									
								
							|  | @ -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> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | @ -107,7 +107,7 @@ class GettingStarted extends ImmutablePureComponent { | ||||||
| 
 | 
 | ||||||
|       if (profile_directory) { |       if (profile_directory) { | ||||||
|         navItems.push( |         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; |         height += 48; | ||||||
|  | @ -120,7 +120,7 @@ class GettingStarted extends ImmutablePureComponent { | ||||||
|       height += 34; |       height += 34; | ||||||
|     } else if (profile_directory) { |     } else if (profile_directory) { | ||||||
|       navItems.push( |       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; |       height += 48; | ||||||
|  |  | ||||||
|  | @ -20,7 +20,7 @@ const mapDispatchToProps = (dispatch, { columnId }) => ({ | ||||||
|   }, |   }, | ||||||
| 
 | 
 | ||||||
|   onLoad (value) { |   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 (response.data.hashtags || []).map((tag) => { | ||||||
|         return { value: tag.name, label: `#${tag.name}` }; |         return { value: tag.name, label: `#${tag.name}` }; | ||||||
|       }); |       }); | ||||||
|  |  | ||||||
|  | @ -84,9 +84,9 @@ const makeMapStateToProps = () => { | ||||||
|   const getDescendantsIds = createSelector([ |   const getDescendantsIds = createSelector([ | ||||||
|     (_, { id }) => id, |     (_, { id }) => id, | ||||||
|     state => state.getIn(['contexts', 'replies']), |     state => state.getIn(['contexts', 'replies']), | ||||||
|   ], (statusId, contextReplies) => { |     state => state.get('statuses'), | ||||||
|     let descendantsIds = Immutable.List(); |   ], (statusId, contextReplies, statuses) => { | ||||||
|     descendantsIds = descendantsIds.withMutations(mutable => { |     let descendantsIds = []; | ||||||
|     const ids = [statusId]; |     const ids = [statusId]; | ||||||
| 
 | 
 | ||||||
|     while (ids.length > 0) { |     while (ids.length > 0) { | ||||||
|  | @ -94,7 +94,7 @@ const makeMapStateToProps = () => { | ||||||
|       const replies = contextReplies.get(id); |       const replies = contextReplies.get(id); | ||||||
| 
 | 
 | ||||||
|       if (statusId !== id) { |       if (statusId !== id) { | ||||||
|           mutable.push(id); |         descendantsIds.push(id); | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       if (replies) { |       if (replies) { | ||||||
|  | @ -103,9 +103,19 @@ const makeMapStateToProps = () => { | ||||||
|         }); |         }); | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|     }); |  | ||||||
| 
 | 
 | ||||||
|     return descendantsIds; |     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 Immutable.List(descendantsIds); | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   const mapStateToProps = (state, props) => { |   const mapStateToProps = (state, props) => { | ||||||
|  |  | ||||||
|  | @ -12,7 +12,18 @@ import BundleContainer from '../containers/bundle_container'; | ||||||
| import ColumnLoading from './column_loading'; | import ColumnLoading from './column_loading'; | ||||||
| import DrawerLoading from './drawer_loading'; | import DrawerLoading from './drawer_loading'; | ||||||
| import BundleColumnError from './bundle_column_error'; | 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 Icon from 'mastodon/components/icon'; | ||||||
| import ComposePanel from './compose_panel'; | import ComposePanel from './compose_panel'; | ||||||
| import NavigationPanel from './navigation_panel'; | import NavigationPanel from './navigation_panel'; | ||||||
|  | @ -30,6 +41,7 @@ const componentMap = { | ||||||
|   'DIRECT': DirectTimeline, |   'DIRECT': DirectTimeline, | ||||||
|   'FAVOURITES': FavouritedStatuses, |   'FAVOURITES': FavouritedStatuses, | ||||||
|   'LIST': ListTimeline, |   'LIST': ListTimeline, | ||||||
|  |   'DIRECTORY': Directory, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| const messages = defineMessages({ | const messages = defineMessages({ | ||||||
|  |  | ||||||
|  | @ -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='/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='/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> |     <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 /> |     <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='/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> |     <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 && <div className='flex-spacer' />} | ||||||
|     {showTrends && <TrendsContainer />} |     {showTrends && <TrendsContainer />} | ||||||
|  |  | ||||||
|  | @ -47,6 +47,7 @@ import { | ||||||
|   PinnedStatuses, |   PinnedStatuses, | ||||||
|   Lists, |   Lists, | ||||||
|   Search, |   Search, | ||||||
|  |   Directory, | ||||||
| } from './util/async-components'; | } from './util/async-components'; | ||||||
| import { me, forceSingleColumn } from '../../initial_state'; | import { me, forceSingleColumn } from '../../initial_state'; | ||||||
| import { previewState as previewMediaState } from './components/media_modal'; | 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='/pinned' component={PinnedStatuses} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} /> | ||||||
| 
 | 
 | ||||||
|           <WrappedRoute path='/search' component={Search} 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/new' component={Compose} content={children} /> | ||||||
|           <WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} /> |           <WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} /> | ||||||
|  |  | ||||||
|  | @ -141,3 +141,7 @@ export function Tesseract () { | ||||||
| export function Audio () { | export function Audio () { | ||||||
|   return import(/* webpackChunkName: "features/audio" */'../../audio'); |   return import(/* webpackChunkName: "features/audio" */'../../audio'); | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | export function Directory () { | ||||||
|  |   return import(/* webpackChunkName: "features/directory" */'../../directory'); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -20,6 +20,14 @@ import { | ||||||
|   MUTES_FETCH_SUCCESS, |   MUTES_FETCH_SUCCESS, | ||||||
|   MUTES_EXPAND_SUCCESS, |   MUTES_EXPAND_SUCCESS, | ||||||
| } from '../actions/mutes'; | } 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'; | import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; | ||||||
| 
 | 
 | ||||||
| const initialState = ImmutableMap({ | 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); |     return state.setIn(['mutes', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next); | ||||||
|   case MUTES_EXPAND_SUCCESS: |   case MUTES_EXPAND_SUCCESS: | ||||||
|     return state.updateIn(['mutes', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next); |     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: |   default: | ||||||
|     return state; |     return state; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  | @ -2092,13 +2092,23 @@ a.account__display-name { | ||||||
|     padding: 0; |     padding: 0; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   //.column { |   .directory__list { | ||||||
|   //  margin-top: 0; |     display: grid; | ||||||
|  |     grid-gap: 10px; | ||||||
|  |     grid-template-columns: minmax(0, 50%) minmax(0, 50%); | ||||||
| 
 | 
 | ||||||
|   //  @media screen and (min-width: $no-gap-breakpoint) { |     @media screen and (max-width: $no-gap-breakpoint) { | ||||||
|   //    margin-top: 10px; |       display: block; | ||||||
|   //  } |     } | ||||||
|   //} |   } | ||||||
|  | 
 | ||||||
|  |   .directory__card { | ||||||
|  |     margin-bottom: 0; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   .filter-form { | ||||||
|  |     display: flex; | ||||||
|  |   } | ||||||
| 
 | 
 | ||||||
|   .autosuggest-textarea__textarea { |   .autosuggest-textarea__textarea { | ||||||
|     font-size: 16px; |     font-size: 16px; | ||||||
|  | @ -4982,59 +4992,6 @@ a.status-card.compact:hover { | ||||||
| } | } | ||||||
| /* End Media Gallery */ | /* 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, | .detailed, | ||||||
| .fullscreen { | .fullscreen { | ||||||
|   .video-player__volume__current, |   .video-player__volume__current, | ||||||
|  | @ -5387,28 +5344,137 @@ a.status-card.compact:hover { | ||||||
|   } |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .media-spoiler-video { | .directory { | ||||||
|   background-size: cover; |   &__list { | ||||||
|   background-repeat: no-repeat; |     width: 100%; | ||||||
|   background-position: center; |     margin: 10px 0; | ||||||
|   cursor: pointer; |     transition: opacity 100ms ease-in; | ||||||
|   margin-top: 8px; |  | ||||||
|   position: relative; |  | ||||||
|   border: 0; |  | ||||||
|   display: block; |  | ||||||
| } |  | ||||||
| 
 | 
 | ||||||
| .media-spoiler-video-play-icon { |     &.loading { | ||||||
|   border-radius: 100px; |       opacity: 0.7; | ||||||
|   color: rgba($primary-text-color, 0.8); |     } | ||||||
|   font-size: 36px; | 
 | ||||||
|   left: 50%; |     @media screen and (max-width: $no-gap-breakpoint) { | ||||||
|   padding: 5px; |       margin: 0; | ||||||
|   position: absolute; |     } | ||||||
|   top: 50%; |   } | ||||||
|   transform: translate(-50%, -50%); | 
 | ||||||
|  |   &__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 { | .account-gallery__container { | ||||||
|   display: flex; |   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 { | ::-webkit-scrollbar-thumb { | ||||||
|  |  | ||||||
|  | @ -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 { |   .card-grid { | ||||||
|     display: flex; |     display: flex; | ||||||
|     flex-wrap: wrap; |     flex-wrap: wrap; | ||||||
|  |  | ||||||
|  | @ -20,6 +20,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base | ||||||
|     focal_point: { 'toot' => 'http://joinmastodon.org/ns#', 'focalPoint' => { '@container' => '@list', '@id' => 'toot:focalPoint' } }, |     focal_point: { 'toot' => 'http://joinmastodon.org/ns#', 'focalPoint' => { '@container' => '@list', '@id' => 'toot:focalPoint' } }, | ||||||
|     identity_proof: { 'toot' => 'http://joinmastodon.org/ns#', 'IdentityProof' => 'toot:IdentityProof' }, |     identity_proof: { 'toot' => 'http://joinmastodon.org/ns#', 'IdentityProof' => 'toot:IdentityProof' }, | ||||||
|     blurhash: { 'toot' => 'http://joinmastodon.org/ns#', 'blurhash' => 'toot:blurhash' }, |     blurhash: { 'toot' => 'http://joinmastodon.org/ns#', 'blurhash' => 'toot:blurhash' }, | ||||||
|  |     discoverable: { 'toot' => 'http://joinmastodon.org/ns#', 'discoverable' => 'toot:discoverable' }, | ||||||
|   }.freeze |   }.freeze | ||||||
| 
 | 
 | ||||||
|   def self.default_key_transform |   def self.default_key_transform | ||||||
|  |  | ||||||
|  | @ -51,7 +51,6 @@ | ||||||
| class Account < ApplicationRecord | class Account < ApplicationRecord | ||||||
|   USERNAME_RE = /[a-z0-9_]+([a-z0-9_\.-]+[a-z0-9_]+)?/i |   USERNAME_RE = /[a-z0-9_]+([a-z0-9_\.-]+[a-z0-9_]+)?/i | ||||||
|   MENTION_RE  = /(?<=^|[^\/[:word:]])@((#{USERNAME_RE})(?:@[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 AccountAssociations | ||||||
|   include AccountAvatar |   include AccountAvatar | ||||||
|  | @ -104,11 +103,13 @@ class Account < ApplicationRecord | ||||||
|   scope :matches_display_name, ->(value) { where(arel_table[:display_name].matches("#{value}%")) } |   scope :matches_display_name, ->(value) { where(arel_table[:display_name].matches("#{value}%")) } | ||||||
|   scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) } |   scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) } | ||||||
|   scope :searchable, -> { without_suspended.where(moved_to_account_id: nil) } |   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 :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 :popular, -> { order('account_stats.followers_count desc') } | ||||||
|   scope :by_domain_and_subdomains, ->(domain) { where(domain: domain).or(where(arel_table[:domain].matches('%.' + domain))) } |   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, |   delegate :email, | ||||||
|            :unconfirmed_email, |            :unconfirmed_email, | ||||||
|  |  | ||||||
|  | @ -9,6 +9,11 @@ class Feed | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def get(limit, max_id = nil, since_id = nil, min_id = nil) |   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) |     from_redis(limit, max_id, since_id, min_id) | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -28,12 +28,12 @@ class MediaAttachment < ApplicationRecord | ||||||
| 
 | 
 | ||||||
|   IMAGE_FILE_EXTENSIONS = %w(.jpg .jpeg .png .gif).freeze |   IMAGE_FILE_EXTENSIONS = %w(.jpg .jpeg .png .gif).freeze | ||||||
|   VIDEO_FILE_EXTENSIONS = %w(.webm .mp4 .m4v .mov).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 |   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_MIME_TYPES             = %w(video/webm video/mp4 video/quicktime video/ogg).freeze | ||||||
|   VIDEO_CONVERTIBLE_MIME_TYPES = %w(video/webm video/quicktime).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 = { |   BLURHASH_OPTIONS = { | ||||||
|     x_comp: 4, |     x_comp: 4, | ||||||
|  |  | ||||||
|  | @ -6,7 +6,7 @@ class RemoteFollow | ||||||
| 
 | 
 | ||||||
|   attr_accessor :acct, :addressable_template |   attr_accessor :acct, :addressable_template | ||||||
| 
 | 
 | ||||||
|   validates :acct, presence: true |   validates :acct, presence: true, domain: { acct: true } | ||||||
| 
 | 
 | ||||||
|   def initialize(attrs = {}) |   def initialize(attrs = {}) | ||||||
|     @acct = normalize_acct(attrs[:acct]) |     @acct = normalize_acct(attrs[:acct]) | ||||||
|  | @ -21,7 +21,7 @@ class RemoteFollow | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def subscribe_address_for(account) |   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 |   end | ||||||
| 
 | 
 | ||||||
|   def interact_address_for(status) |   def interact_address_for(status) | ||||||
|  | @ -44,6 +44,8 @@ class RemoteFollow | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     [username, domain].compact.join('@') |     [username, domain].compact.join('@') | ||||||
|  |   rescue Addressable::URI::InvalidURIError | ||||||
|  |     value | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def fetch_template! |   def fetch_template! | ||||||
|  |  | ||||||
|  | @ -6,12 +6,14 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer | ||||||
|   context :security |   context :security | ||||||
| 
 | 
 | ||||||
|   context_extensions :manually_approves_followers, :featured, :also_known_as, |   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, |   attributes :id, :type, :following, :followers, | ||||||
|              :inbox, :outbox, :featured, |              :inbox, :outbox, :featured, | ||||||
|              :preferred_username, :name, :summary, |              :preferred_username, :name, :summary, | ||||||
|              :url, :manually_approves_followers |              :url, :manually_approves_followers, | ||||||
|  |              :discoverable | ||||||
| 
 | 
 | ||||||
|   has_one :public_key, serializer: ActivityPub::PublicKeySerializer |   has_one :public_key, serializer: ActivityPub::PublicKeySerializer | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -5,7 +5,7 @@ class REST::AccountSerializer < ActiveModel::Serializer | ||||||
| 
 | 
 | ||||||
|   attributes :id, :username, :acct, :display_name, :locked, :bot, :created_at, |   attributes :id, :username, :acct, :display_name, :locked, :bot, :created_at, | ||||||
|              :note, :url, :avatar, :avatar_static, :header, :header_static, |              :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_one :moved_to_account, key: :moved, serializer: REST::AccountSerializer, if: :moved_and_not_nested? | ||||||
|   has_many :emojis, serializer: REST::CustomEmojiSerializer |   has_many :emojis, serializer: REST::CustomEmojiSerializer | ||||||
|  |  | ||||||
|  | @ -83,6 +83,7 @@ class ActivityPub::ProcessAccountService < BaseService | ||||||
|     @account.fields                  = property_values || {} |     @account.fields                  = property_values || {} | ||||||
|     @account.also_known_as           = as_array(@json['alsoKnownAs'] || []).map { |item| value_or_id(item) } |     @account.also_known_as           = as_array(@json['alsoKnownAs'] || []).map { |item| value_or_id(item) } | ||||||
|     @account.actor_type              = actor_type |     @account.actor_type              = actor_type | ||||||
|  |     @account.discoverable            = @json['discoverable'] || false | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def set_fetchable_attributes! |   def set_fetchable_attributes! | ||||||
|  |  | ||||||
|  | @ -49,7 +49,13 @@ class PostStatusService < BaseService | ||||||
|   def preprocess_attributes! |   def preprocess_attributes! | ||||||
|     if @text.blank? && @options[:spoiler_text].present? |     if @text.blank? && @options[:spoiler_text].present? | ||||||
|      @text = '.' |      @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 |     end | ||||||
|     @visibility   = @options[:visibility] || @account.user&.setting_default_privacy |     @visibility   = @options[:visibility] || @account.user&.setting_default_privacy | ||||||
|     @visibility   = :unlisted if @visibility == :public && @account.silenced? |     @visibility   = :unlisted if @visibility == :public && @account.silenced? | ||||||
|  |  | ||||||
|  | @ -4,14 +4,22 @@ class DomainValidator < ActiveModel::EachValidator | ||||||
|   def validate_each(record, attribute, value) |   def validate_each(record, attribute, value) | ||||||
|     return if value.blank? |     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 |   end | ||||||
| 
 | 
 | ||||||
|   private |   private | ||||||
| 
 | 
 | ||||||
|   def compliant?(value) |   def compliant?(value) | ||||||
|     Addressable::URI.new.tap { |uri| uri.host = value } |     Addressable::URI.new.tap { |uri| uri.host = value } | ||||||
|   rescue Addressable::URI::InvalidURIError |   rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError | ||||||
|     false |     false | ||||||
|   end |   end | ||||||
| end | end | ||||||
|  |  | ||||||
|  | @ -14,6 +14,7 @@ class EmailMxValidator < ActiveModel::Validator | ||||||
| 
 | 
 | ||||||
|     return true if domain.nil? |     return true if domain.nil? | ||||||
| 
 | 
 | ||||||
|  |     domain    = TagManager.instance.normalize_domain(domain) | ||||||
|     hostnames = [] |     hostnames = [] | ||||||
|     ips       = [] |     ips       = [] | ||||||
| 
 | 
 | ||||||
|  | @ -29,6 +30,8 @@ class EmailMxValidator < ActiveModel::Validator | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     ips.empty? || on_blacklist?(hostnames + ips) |     ips.empty? || on_blacklist?(hostnames + ips) | ||||||
|  |   rescue Addressable::URI::InvalidURIError | ||||||
|  |     true | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def on_blacklist?(values) |   def on_blacklist?(values) | ||||||
|  |  | ||||||
|  | @ -9,7 +9,7 @@ | ||||||
|         = image_tag account.avatar.url, alt: '', width: 48, height: 48, class: 'u-photo' |         = image_tag account.avatar.url, alt: '', width: 48, height: 48, class: 'u-photo' | ||||||
| 
 | 
 | ||||||
|       .display-name |       .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 |         %bdi | ||||||
|           %strong.emojify.p-name= display_name(account, custom_emojify: true) |           %strong.emojify.p-name= display_name(account, custom_emojify: true) | ||||||
|         %span |         %span | ||||||
|  |  | ||||||
|  | @ -14,58 +14,43 @@ | ||||||
|   %h1= t('directories.explore_mastodon', title: site_title) |   %h1= t('directories.explore_mastodon', title: site_title) | ||||||
|   %p= t('directories.explanation') |   %p= t('directories.explanation') | ||||||
| 
 | 
 | ||||||
| .grid | - if @accounts.empty? | ||||||
|   .column-0 |  | ||||||
|     - if @accounts.empty? |  | ||||||
|   = nothing_here |   = nothing_here | ||||||
|     - else | - else | ||||||
|       .directory |   .directory__list | ||||||
|         %table.accounts-table |  | ||||||
|           %tbody |  | ||||||
|     - @accounts.each do |account| |     - @accounts.each do |account| | ||||||
|               %tr |       .directory__card | ||||||
|                 %td= account_link_to account |         .directory__card__img | ||||||
|                 %td.accounts-table__count.optional |           = 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 |             = number_to_human account.statuses_count, strip_insignificant_zeros: true | ||||||
|             %small= t('accounts.posts', count: account.statuses_count).downcase |             %small= t('accounts.posts', count: account.statuses_count).downcase | ||||||
|                 %td.accounts-table__count.optional |           .accounts-table__count | ||||||
|             = hide_followers_count?(account) ? '-' : (number_to_human account.followers_count, strip_insignificant_zeros: true) |             = hide_followers_count?(account) ? '-' : (number_to_human account.followers_count, strip_insignificant_zeros: true) | ||||||
|             %small= t('accounts.followers', count: account.followers_count).downcase |             %small= t('accounts.followers', count: account.followers_count).downcase | ||||||
|                 %td.accounts-table__count |           .accounts-table__count | ||||||
|             - if account.last_status_at.present? |             - 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 |               %time.time-ago{ datetime: account.last_status_at.iso8601, title: l(account.last_status_at) }= l account.last_status_at | ||||||
|             - else |             - else | ||||||
|                     \- |               = t('invites.expires_in_prompt') | ||||||
|  | 
 | ||||||
|             %small= t('accounts.last_active') |             %small= t('accounts.last_active') | ||||||
| 
 | 
 | ||||||
|   = paginate @accounts |   = 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' |  | ||||||
|  |  | ||||||
							
								
								
									
										5
									
								
								app/views/errors/400.html.haml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								app/views/errors/400.html.haml
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,5 @@ | ||||||
|  | - content_for :page_title do | ||||||
|  |   = t('errors.400') | ||||||
|  | 
 | ||||||
|  | - content_for :content do | ||||||
|  |   = t('errors.400') | ||||||
							
								
								
									
										5
									
								
								app/views/errors/406.html.haml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								app/views/errors/406.html.haml
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,5 @@ | ||||||
|  | - content_for :page_title do | ||||||
|  |   = t('errors.406') | ||||||
|  | 
 | ||||||
|  | - content_for :content do | ||||||
|  |   = t('errors.406') | ||||||
							
								
								
									
										5
									
								
								app/views/errors/503.html.haml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								app/views/errors/503.html.haml
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,5 @@ | ||||||
|  | - content_for :page_title do | ||||||
|  |   = t('errors.503') | ||||||
|  | 
 | ||||||
|  | - content_for :content do | ||||||
|  |   = t('errors.503') | ||||||
|  | @ -28,7 +28,7 @@ | ||||||
| 
 | 
 | ||||||
|   - if Setting.profile_directory |   - if Setting.profile_directory | ||||||
|     .fields-group |     .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/ |   %hr.spacer/ | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -42,11 +42,11 @@ | ||||||
|                               - unless @warning.text.blank? |                               - unless @warning.text.blank? | ||||||
|                                 = Formatter.instance.linkify(@warning.text) |                                 = Formatter.instance.linkify(@warning.text) | ||||||
| 
 | 
 | ||||||
|                               - unless @statuses.empty? |                               - unless @statuses&.empty? | ||||||
|                                 %p |                                 %p | ||||||
|                                   %strong= t('user_mailer.warning.statuses') |                                   %strong= t('user_mailer.warning.statuses') | ||||||
| 
 | 
 | ||||||
| - unless @statuses.empty? | - unless @statuses&.empty? | ||||||
|   - @statuses.each_with_index do |status, i| |   - @statuses.each_with_index do |status, i| | ||||||
|     = render 'notification_mailer/status', status: status, i: i + 1, highlighted: true |     = render 'notification_mailer/status', status: status, i: i + 1, highlighted: true | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -7,7 +7,7 @@ | ||||||
| 
 | 
 | ||||||
| <% end %> | <% end %> | ||||||
| <%= @warning.text %> | <%= @warning.text %> | ||||||
| <% unless @statuses.empty? %> | <% unless @statuses&.empty? %> | ||||||
| <%= t('user_mailer.warning.statuses') %> | <%= t('user_mailer.warning.statuses') %> | ||||||
| 
 | 
 | ||||||
| <% @statuses.each do |status| %> | <% @statuses.each do |status| %> | ||||||
|  |  | ||||||
|  | @ -643,14 +643,8 @@ en: | ||||||
|     warning_title: Disseminated content availability |     warning_title: Disseminated content availability | ||||||
|   directories: |   directories: | ||||||
|     directory: Profile directory |     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 |     explanation: Discover users based on their interests | ||||||
|     explore_mastodon: Explore %{title} |     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: |   domain_blocks: | ||||||
|     blocked_domains: List of limited and blocked domains |     blocked_domains: List of limited and blocked domains | ||||||
|     description: This is the list of servers that %{instance} limits or reject federation with. |     description: This is the list of servers that %{instance} limits or reject federation with. | ||||||
|  | @ -671,8 +665,10 @@ en: | ||||||
|   domain_validator: |   domain_validator: | ||||||
|     invalid_domain: is not a valid domain name |     invalid_domain: is not a valid domain name | ||||||
|   errors: |   errors: | ||||||
|  |     '400': The request you submitted was invalid or malformed. | ||||||
|     '403': You don't have permission to view this page. |     '403': You don't have permission to view this page. | ||||||
|     '404': The page you are looking for isn't here. |     '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. |     '410': The page you were looking for doesn't exist here anymore. | ||||||
|     '422': |     '422': | ||||||
|       content: Security verification failed. Are you blocking cookies? |       content: Security verification failed. Are you blocking cookies? | ||||||
|  | @ -681,6 +677,7 @@ en: | ||||||
|     '500': |     '500': | ||||||
|       content: We're sorry, but something went wrong on our end. |       content: We're sorry, but something went wrong on our end. | ||||||
|       title: This page is not correct |       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. |     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: |   existing_username_validator: | ||||||
|     not_found: could not find a local user with that username |     not_found: could not find a local user with that username | ||||||
|  |  | ||||||
|  | @ -16,7 +16,7 @@ en: | ||||||
|         bot: This account mainly performs automated actions and might not be monitored |         bot: This account mainly performs automated actions and might not be monitored | ||||||
|         context: One or multiple contexts where the filter should apply |         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 |         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 |         email: You will be sent a confirmation e-mail | ||||||
|         fields: You can have up to 4 items displayed as a table on your profile |         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 |         header: PNG, GIF or JPG. At most %{size}. Will be downscaled to %{dimensions}px | ||||||
|  |  | ||||||
|  | @ -6,6 +6,8 @@ require 'sidekiq-scheduler/web' | ||||||
| Sidekiq::Web.set :session_secret, Rails.application.secrets[:secret_key_base] | Sidekiq::Web.set :session_secret, Rails.application.secrets[:secret_key_base] | ||||||
| 
 | 
 | ||||||
| Rails.application.routes.draw do | Rails.application.routes.draw do | ||||||
|  |   root 'home#index' | ||||||
|  | 
 | ||||||
|   mount LetterOpenerWeb::Engine, at: 'letter_opener' if Rails.env.development? |   mount LetterOpenerWeb::Engine, at: 'letter_opener' if Rails.env.development? | ||||||
| 
 | 
 | ||||||
|   authenticate :user, lambda { |u| u.admin? } do |   authenticate :user, lambda { |u| u.admin? } do | ||||||
|  | @ -336,6 +338,7 @@ Rails.application.routes.draw do | ||||||
|       end |       end | ||||||
| 
 | 
 | ||||||
|       resource :domain_blocks, only: [:show, :create, :destroy] |       resource :domain_blocks, only: [:show, :create, :destroy] | ||||||
|  |       resource :directory, only: [:show] | ||||||
| 
 | 
 | ||||||
|       resources :follow_requests, only: [:index] do |       resources :follow_requests, only: [:index] do | ||||||
|         member do |         member do | ||||||
|  | @ -440,10 +443,6 @@ Rails.application.routes.draw do | ||||||
|   get '/about/blocks', to: 'about#blocks' |   get '/about/blocks', to: 'about#blocks' | ||||||
|   get '/terms',        to: 'about#terms' |   get '/terms',        to: 'about#terms' | ||||||
| 
 | 
 | ||||||
|   root 'home#index' |   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 | ||||||
|   match '*unmatched_route', |  | ||||||
|         via: :all, |  | ||||||
|         to: 'application#raise_not_found', |  | ||||||
|         format: false |  | ||||||
| end | end | ||||||
|  |  | ||||||
							
								
								
									
										2
									
								
								dist/nginx.conf
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								dist/nginx.conf
									
									
									
									
										vendored
									
									
								
							|  | @ -19,7 +19,7 @@ server { | ||||||
|   listen [::]:443 ssl http2; |   listen [::]:443 ssl http2; | ||||||
|   server_name example.com; |   server_name example.com; | ||||||
| 
 | 
 | ||||||
|   ssl_protocols TLSv1.2; |   ssl_protocols TLSv1.2 TLSv1.3; | ||||||
|   ssl_ciphers HIGH:!MEDIUM:!LOW:!aNULL:!NULL:!SHA; |   ssl_ciphers HIGH:!MEDIUM:!LOW:!aNULL:!NULL:!SHA; | ||||||
|   ssl_prefer_server_ciphers on; |   ssl_prefer_server_ciphers on; | ||||||
|   ssl_session_cache shared:SSL:10m; |   ssl_session_cache shared:SSL:10m; | ||||||
|  |  | ||||||
|  | @ -2,7 +2,7 @@ | ||||||
|   "name": "mastodon", |   "name": "mastodon", | ||||||
|   "license": "AGPL-3.0-or-later", |   "license": "AGPL-3.0-or-later", | ||||||
|   "engines": { |   "engines": { | ||||||
|     "node": ">=8.12 <12" |     "node": ">=8.12 <13" | ||||||
|   }, |   }, | ||||||
|   "scripts": { |   "scripts": { | ||||||
|     "postversion": "git push --tags", |     "postversion": "git push --tags", | ||||||
|  |  | ||||||
|  | @ -66,9 +66,7 @@ describe RemoteFollowController do | ||||||
|         end |         end | ||||||
| 
 | 
 | ||||||
|         it 'redirects to the remote location' do |         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("http://example.com/follow_me?acct=https%3A%2F%2F#{Rails.configuration.x.local_domain}%2Fusers%2Ftest_user") | ||||||
| 
 |  | ||||||
|           expect(response).to redirect_to(address) |  | ||||||
|         end |         end | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
|  |  | ||||||
|  | @ -50,7 +50,8 @@ describe Settings::TwoFactorAuthentication::ConfirmationsController do | ||||||
| 
 | 
 | ||||||
|       describe 'when form_two_factor_confirmation parameter is not provided' do |       describe 'when form_two_factor_confirmation parameter is not provided' do | ||||||
|         it 'raises ActionController::ParameterMissing' 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 | ||||||
|       end |       end | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -112,7 +112,8 @@ describe Settings::TwoFactorAuthenticationsController do | ||||||
|       end |       end | ||||||
| 
 | 
 | ||||||
|       it 'raises ActionController::ParameterMissing if code is missing' do |       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 | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -61,7 +61,7 @@ RSpec.describe RemoteFollow do | ||||||
|     subject { remote_follow.subscribe_address_for(account) } |     subject { remote_follow.subscribe_address_for(account) } | ||||||
| 
 | 
 | ||||||
|     it 'returns subscribe address' do |     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 |   end | ||||||
| end | end | ||||||
|  |  | ||||||
		Loading…
	
		Reference in a new issue