diff options
author | reverite <samantha@chalker.io> | 2019-03-15 05:54:30 -0700 |
---|---|---|
committer | reverite <samantha@chalker.io> | 2019-03-15 05:54:30 -0700 |
commit | 75eeb003b09c53d3b4e98046d1c20b0ad8a887bb (patch) | |
tree | d2ae669ee583c613a474f8698b7ea718da803819 /app | |
parent | 41d1369391d70a9cd25bdf96cfe567975793ef5a (diff) | |
parent | c2fa0f7c40bcc4064e8baaa221665eadd391c001 (diff) |
Merge remote-tracking branch 'glitch/master' into production
Diffstat (limited to 'app')
123 files changed, 1765 insertions, 2098 deletions
diff --git a/app/controllers/about_controller.rb b/app/controllers/about_controller.rb index ce1e8293c..f459bab19 100644 --- a/app/controllers/about_controller.rb +++ b/app/controllers/about_controller.rb @@ -2,21 +2,17 @@ class AboutController < ApplicationController before_action :set_pack - before_action :set_body_classes + layout 'public' + before_action :set_instance_presenter, only: [:show, :more, :terms] def show - serializable_resource = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(initial_state_params), serializer: InitialStateSerializer) - @initial_state_json = serializable_resource.to_json + @hide_navbar = true end - def more - render layout: 'public' - end + def more; end - def terms - render layout: 'public' - end + def terms; end private @@ -27,21 +23,10 @@ class AboutController < ApplicationController helper_method :new_user def set_pack - use_pack action_name == 'show' ? 'about' : 'common' + use_pack 'common' end def set_instance_presenter @instance_presenter = InstancePresenter.new end - - def set_body_classes - @body_classes = 'with-modals' - end - - def initial_state_params - { - settings: { known_fediverse: Setting.show_known_fediverse_at_about_page }, - token: current_session&.token, - } - end end diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 442e99089..157ea8569 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -81,11 +81,17 @@ class AccountsController < ApplicationController end def hashtag_scope - Status.tagged_with(Tag.find_by(name: params[:tag].downcase)&.id) + tag = Tag.find_normalized(params[:tag]) + + if tag + Status.tagged_with(tag.id) + else + Status.none + end end - def set_account - @account = Account.find_local!(params[:username]) + def username_param + params[:username] end def older_url diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb index 562fba996..e160c603a 100644 --- a/app/controllers/admin/accounts_controller.rb +++ b/app/controllers/admin/accounts_controller.rb @@ -2,9 +2,9 @@ module Admin class AccountsController < BaseController - before_action :set_account, only: [:show, :subscribe, :unsubscribe, :redownload, :remove_avatar, :remove_header, :enable, :unsilence, :unsuspend, :memorialize] + before_action :set_account, only: [:show, :subscribe, :unsubscribe, :redownload, :remove_avatar, :remove_header, :enable, :unsilence, :unsuspend, :memorialize, :approve, :reject] before_action :require_remote_account!, only: [:subscribe, :unsubscribe, :redownload] - before_action :require_local_account!, only: [:enable, :memorialize] + before_action :require_local_account!, only: [:enable, :memorialize, :approve, :reject] def index authorize :account, :index? @@ -45,6 +45,18 @@ module Admin redirect_to admin_account_path(@account.id) end + def approve + authorize @account.user, :approve? + @account.user.approve! + redirect_to admin_accounts_path(pending: '1') + end + + def reject + authorize @account.user, :reject? + SuspendAccountService.new.call(@account, including_user: true, destroy: true) + redirect_to admin_accounts_path(pending: '1') + end + def unsilence authorize @account, :unsilence? @account.unsilence! @@ -114,6 +126,7 @@ module Admin :remote, :by_domain, :active, + :pending, :silenced, :suspended, :username, diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb index bb923c185..22bbcec19 100644 --- a/app/controllers/admin/dashboard_controller.rb +++ b/app/controllers/admin/dashboard_controller.rb @@ -10,7 +10,7 @@ module Admin @interactions_week = Redis.current.get("activity:interactions:#{current_week}") || 0 @relay_enabled = Relay.enabled.exists? @single_user_mode = Rails.configuration.x.single_user_mode - @registrations_enabled = Setting.open_registrations + @registrations_enabled = Setting.registrations_mode != 'none' @deletions_enabled = Setting.open_deletion @invites_enabled = Setting.min_invite_role == 'user' @search_enabled = Chewy.enabled? diff --git a/app/controllers/admin/settings_controller.rb b/app/controllers/admin/settings_controller.rb index 9624df96b..a64e98868 100644 --- a/app/controllers/admin/settings_controller.rb +++ b/app/controllers/admin/settings_controller.rb @@ -10,7 +10,7 @@ module Admin site_description site_extended_description site_terms - open_registrations + registrations_mode closed_registrations_message open_deletion timeline_preview @@ -33,7 +33,6 @@ module Admin ).freeze BOOLEAN_SETTINGS = %w( - open_registrations open_deletion timeline_preview show_staff_badge diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index a1dd30918..3a92ee4e4 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -73,7 +73,9 @@ class Api::BaseController < ApplicationController elsif current_user.disabled? render json: { error: 'Your login is currently disabled' }, status: 403 elsif !current_user.confirmed? - render json: { error: 'Email confirmation is not completed' }, status: 403 + render json: { error: 'Your login is missing a confirmed e-mail address' }, status: 403 + elsif !current_user.approved? + render json: { error: 'Your login is currently pending approval' }, status: 403 else set_user_activity end diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb index ed10f3f6a..8cd8f8e79 100644 --- a/app/controllers/api/v1/accounts/statuses_controller.rb +++ b/app/controllers/api/v1/accounts/statuses_controller.rb @@ -69,7 +69,13 @@ class Api::V1::Accounts::StatusesController < Api::BaseController end def hashtag_scope - Status.tagged_with(Tag.find_by(name: params[:tagged])&.id) + tag = Tag.find_normalized(params[:tagged]) + + if tag + Status.tagged_with(tag.id) + else + Status.none + end end def pagination_params(core_params) diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 2ccbc3cbb..b0c62778e 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -80,6 +80,10 @@ class Api::V1::AccountsController < Api::BaseController end def check_enabled_registrations - forbidden if single_user_mode? || !Setting.open_registrations + forbidden if single_user_mode? || !allowed_registrations? + end + + def allowed_registrations? + Setting.registrations_mode != 'none' end end diff --git a/app/controllers/api/v1/timelines/tag_controller.rb b/app/controllers/api/v1/timelines/tag_controller.rb index 92c32c178..9adc4ad29 100644 --- a/app/controllers/api/v1/timelines/tag_controller.rb +++ b/app/controllers/api/v1/timelines/tag_controller.rb @@ -14,7 +14,7 @@ class Api::V1::Timelines::TagController < Api::BaseController private def load_tag - @tag = Tag.find_by(name: params[:id].downcase) + @tag = Tag.find_normalized(params[:id]) end def load_statuses diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index efe29b53f..74dd7ff34 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -66,7 +66,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController end def allowed_registrations? - Setting.open_registrations || @invite&.valid_for_use? + Setting.registrations_mode != 'none' || @invite&.valid_for_use? end def invite_code diff --git a/app/controllers/concerns/account_controller_concern.rb b/app/controllers/concerns/account_controller_concern.rb index 6c27ef330..8817fd7de 100644 --- a/app/controllers/concerns/account_controller_concern.rb +++ b/app/controllers/concerns/account_controller_concern.rb @@ -7,16 +7,18 @@ module AccountControllerConcern included do layout 'public' + before_action :set_account + before_action :check_account_approval + before_action :check_account_suspension before_action :set_instance_presenter before_action :set_link_headers - before_action :check_account_suspension end private def set_account - @account = Account.find_local!(params[:account_username]) + @account = Account.find_local!(username_param) end def set_instance_presenter @@ -33,6 +35,10 @@ module AccountControllerConcern ) end + def username_param + params[:account_username] + end + def webfinger_account_link [ webfinger_account_url, @@ -58,6 +64,10 @@ module AccountControllerConcern webfinger_url(resource: @account.to_webfinger_s) end + def check_account_approval + not_found if @account.user_pending? + end + def check_account_suspension gone if @account.suspended? end diff --git a/app/controllers/public_timelines_controller.rb b/app/controllers/public_timelines_controller.rb new file mode 100644 index 000000000..c5fe789f4 --- /dev/null +++ b/app/controllers/public_timelines_controller.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class PublicTimelinesController < ApplicationController + before_action :set_pack + layout 'public' + + before_action :check_enabled + before_action :set_body_classes + before_action :set_instance_presenter + + def show + respond_to do |format| + format.html do + @initial_state_json = ActiveModelSerializers::SerializableResource.new( + InitialStatePresenter.new(settings: { known_fediverse: Setting.show_known_fediverse_at_about_page }, token: current_session&.token), + serializer: InitialStateSerializer + ).to_json + end + end + end + + private + + def check_enabled + raise ActiveRecord::RecordNotFound unless Setting.timeline_preview + end + + def set_body_classes + @body_classes = 'with-modals' + end + + def set_instance_presenter + @instance_presenter = InstancePresenter.new + end + + def set_pack + use_pack 'about' + end +end diff --git a/app/controllers/settings/exports_controller.rb b/app/controllers/settings/exports_controller.rb index cf8745576..7f76668d5 100644 --- a/app/controllers/settings/exports_controller.rb +++ b/app/controllers/settings/exports_controller.rb @@ -9,11 +9,25 @@ class Settings::ExportsController < Settings::BaseController end def create - authorize :backup, :create? + raise Mastodon::NotPermittedError unless user_signed_in? + + backup = nil + + RedisLock.acquire(lock_options) do |lock| + if lock.acquired? + authorize :backup, :create? + backup = current_user.backups.create! + else + raise Mastodon::RaceConditionError + end + end - backup = current_user.backups.create! BackupWorker.perform_async(backup.id) redirect_to settings_export_path end + + def lock_options + { redis: Redis.current, key: "backup:#{current_user.id}" } + end end diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 186d276c2..5cb048c1a 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -9,13 +9,15 @@ class TagsController < ApplicationController before_action :set_instance_presenter def show - @tag = Tag.find_by!(name: params[:id].downcase) + @tag = Tag.find_normalized!(params[:id]) respond_to do |format| format.html do use_pack 'about' - serializable_resource = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(initial_state_params), serializer: InitialStateSerializer) - @initial_state_json = serializable_resource.to_json + @initial_state_json = ActiveModelSerializers::SerializableResource.new( + InitialStatePresenter.new(settings: {}, token: current_session&.token), + serializer: InitialStateSerializer + ).to_json end format.rss do @@ -26,8 +28,7 @@ class TagsController < ApplicationController end format.json do - @statuses = HashtagQueryService.new.call(@tag, params.slice(:any, :all, :none), current_account, params[:local]) - .paginate_by_max_id(PAGE_SIZE, params[:max_id]) + @statuses = HashtagQueryService.new.call(@tag, params.slice(:any, :all, :none), current_account, params[:local]).paginate_by_max_id(PAGE_SIZE, params[:max_id]) @statuses = cache_collection(@statuses, Status) render json: collection_presenter, @@ -56,11 +57,4 @@ class TagsController < ApplicationController items: @statuses.map { |s| ActivityPub::TagManager.instance.uri_for(s) } ) end - - def initial_state_params - { - settings: {}, - token: current_session&.token, - } - end end diff --git a/app/helpers/admin/filter_helper.rb b/app/helpers/admin/filter_helper.rb index 275b5f2fe..8f78bf5f8 100644 --- a/app/helpers/admin/filter_helper.rb +++ b/app/helpers/admin/filter_helper.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Admin::FilterHelper - ACCOUNT_FILTERS = %i(local remote by_domain active silenced suspended username display_name email ip staff).freeze + ACCOUNT_FILTERS = %i(local remote by_domain active pending silenced suspended username display_name email ip staff).freeze REPORT_FILTERS = %i(resolved account_id target_account_id).freeze INVITE_FILTER = %i(available expired).freeze CUSTOM_EMOJI_FILTERS = %i(local remote by_domain shortcode).freeze diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index fc006d777..f70c37522 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -20,7 +20,23 @@ module ApplicationHelper end def open_registrations? - Setting.open_registrations + Setting.registrations_mode == 'open' + end + + def approved_registrations? + Setting.registrations_mode == 'approved' + end + + def closed_registrations? + Setting.registrations_mode == 'none' + end + + def available_sign_up_path + if closed_registrations? + 'https://joinmastodon.org/#getting-started' + else + new_user_registration_path + end end def open_deletion? diff --git a/app/helpers/home_helper.rb b/app/helpers/home_helper.rb index 9b3f1380b..df60b7dd7 100644 --- a/app/helpers/home_helper.rb +++ b/app/helpers/home_helper.rb @@ -56,4 +56,22 @@ module HomeHelper 'emojify' end end + + def optional_link_to(condition, path, options = {}, &block) + if condition + link_to(path, options, &block) + else + content_tag(:div, &block) + end + end + + def sign_up_message + if closed_registrations? + t('auth.registration_closed', instance: site_hostname) + elsif open_registrations? + t('auth.register') + elsif approved_registrations? + t('auth.apply_for_account') + end + end end diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb index f0a19e332..5b4011275 100644 --- a/app/helpers/jsonld_helper.rb +++ b/app/helpers/jsonld_helper.rb @@ -47,6 +47,15 @@ module JsonLdHelper !uri.start_with?('http://', 'https://') end + def invalid_origin?(url) + return true if unsupported_uri_scheme?(url) + + needle = Addressable::URI.parse(url).host + haystack = Addressable::URI.parse(@account.uri).host + + !haystack.casecmp(needle).zero? + end + def canonicalize(json) graph = RDF::Graph.new << JSON::LD::API.toRdf(json, documentLoader: method(:load_jsonld_context)) graph.dump(:normalize) diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js index f89b4cb36..dd4f5fd44 100644 --- a/app/javascript/flavours/glitch/actions/notifications.js +++ b/app/javascript/flavours/glitch/actions/notifications.js @@ -106,7 +106,7 @@ const excludeTypesFromSettings = state => state.getIn(['settings', 'notification const excludeTypesFromFilter = filter => { - const allTypes = ImmutableList(['follow', 'favourite', 'reblog', 'mention']); + const allTypes = ImmutableList(['follow', 'favourite', 'reblog', 'mention', 'poll']); return allTypes.filterNot(item => item === filter).toJS(); }; diff --git a/app/javascript/flavours/glitch/components/media_gallery.js b/app/javascript/flavours/glitch/components/media_gallery.js index 1fa25ee4e..6be2b4700 100644 --- a/app/javascript/flavours/glitch/components/media_gallery.js +++ b/app/javascript/flavours/glitch/components/media_gallery.js @@ -238,7 +238,7 @@ export default class MediaGallery extends React.PureComponent { }; componentWillReceiveProps (nextProps) { - if (!is(nextProps.media, this.props.media)) { + if (!is(nextProps.media, this.props.media) || nextProps.revealed === true) { this.setState({ visible: nextProps.revealed === undefined ? (displayMedia !== 'hide_all' && !nextProps.sensitive || displayMedia === 'show_all') : nextProps.revealed }); } } diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js index 31f4f1ddd..c8bf75f79 100644 --- a/app/javascript/flavours/glitch/components/status.js +++ b/app/javascript/flavours/glitch/components/status.js @@ -466,6 +466,7 @@ export default class Status extends ImmutablePureComponent { onOpenVideo={this.handleOpenVideo} width={this.props.cachedMediaWidth} cacheWidth={this.props.cacheMediaWidth} + revealed={settings.getIn(['media', 'reveal_behind_cw']) && !!status.get('spoiler_text') ? true : undefined} />)} </Bundle> ); @@ -483,6 +484,7 @@ export default class Status extends ImmutablePureComponent { onOpenMedia={this.props.onOpenMedia} cacheWidth={this.props.cacheMediaWidth} defaultWidth={this.props.cachedMediaWidth} + revealed={settings.getIn(['media', 'reveal_behind_cw']) && !!status.get('spoiler_text') ? true : undefined} /> )} </Bundle> diff --git a/app/javascript/flavours/glitch/components/status_prepend.js b/app/javascript/flavours/glitch/components/status_prepend.js index 4e329f546..481e6644e 100644 --- a/app/javascript/flavours/glitch/components/status_prepend.js +++ b/app/javascript/flavours/glitch/components/status_prepend.js @@ -62,6 +62,13 @@ export default class StatusPrepend extends React.PureComponent { values={{ name : link }} /> ); + case 'poll': + return ( + <FormattedMessage + id='notification.poll' + defaultMessage='A poll you have voted in has ended' + /> + ); } return null; } @@ -75,7 +82,7 @@ export default class StatusPrepend extends React.PureComponent { <div className={type === 'reblogged_by' || type === 'featured' ? 'status__prepend-icon-wrapper' : 'notification__favourite-icon-wrapper'}> <i className={`fa fa-fw fa-${ - type === 'favourite' ? 'star star-icon' : (type === 'featured' ? 'thumb-tack' : 'retweet') + type === 'favourite' ? 'star star-icon' : (type === 'featured' ? 'thumb-tack' : (type === 'poll' ? 'tasks' : 'retweet')) } status__prepend-icon`} /> </div> diff --git a/app/javascript/flavours/glitch/containers/timeline_container.js b/app/javascript/flavours/glitch/containers/timeline_container.js index 5a1f41f7a..6ce556664 100644 --- a/app/javascript/flavours/glitch/containers/timeline_container.js +++ b/app/javascript/flavours/glitch/containers/timeline_container.js @@ -7,7 +7,6 @@ import { hydrateStore } from 'flavours/glitch/actions/store'; import { IntlProvider, addLocaleData } from 'react-intl'; import { getLocale } from 'mastodon/locales'; import PublicTimeline from 'flavours/glitch/features/standalone/public_timeline'; -import CommunityTimeline from 'flavours/glitch/features/standalone/community_timeline'; import HashtagTimeline from 'flavours/glitch/features/standalone/hashtag_timeline'; import ModalContainer from 'flavours/glitch/features/ui/containers/modal_container'; import initialState from 'flavours/glitch/util/initial_state'; @@ -26,24 +25,22 @@ export default class TimelineContainer extends React.PureComponent { static propTypes = { locale: PropTypes.string.isRequired, hashtag: PropTypes.string, - showPublicTimeline: PropTypes.bool.isRequired, + local: PropTypes.bool, }; static defaultProps = { - showPublicTimeline: initialState.settings.known_fediverse, + local: !initialState.settings.known_fediverse, }; render () { - const { locale, hashtag, showPublicTimeline } = this.props; + const { locale, hashtag, local } = this.props; let timeline; if (hashtag) { timeline = <HashtagTimeline hashtag={hashtag} />; - } else if (showPublicTimeline) { - timeline = <PublicTimeline />; } else { - timeline = <CommunityTimeline />; + timeline = <PublicTimeline local={local} />; } return ( @@ -51,6 +48,7 @@ export default class TimelineContainer extends React.PureComponent { <Provider store={store}> <Fragment> {timeline} + {ReactDOM.createPortal( <ModalContainer />, document.getElementById('modal-container'), diff --git a/app/javascript/flavours/glitch/features/account/components/profile_column_header.js b/app/javascript/flavours/glitch/features/account/components/profile_column_header.js index 1a6abef37..b6d373a2c 100644 --- a/app/javascript/flavours/glitch/features/account/components/profile_column_header.js +++ b/app/javascript/flavours/glitch/features/account/components/profile_column_header.js @@ -11,16 +11,18 @@ export default @injectIntl class ProfileColumnHeader extends React.PureComponent { static propTypes = { + onClick: PropTypes.func, intl: PropTypes.object.isRequired, }; render() { - const { intl } = this.props; + const { onClick, intl } = this.props; return ( <ColumnHeader icon='user-circle' title={intl.formatMessage(messages.profile)} + onClick={onClick} showBackButton /> ); diff --git a/app/javascript/flavours/glitch/features/account_gallery/index.js b/app/javascript/flavours/glitch/features/account_gallery/index.js index a9ea5088e..63c1b2d86 100644 --- a/app/javascript/flavours/glitch/features/account_gallery/index.js +++ b/app/javascript/flavours/glitch/features/account_gallery/index.js @@ -65,6 +65,10 @@ export default class AccountGallery extends ImmutablePureComponent { } } + handleHeaderClick = () => { + this.column.scrollTop(); + } + handleScrollToBottom = () => { if (this.props.hasMore) { this.handleLoadMore(this.props.medias.size > 0 ? this.props.medias.last().getIn(['status', 'id']) : undefined); @@ -94,6 +98,10 @@ export default class AccountGallery extends ImmutablePureComponent { return !(location.state && location.state.mastodonModalOpen); } + setRef = c => { + this.column = c; + } + render () { const { medias, isLoading, hasMore } = this.props; @@ -112,8 +120,8 @@ export default class AccountGallery extends ImmutablePureComponent { } return ( - <Column> - <ProfileColumnHeader /> + <Column ref={this.setRef}> + <ProfileColumnHeader onClick={this.handleHeaderClick} /> <ScrollContainer scrollKey='account_gallery' shouldUpdateScroll={this.shouldUpdateScroll}> <div className='scrollable scrollable--flex' onScroll={this.handleScroll}> diff --git a/app/javascript/flavours/glitch/features/account_timeline/index.js b/app/javascript/flavours/glitch/features/account_timeline/index.js index 415e3be20..d683b8789 100644 --- a/app/javascript/flavours/glitch/features/account_timeline/index.js +++ b/app/javascript/flavours/glitch/features/account_timeline/index.js @@ -57,10 +57,18 @@ export default class AccountTimeline extends ImmutablePureComponent { } } + handleHeaderClick = () => { + this.column.scrollTop(); + } + handleLoadMore = maxId => { this.props.dispatch(expandAccountTimeline(this.props.params.accountId, { maxId, withReplies: this.props.withReplies })); } + setRef = c => { + this.column = c; + } + render () { const { statusIds, featuredStatusIds, isLoading, hasMore } = this.props; @@ -73,8 +81,8 @@ export default class AccountTimeline extends ImmutablePureComponent { } return ( - <Column name='account'> - <ProfileColumnHeader /> + <Column ref={this.setRef} name='account'> + <ProfileColumnHeader onClick={this.handleHeaderClick} /> <StatusList prepend={<HeaderContainer accountId={this.props.params.accountId} />} diff --git a/app/javascript/flavours/glitch/features/followers/index.js b/app/javascript/flavours/glitch/features/followers/index.js index 124004cb6..6bb9f60fd 100644 --- a/app/javascript/flavours/glitch/features/followers/index.js +++ b/app/javascript/flavours/glitch/features/followers/index.js @@ -43,6 +43,10 @@ export default class Followers extends ImmutablePureComponent { } } + handleHeaderClick = () => { + this.column.scrollTop(); + } + handleScroll = (e) => { const { scrollTop, scrollHeight, clientHeight } = e.target; @@ -61,6 +65,10 @@ export default class Followers extends ImmutablePureComponent { return !(location.state && location.state.mastodonModalOpen); } + setRef = c => { + this.column = c; + } + render () { const { accountIds, hasMore } = this.props; @@ -79,8 +87,8 @@ export default class Followers extends ImmutablePureComponent { } return ( - <Column> - <ProfileColumnHeader /> + <Column ref={this.setRef}> + <ProfileColumnHeader onClick={this.handleHeaderClick} /> <ScrollContainer scrollKey='followers' shouldUpdateScroll={this.shouldUpdateScroll}> <div className='scrollable' onScroll={this.handleScroll}> diff --git a/app/javascript/flavours/glitch/features/following/index.js b/app/javascript/flavours/glitch/features/following/index.js index 656100dad..3f2f091a1 100644 --- a/app/javascript/flavours/glitch/features/following/index.js +++ b/app/javascript/flavours/glitch/features/following/index.js @@ -43,6 +43,10 @@ export default class Following extends ImmutablePureComponent { } } + handleHeaderClick = () => { + this.column.scrollTop(); + } + handleScroll = (e) => { const { scrollTop, scrollHeight, clientHeight } = e.target; @@ -61,6 +65,10 @@ export default class Following extends ImmutablePureComponent { return !(location.state && location.state.mastodonModalOpen); } + setRef = c => { + this.column = c; + } + render () { const { accountIds, hasMore } = this.props; @@ -79,8 +87,8 @@ export default class Following extends ImmutablePureComponent { } return ( - <Column> - <ProfileColumnHeader /> + <Column ref={this.setRef}> + <ProfileColumnHeader onClick={this.handleHeaderClick} /> <ScrollContainer scrollKey='following' shouldUpdateScroll={this.shouldUpdateScroll}> <div className='scrollable' onScroll={this.handleScroll}> diff --git a/app/javascript/flavours/glitch/features/local_settings/page/index.js b/app/javascript/flavours/glitch/features/local_settings/page/index.js index 4535d9849..bc4ad359c 100644 --- a/app/javascript/flavours/glitch/features/local_settings/page/index.js +++ b/app/javascript/flavours/glitch/features/local_settings/page/index.js @@ -328,6 +328,14 @@ export default class LocalSettingsPage extends React.PureComponent { > <FormattedMessage id='settings.inline_preview_cards' defaultMessage='Inline preview cards for external links' /> </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['media', 'reveal_behind_cw']} + id='mastodon-settings--reveal-behind-cw' + onChange={onChange} + > + <FormattedMessage id='settings.media_reveal_behind_cw' defaultMessage='Reveal sensitive media behind a CW by default' /> + </LocalSettingsPageItem> </div> ), ]; diff --git a/app/javascript/flavours/glitch/features/notifications/components/column_settings.js b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js index 4e35d5b4e..e29bd61f5 100644 --- a/app/javascript/flavours/glitch/features/notifications/components/column_settings.js +++ b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js @@ -90,6 +90,17 @@ export default class ColumnSettings extends React.PureComponent { <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'reblog']} onChange={onChange} label={soundStr} /> </div> </div> + + <div role='group' aria-labelledby='notifications-poll'> + <span id='notifications-poll' className='column-settings__section'><FormattedMessage id='notifications.column_settings.poll' defaultMessage='Poll results:' /></span> + + <div className='column-settings__row'> + <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'poll']} onChange={onChange} label={alertStr} /> + {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'poll']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />} + <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'poll']} onChange={onChange} label={showStr} /> + <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'poll']} onChange={onChange} label={soundStr} /> + </div> + </div> </div> ); } diff --git a/app/javascript/flavours/glitch/features/notifications/components/filter_bar.js b/app/javascript/flavours/glitch/features/notifications/components/filter_bar.js index f95a2c9de..3457b7598 100644 --- a/app/javascript/flavours/glitch/features/notifications/components/filter_bar.js +++ b/app/javascript/flavours/glitch/features/notifications/components/filter_bar.js @@ -6,6 +6,7 @@ const tooltips = defineMessages({ mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' }, favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Favourites' }, boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' }, + polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' }, follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' }, }); @@ -79,6 +80,13 @@ class FilterBar extends React.PureComponent { <i className='fa fa-fw fa-retweet' /> </button> <button + className={selectedFilter === 'poll' ? 'active' : ''} + onClick={this.onClick('poll')} + title={intl.formatMessage(tooltips.polls)} + > + <i className='fa fa-fw fa-tasks' /> + </button> + <button className={selectedFilter === 'follow' ? 'active' : ''} onClick={this.onClick('follow')} title={intl.formatMessage(tooltips.follows)} diff --git a/app/javascript/flavours/glitch/features/notifications/components/notification.js b/app/javascript/flavours/glitch/features/notifications/components/notification.js index daafe3507..5c5bbf604 100644 --- a/app/javascript/flavours/glitch/features/notifications/components/notification.js +++ b/app/javascript/flavours/glitch/features/notifications/components/notification.js @@ -108,6 +108,27 @@ export default class Notification extends ImmutablePureComponent { withDismiss /> ); + case 'poll': + return ( + <StatusContainer + containerId={notification.get('id')} + hidden={hidden} + id={notification.get('status')} + account={notification.get('account')} + prepend='poll' + muted + notification={notification} + onMoveDown={onMoveDown} + onMoveUp={onMoveUp} + onMention={onMention} + getScrollPosition={getScrollPosition} + updateScrollBottom={updateScrollBottom} + cachedMediaWidth={this.props.cachedMediaWidth} + cacheMediaWidth={this.props.cacheMediaWidth} + onUnmount={this.props.onUnmount} + withDismiss + /> + ); default: return null; } diff --git a/app/javascript/flavours/glitch/features/standalone/community_timeline/index.js b/app/javascript/flavours/glitch/features/standalone/community_timeline/index.js deleted file mode 100644 index 2b67e836a..000000000 --- a/app/javascript/flavours/glitch/features/standalone/community_timeline/index.js +++ /dev/null @@ -1,71 +0,0 @@ -import React from 'react'; -import { connect } from 'react-redux'; -import PropTypes from 'prop-types'; -import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container'; -import { expandCommunityTimeline } from 'flavours/glitch/actions/timelines'; -import Column from 'flavours/glitch/components/column'; -import ColumnHeader from 'flavours/glitch/components/column_header'; -import { defineMessages, injectIntl } from 'react-intl'; -import { connectCommunityStream } from 'flavours/glitch/actions/streaming'; - -const messages = defineMessages({ - title: { id: 'standalone.public_title', defaultMessage: 'A look inside...' }, -}); - -@connect() -@injectIntl -export default class CommunityTimeline extends React.PureComponent { - - static propTypes = { - dispatch: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, - }; - - handleHeaderClick = () => { - this.column.scrollTop(); - } - - setRef = c => { - this.column = c; - } - - componentDidMount () { - const { dispatch } = this.props; - - dispatch(expandCommunityTimeline()); - this.disconnect = dispatch(connectCommunityStream()); - } - - componentWillUnmount () { - if (this.disconnect) { - this.disconnect(); - this.disconnect = null; - } - } - - handleLoadMore = maxId => { - this.props.dispatch(expandCommunityTimeline({ maxId })); - } - - render () { - const { intl } = this.props; - - return ( - <Column ref={this.setRef} label={intl.formatMessage(messages.title)}> - <ColumnHeader - icon='users' - title={intl.formatMessage(messages.title)} - onClick={this.handleHeaderClick} - /> - - <StatusListContainer - timelineId='community' - onLoadMore={this.handleLoadMore} - scrollKey='standalone_public_timeline' - trackScroll={false} - /> - </Column> - ); - } - -} diff --git a/app/javascript/flavours/glitch/features/standalone/public_timeline/index.js b/app/javascript/flavours/glitch/features/standalone/public_timeline/index.js index 907da3992..5e2b3fc6d 100644 --- a/app/javascript/flavours/glitch/features/standalone/public_timeline/index.js +++ b/app/javascript/flavours/glitch/features/standalone/public_timeline/index.js @@ -1,70 +1,112 @@ import React from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; -import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container'; -import { expandPublicTimeline } from 'flavours/glitch/actions/timelines'; -import Column from 'flavours/glitch/components/column'; -import ColumnHeader from 'flavours/glitch/components/column_header'; -import { defineMessages, injectIntl } from 'react-intl'; -import { connectPublicStream } from 'flavours/glitch/actions/streaming'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { expandPublicTimeline, expandCommunityTimeline } from 'flavours/glitch/actions/timelines'; +import { connectPublicStream, connectCommunityStream } from 'flavours/glitch/actions/streaming'; +import Masonry from 'react-masonry-infinite'; +import { List as ImmutableList, Map as ImmutableMap } from 'immutable'; +import DetailedStatusContainer from 'flavours/glitch/features/status/containers/detailed_status_container'; +import { debounce } from 'lodash'; +import LoadingIndicator from 'flavours/glitch/components/loading_indicator'; -const messages = defineMessages({ - title: { id: 'standalone.public_title', defaultMessage: 'A look inside...' }, -}); +const mapStateToProps = (state, { local }) => { + const timeline = state.getIn(['timelines', local ? 'community' : 'public'], ImmutableMap()); -@connect() -@injectIntl -export default class PublicTimeline extends React.PureComponent { + return { + statusIds: timeline.get('items', ImmutableList()), + isLoading: timeline.get('isLoading', false), + hasMore: timeline.get('hasMore', false), + }; +}; + +export default @connect(mapStateToProps) +class PublicTimeline extends React.PureComponent { static propTypes = { dispatch: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, + statusIds: ImmutablePropTypes.list.isRequired, + isLoading: PropTypes.bool.isRequired, + hasMore: PropTypes.bool.isRequired, + local: PropTypes.bool, }; - handleHeaderClick = () => { - this.column.scrollTop(); + componentDidMount () { + this._connect(); } - setRef = c => { - this.column = c; + componentDidUpdate (prevProps) { + if (prevProps.local !== this.props.local) { + this._disconnect(); + this._connect(); + } } - componentDidMount () { - const { dispatch } = this.props; - - dispatch(expandPublicTimeline()); - this.disconnect = dispatch(connectPublicStream()); + componentWillUnmount () { + this._disconnect(); } - componentWillUnmount () { + _connect () { + const { dispatch, local } = this.props; + + dispatch(local ? expandCommunityTimeline() : expandPublicTimeline()); + this.disconnect = dispatch(local ? connectCommunityStream() : connectPublicStream()); + } + + _disconnect () { if (this.disconnect) { this.disconnect(); this.disconnect = null; } } - handleLoadMore = maxId => { - this.props.dispatch(expandPublicTimeline({ maxId })); + handleLoadMore = () => { + const { dispatch, statusIds, local } = this.props; + const maxId = statusIds.last(); + + if (maxId) { + dispatch(local ? expandCommunityTimeline({ maxId }) : expandPublicTimeline({ maxId })); + } + } + + setRef = c => { + this.masonry = c; } + handleHeightChange = debounce(() => { + if (!this.masonry) { + return; + } + + this.masonry.forcePack(); + }, 50) + render () { - const { intl } = this.props; + const { statusIds, hasMore, isLoading } = this.props; + + const sizes = [ + { columns: 1, gutter: 0 }, + { mq: '415px', columns: 1, gutter: 10 }, + { mq: '640px', columns: 2, gutter: 10 }, + { mq: '960px', columns: 3, gutter: 10 }, + { mq: '1255px', columns: 3, gutter: 10 }, + ]; + + const loader = (isLoading && statusIds.isEmpty()) ? <LoadingIndicator key={0} /> : undefined; return ( - <Column ref={this.setRef} label={intl.formatMessage(messages.title)}> - <ColumnHeader - icon='globe' - title={intl.formatMessage(messages.title)} - onClick={this.handleHeaderClick} - /> - - <StatusListContainer - timelineId='public' - onLoadMore={this.handleLoadMore} - scrollKey='standalone_public_timeline' - trackScroll={false} - /> - </Column> + <Masonry ref={this.setRef} className='statuses-grid' hasMore={hasMore} loadMore={this.handleLoadMore} sizes={sizes} loader={loader}> + {statusIds.map(statusId => ( + <div className='statuses-grid__item' key={statusId}> + <DetailedStatusContainer + id={statusId} + compact + measureHeight + onHeightChange={this.handleHeightChange} + /> + </div> + )).toArray()} + </Masonry> ); } diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.js b/app/javascript/flavours/glitch/features/status/components/detailed_status.js index ad60320ef..e78f16c54 100644 --- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js +++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js @@ -23,7 +23,7 @@ export default class DetailedStatus extends ImmutablePureComponent { }; static propTypes = { - status: ImmutablePropTypes.map.isRequired, + status: ImmutablePropTypes.map, settings: ImmutablePropTypes.map.isRequired, onOpenMedia: PropTypes.func.isRequired, onOpenVideo: PropTypes.func.isRequired, @@ -138,6 +138,7 @@ export default class DetailedStatus extends ImmutablePureComponent { preventPlayback={!expanded} onOpenVideo={this.handleOpenVideo} autoplay + revealed={settings.getIn(['media', 'reveal_behind_cw']) && !!status.get('spoiler_text') ? true : undefined} /> ); mediaIcon = 'video-camera'; @@ -151,6 +152,7 @@ export default class DetailedStatus extends ImmutablePureComponent { fullwidth={settings.getIn(['media', 'fullwidth'])} hidden={!expanded} onOpenMedia={this.props.onOpenMedia} + revealed={settings.getIn(['media', 'reveal_behind_cw']) && !!status.get('spoiler_text') ? true : undefined} /> ); mediaIcon = 'picture-o'; diff --git a/app/javascript/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js index 880372de5..73d3c7e9e 100644 --- a/app/javascript/flavours/glitch/features/status/index.js +++ b/app/javascript/flavours/glitch/features/status/index.js @@ -369,6 +369,10 @@ export default class Status extends ImmutablePureComponent { } } + handleHeaderClick = () => { + this.column.scrollTop(); + } + renderChildren (list) { return list.map(id => ( <StatusContainer @@ -390,6 +394,10 @@ export default class Status extends ImmutablePureComponent { this.node = c; } + setColumnRef = c => { + this.column = c; + } + componentDidUpdate (prevProps) { if (this.props.params.statusId && (this.props.params.statusId !== prevProps.params.statusId || prevProps.ancestorsIds.size < this.props.ancestorsIds.size)) { const { status, ancestorsIds } = this.props; @@ -452,10 +460,11 @@ export default class Status extends ImmutablePureComponent { }; return ( - <Column label={intl.formatMessage(messages.detailedStatus)}> + <Column ref={this.setColumnRef} label={intl.formatMessage(messages.detailedStatus)}> <ColumnHeader icon='comment' title={intl.formatMessage(messages.tootHeading)} + onClick={this.handleHeaderClick} showBackButton extraButton={( <button className='column-header__button' title={intl.formatMessage(!isExpanded ? messages.revealAll : messages.hideAll)} aria-label={intl.formatMessage(!isExpanded ? messages.revealAll : messages.hideAll)} onClick={this.handleToggleAll} aria-pressed={!isExpanded ? 'false' : 'true'}><i className={`fa fa-${!isExpanded ? 'eye-slash' : 'eye'}`} /></button> diff --git a/app/javascript/flavours/glitch/features/video/index.js b/app/javascript/flavours/glitch/features/video/index.js index cf66536c4..e3ed799c7 100644 --- a/app/javascript/flavours/glitch/features/video/index.js +++ b/app/javascript/flavours/glitch/features/video/index.js @@ -119,6 +119,12 @@ export default class Video extends React.PureComponent { revealed: this.props.revealed === undefined ? (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all') : this.props.revealed, }; + componentWillReceiveProps (nextProps) { + if (nextProps.revealed === true) { + this.setState({ revealed: true }); + } + } + // hard coded in components.scss // any way to get ::before values programatically? volWidth = 50; diff --git a/app/javascript/flavours/glitch/reducers/local_settings.js b/app/javascript/flavours/glitch/reducers/local_settings.js index 93a404328..ef694d4ea 100644 --- a/app/javascript/flavours/glitch/reducers/local_settings.js +++ b/app/javascript/flavours/glitch/reducers/local_settings.js @@ -39,8 +39,9 @@ const initialState = ImmutableMap({ show_action_bar : true, }), media : ImmutableMap({ - letterbox : true, - fullwidth : true, + letterbox : true, + fullwidth : true, + reveal_behind_cw : false, }), notifications : ImmutableMap({ favicon_badge : false, diff --git a/app/javascript/flavours/glitch/reducers/push_notifications.js b/app/javascript/flavours/glitch/reducers/push_notifications.js index 1b47ca962..e87e8fc1a 100644 --- a/app/javascript/flavours/glitch/reducers/push_notifications.js +++ b/app/javascript/flavours/glitch/reducers/push_notifications.js @@ -9,6 +9,7 @@ const initialState = Immutable.Map({ favourite: false, reblog: false, mention: false, + poll: false, }), isSubscribed: false, browserSupport: false, diff --git a/app/javascript/flavours/glitch/reducers/settings.js b/app/javascript/flavours/glitch/reducers/settings.js index 384ff2d44..cc86a6b20 100644 --- a/app/javascript/flavours/glitch/reducers/settings.js +++ b/app/javascript/flavours/glitch/reducers/settings.js @@ -33,6 +33,7 @@ const initialState = ImmutableMap({ favourite: true, reblog: true, mention: true, + poll: true, }), quickFilter: ImmutableMap({ @@ -46,6 +47,7 @@ const initialState = ImmutableMap({ favourite: true, reblog: true, mention: true, + poll: true, }), sounds: ImmutableMap({ @@ -53,6 +55,7 @@ const initialState = ImmutableMap({ favourite: true, reblog: true, mention: true, + poll: true, }), }), diff --git a/app/javascript/flavours/glitch/styles/about.scss b/app/javascript/flavours/glitch/styles/about.scss index 329482458..7a457600e 100644 --- a/app/javascript/flavours/glitch/styles/about.scss +++ b/app/javascript/flavours/glitch/styles/about.scss @@ -193,6 +193,7 @@ $small-breakpoint: 960px; } strong { + font-family: $font-display, sans-serif; font-weight: 500; font-size: 32px; line-height: 48px; @@ -282,168 +283,6 @@ $small-breakpoint: 960px; } .landing-page { - .grid { - display: grid; - grid-gap: 10px; - grid-template-columns: 1fr 2fr; - grid-auto-columns: 25%; - grid-auto-rows: max-content; - - .column-0 { - display: none; - } - - .column-1 { - grid-column: 1; - grid-row: 1; - } - - .column-2 { - grid-column: 2; - grid-row: 1; - } - - .column-3 { - grid-column: 3; - grid-row: 1 / 3; - } - - .column-4 { - grid-column: 1 / 3; - grid-row: 2; - } - } - - @media screen and (max-width: $small-breakpoint) { - .grid { - grid-template-columns: 40% 60%; - - .column-0 { - display: none; - } - - .column-1 { - grid-column: 1; - grid-row: 1; - - &.non-preview .landing-page__forms { - height: 100%; - } - } - - .column-2 { - grid-column: 2; - grid-row: 1 / 3; - - &.non-preview { - grid-column: 2; - grid-row: 1; - } - } - - .column-3 { - grid-column: 1; - grid-row: 2 / 4; - } - - .column-4 { - grid-column: 2; - grid-row: 3; - - &.non-preview { - grid-column: 1 / 3; - grid-row: 2; - } - } - } - } - - @media screen and (max-width: $column-breakpoint) { - .grid { - grid-template-columns: 100%; - - .column-0 { - display: block; - grid-column: 1; - grid-row: 1; - } - - .column-1 { - grid-column: 1; - grid-row: 3; - - .brand { - display: none; - } - } - - .column-2 { - grid-column: 1; - grid-row: 2; - - .landing-page__logo, - .landing-page__call-to-action { - display: none; - } - - &.non-preview { - grid-column: 1; - grid-row: 2; - } - } - - .column-3 { - grid-column: 1; - grid-row: 5; - } - - .column-4 { - grid-column: 1; - grid-row: 4; - - &.non-preview { - grid-column: 1; - grid-row: 4; - } - } - } - } - - .column-flex { - display: flex; - flex-direction: column; - } - - .separator-or { - position: relative; - margin: 40px 0; - text-align: center; - - &::before { - content: ""; - display: block; - width: 100%; - height: 0; - border-bottom: 1px solid rgba($ui-base-lighter-color, .6); - position: absolute; - top: 50%; - left: 0; - } - - span { - display: inline-block; - background: $ui-base-color; - font-size: 12px; - font-weight: 500; - color: $darker-text-color; - text-transform: uppercase; - position: relative; - z-index: 1; - padding: 0 8px; - cursor: default; - } - } - p, li { font-family: $font-sans-serif, sans-serif; @@ -460,28 +299,6 @@ $small-breakpoint: 960px; } } - .closed-registrations-message { - margin-top: 20px; - - &, - p { - text-align: center; - font-size: 12px; - line-height: 18px; - color: $darker-text-color; - margin-bottom: 0; - - a { - color: $highlight-text-color; - text-decoration: underline; - } - } - - p:last-child { - margin-bottom: 0; - } - } - em { display: inline; margin: 0; @@ -595,187 +412,6 @@ $small-breakpoint: 960px; } } - .container-alt { - width: 100%; - box-sizing: border-box; - max-width: 800px; - margin: 0 auto; - word-wrap: break-word; - } - - .header-wrapper { - padding-top: 15px; - background: $ui-base-color; - background: linear-gradient(150deg, lighten($ui-base-color, 8%), $ui-base-color); - position: relative; - - &.compact { - background: $ui-base-color; - padding-bottom: 15px; - - .hero .heading { - padding-bottom: 20px; - font-family: $font-sans-serif, sans-serif; - font-size: 16px; - font-weight: 400; - font-size: 16px; - line-height: 30px; - color: $darker-text-color; - - a { - color: $highlight-text-color; - text-decoration: underline; - } - } - } - } - - .brand { - a { - padding-left: 0; - padding-right: 0; - color: $white; - } - - img { - height: 32px; - position: relative; - top: 4px; - left: -10px; - } - } - - .header { - line-height: 30px; - overflow: hidden; - - .container-alt { - display: flex; - justify-content: space-between; - } - - .links { - position: relative; - z-index: 4; - - a { - display: flex; - justify-content: center; - align-items: center; - color: $darker-text-color; - text-decoration: none; - padding: 12px 16px; - line-height: 32px; - font-family: $font-display, sans-serif; - font-weight: 500; - font-size: 14px; - - &:hover { - color: $secondary-text-color; - } - } - - ul { - list-style: none; - margin: 0; - - li { - display: inline-block; - vertical-align: bottom; - margin: 0; - - &:first-child a { - padding-left: 0; - } - - &:last-child a { - padding-right: 0; - } - } - } - } - - .hero { - margin-top: 50px; - align-items: center; - position: relative; - - .heading { - position: relative; - z-index: 4; - padding-bottom: 150px; - } - - .simple_form, - .closed-registrations-message { - background: darken($ui-base-color, 4%); - width: 280px; - padding: 15px 20px; - border-radius: 4px 4px 0 0; - line-height: initial; - position: relative; - z-index: 4; - - .actions { - margin-bottom: 0; - - button, - .button, - .block-button { - margin-bottom: 0; - } - } - } - - .closed-registrations-message { - min-height: 330px; - display: flex; - flex-direction: column; - justify-content: space-between; - } - } - } - - .about-short { - background: darken($ui-base-color, 4%); - padding: 50px 0 30px; - font-family: $font-sans-serif, sans-serif; - font-size: 16px; - font-weight: 400; - font-size: 16px; - line-height: 30px; - color: $darker-text-color; - - a { - color: $highlight-text-color; - text-decoration: underline; - } - } - - &.alternative { - padding: 10px 0; - - .brand { - text-align: center; - padding: 30px 0; - margin-bottom: 10px; - - img { - position: static; - padding: 10px 0; - } - - @media screen and (max-width: $small-breakpoint) { - padding: 15px 0; - } - - @media screen and (max-width: $column-breakpoint) { - padding: 0; - margin-bottom: -10px; - } - } - } - &__information, &__forms { padding: 20px; @@ -970,353 +606,253 @@ $small-breakpoint: 960px; } } - &__forms { - height: 100%; - - @media screen and (max-width: $small-breakpoint) { - height: auto; - } - - @media screen and (max-width: $column-breakpoint) { - background: transparent; - box-shadow: none; - padding: 0 20px; - margin-top: 30px; - margin-bottom: 40px; - - .separator-or { - span { - background: darken($ui-base-color, 8%); - } + @media screen and (max-width: 840px) { + .information-board { + .container-alt { + padding-right: 20px; } - } - - hr { - margin: 40px 0; - } - .button { - display: block; - } - - .subtle-hint a { - text-decoration: none; + .panel { + position: static; + margin-top: 20px; + width: 100%; + border-radius: 4px; - &:hover, - &:focus, - &:active { - text-decoration: underline; + .panel-header { + text-align: center; + } } } } - #mastodon-timeline { - display: flex; - -webkit-overflow-scrolling: touch; - -ms-overflow-style: -ms-autohiding-scrollbar; - font-family: $font-sans-serif, sans-serif; - font-size: 13px; - line-height: 18px; - font-weight: 400; - color: $primary-text-color; - width: 100%; - flex: 1 1 auto; - overflow: hidden; - height: 100%; - - .column-header { - color: inherit; - font-family: inherit; - font-size: 16px; - line-height: inherit; - font-weight: inherit; - margin: 0; - padding: 0; - } - - .column { - padding: 0; - border-radius: 4px; - overflow: hidden; - width: 100%; - } - - .scrollable { - height: 400px; - } - - p { - font-size: inherit; - line-height: inherit; - font-weight: inherit; - color: $primary-text-color; - margin-bottom: 20px; - - &:last-child { - margin-bottom: 0; - } + @media screen and (max-width: 675px) { + .header-wrapper { + padding-top: 0; - a { - color: $secondary-text-color; - text-decoration: none; + &.compact { + padding-bottom: 0; } - } - - .attachment-list__list { - margin-left: 0; - list-style: none; - - li { - font-size: inherit; - line-height: inherit; - font-weight: inherit; - margin-bottom: 0; - - a { - color: $dark-text-color; - text-decoration: none; - &:hover { - text-decoration: underline; - } - } + &.compact .hero .heading { + text-align: initial; } } - @media screen and (max-width: $column-breakpoint) { - display: none; + .header .container-alt, + .features .container-alt { + display: block; } } - &__features { - & > p { - padding-right: 60px; - } - - .features-list { - margin: 40px 0; - margin-top: 30px; - } - - &__action { - text-align: center; - } + .cta { + margin: 20px; } +} - .features-list { - .features-list__row { - display: flex; - padding: 10px 0; - justify-content: space-between; - - .visual { - flex: 0 0 auto; - display: flex; - align-items: center; - margin-left: 15px; +.landing { + margin-bottom: 100px; - .fa { - display: block; - color: $darker-text-color; - font-size: 48px; - } - } + @media screen and (max-width: 738px) { + margin-bottom: 0; + } - .text { - font-size: 16px; - line-height: 30px; - color: $darker-text-color; + &__brand { + display: flex; + justify-content: center; + align-items: center; + padding: 100px; - h6 { - font-size: inherit; - line-height: inherit; - margin-bottom: 0; - } - } + img { + height: 52px; } - @media screen and (min-width: $small-breakpoint) { - display: grid; - grid-gap: 30px; - grid-template-columns: 1fr 1fr; - grid-auto-columns: 50%; - grid-auto-rows: max-content; + @media screen and (max-width: $no-gap-breakpoint) { + padding: 0; + margin-bottom: 30px; } } - .footer-links { - padding-bottom: 50px; - text-align: right; - color: $dark-text-color; + .directory { + margin-top: 30px; + background: transparent; + box-shadow: none; + border-radius: 0; + } - p { - font-size: 14px; - } + .hero-widget { + margin-top: 30px; + margin-bottom: 0; - a { - color: inherit; - text-decoration: underline; + h4 { + padding: 10px; + text-transform: uppercase; + font-weight: 700; + font-size: 13px; + color: $darker-text-color; } - } - &__footer { - margin-top: 10px; - text-align: center; - color: $dark-text-color; + &__text { + border-radius: 0; + padding-bottom: 0; + } - p { - font-size: 14px; + &__footer { + background: $ui-base-color; + padding: 10px; + border-radius: 0 0 4px 4px; + display: flex; - a { - color: inherit; - text-decoration: underline; + &__column { + flex: 1 1 50%; } } - } - @media screen and (max-width: 840px) { - .container-alt { - padding: 0 20px; - } + .account { + padding: 10px 0; + border-bottom: 0; - .information-board { - .container-alt { - padding-right: 20px; + .account__display-name { + display: flex; + align-items: center; } - .panel { - position: static; - margin-top: 20px; - width: 100%; - border-radius: 4px; - - .panel-header { - text-align: center; - } + .account__avatar { + width: 44px; + height: 44px; + background-size: 44px 44px; } } - } - @media screen and (max-width: 675px) { - .header-wrapper { - padding-top: 0; + &__counter { + padding: 10px; - &.compact { - padding-bottom: 0; + strong { + font-family: $font-display, sans-serif; + font-size: 15px; + font-weight: 700; + display: block; } - &.compact .hero .heading { - text-align: initial; + span { + font-size: 14px; + color: $darker-text-color; } } + } - .header .container-alt, - .features .container-alt { - display: block; - } - - .header { - .links { - padding-top: 15px; - background: darken($ui-base-color, 4%); + .simple_form .user_agreement .label_input > label { + font-weight: 400; + color: $darker-text-color; + } - a { - padding: 12px 8px; - } + .simple_form p.lead { + color: $darker-text-color; + font-size: 15px; + line-height: 20px; + font-weight: 400; + margin-bottom: 25px; + } - .nav { - display: flex; - flex-flow: row wrap; - justify-content: space-around; - } + &__grid { + max-width: 960px; + margin: 0 auto; + display: grid; + grid-template-columns: minmax(0, 50%) minmax(0, 50%); + grid-gap: 30px; - .brand img { - left: 0; - top: 0; - } - } + @media screen and (max-width: 738px) { + grid-template-columns: minmax(0, 100%); + grid-gap: 10px; - .hero { - margin-top: 30px; - padding: 0; + &__column-login { + grid-row: 1; + display: flex; + flex-direction: column; - .heading { - padding: 30px 20px; - text-align: center; + .box-widget { + order: 2; + flex: 0 0 auto; } - .simple_form, - .closed-registrations-message { - background: darken($ui-base-color, 8%); - width: 100%; - border-radius: 0; - box-sizing: border-box; + .hero-widget { + margin-top: 0; + margin-bottom: 10px; + order: 1; + flex: 0 0 auto; } } - } - } - - .cta { - margin: 20px; - } - &.tag-page { - @media screen and (max-width: $column-breakpoint) { - padding: 0; - - .container { - padding: 0; + &__column-registration { + grid-row: 2; } - #mastodon-timeline { - display: flex; - height: 100vh; - border-radius: 0; + .directory { + margin-top: 10px; } } - .grid { - @media screen and (min-width: $small-breakpoint) { - grid-template-columns: 33% 67%; - } - - .column-2 { - grid-column: 2; - grid-row: 1; - } - } + @media screen and (max-width: $no-gap-breakpoint) { + grid-gap: 0; - .brand { - text-align: unset; - padding: 0; + .hero-widget { + display: block; + margin-bottom: 0; + box-shadow: none; - img { - height: 48px; - width: auto; + &__img, + &__img img, + &__footer { + border-radius: 0; + } } - } - .cta { - margin: 0; - - .button { - margin-right: 4px; + .hero-widget, + .box-widget, + .directory__tag { + border-bottom: 1px solid lighten($ui-base-color, 8%); } - } - @media screen and (max-width: $column-breakpoint) { - .grid { - grid-gap: 0; + .directory { + margin-top: 0; - .column-1 { - grid-column: 1; - grid-row: 1; - } + &__tag { + margin-bottom: 0; - .column-2 { - display: none; + & > a, + & > div { + border-radius: 0; + box-shadow: none; + } + + &:last-child { + border-bottom: 0; + } } } } } } + +.brand { + position: relative; + text-decoration: none; +} + +.brand__tagline { + display: block; + position: absolute; + bottom: -10px; + left: 50px; + width: 300px; + color: $ui-primary-color; + text-decoration: none; + font-size: 14px; + + @media screen and (max-width: $no-gap-breakpoint) { + position: static; + width: auto; + margin-top: 20px; + color: $dark-text-color; + } +} + diff --git a/app/javascript/flavours/glitch/styles/admin.scss b/app/javascript/flavours/glitch/styles/admin.scss index 4dbbaa1e8..42f53f525 100644 --- a/app/javascript/flavours/glitch/styles/admin.scss +++ b/app/javascript/flavours/glitch/styles/admin.scss @@ -705,3 +705,11 @@ a.name-tag, overflow: hidden; text-overflow: ellipsis; } + +.ellipsized-ip { + display: inline-block; + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: middle; +} diff --git a/app/javascript/flavours/glitch/styles/forms.scss b/app/javascript/flavours/glitch/styles/forms.scss index bab982706..6051c1d00 100644 --- a/app/javascript/flavours/glitch/styles/forms.scss +++ b/app/javascript/flavours/glitch/styles/forms.scss @@ -68,6 +68,17 @@ code { top: 2px; left: 0; } + + label a { + color: $highlight-text-color; + text-decoration: underline; + + &:hover, + &:active, + &:focus { + text-decoration: none; + } + } } } @@ -305,7 +316,7 @@ code { box-shadow: none; } - &:focus:invalid { + &:focus:invalid:not(:placeholder-shown) { border-color: lighten($error-red, 12%); } @@ -346,6 +357,10 @@ code { } } + .input.disabled { + opacity: 0.5; + } + .actions { margin-top: 30px; display: flex; @@ -392,6 +407,10 @@ code { background-color: darken($ui-highlight-color, 5%); } + &:disabled:hover { + background-color: $ui-primary-color; + } + &.negative { background: $error-value-color; diff --git a/app/javascript/flavours/glitch/styles/polls.scss b/app/javascript/flavours/glitch/styles/polls.scss index 4f8c94d83..6030e1e98 100644 --- a/app/javascript/flavours/glitch/styles/polls.scss +++ b/app/javascript/flavours/glitch/styles/polls.scss @@ -144,6 +144,7 @@ button, select { + width: 100%; flex: 1 1 50%; } } @@ -190,3 +191,15 @@ color: darken($simple-background-color, 14%); } } + +.muted .poll { + color: $dark-text-color; + + &__chart { + background: rgba(darken($ui-primary-color, 14%), 0.2); + + &.leading { + background: rgba($ui-highlight-color, 0.2); + } + } +} diff --git a/app/javascript/flavours/glitch/styles/tables.scss b/app/javascript/flavours/glitch/styles/tables.scss index 9fd0b95bb..296182ff5 100644 --- a/app/javascript/flavours/glitch/styles/tables.scss +++ b/app/javascript/flavours/glitch/styles/tables.scss @@ -82,6 +82,10 @@ } } } + + &--invites tbody td { + vertical-align: middle; + } } .table-wrapper { diff --git a/app/javascript/flavours/glitch/styles/widgets.scss b/app/javascript/flavours/glitch/styles/widgets.scss index 1eaf30c5b..645192ea4 100644 --- a/app/javascript/flavours/glitch/styles/widgets.scss +++ b/app/javascript/flavours/glitch/styles/widgets.scss @@ -295,6 +295,11 @@ cursor: default; } + &.disabled > div { + opacity: 0.5; + cursor: default; + } + h4 { flex: 1 1 auto; font-size: 18px; diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js index 4c145febc..61fef19e9 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -92,7 +92,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) { const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS(); const excludeTypesFromFilter = filter => { - const allTypes = ImmutableList(['follow', 'favourite', 'reblog', 'mention']); + const allTypes = ImmutableList(['follow', 'favourite', 'reblog', 'mention', 'poll']); return allTypes.filterNot(item => item === filter).toJS(); }; diff --git a/app/javascript/mastodon/containers/timeline_container.js b/app/javascript/mastodon/containers/timeline_container.js index a1a4bd024..54f8eb310 100644 --- a/app/javascript/mastodon/containers/timeline_container.js +++ b/app/javascript/mastodon/containers/timeline_container.js @@ -7,7 +7,6 @@ import { hydrateStore } from '../actions/store'; import { IntlProvider, addLocaleData } from 'react-intl'; import { getLocale } from '../locales'; import PublicTimeline from '../features/standalone/public_timeline'; -import CommunityTimeline from '../features/standalone/community_timeline'; import HashtagTimeline from '../features/standalone/hashtag_timeline'; import ModalContainer from '../features/ui/containers/modal_container'; import initialState from '../initial_state'; @@ -26,24 +25,22 @@ export default class TimelineContainer extends React.PureComponent { static propTypes = { locale: PropTypes.string.isRequired, hashtag: PropTypes.string, - showPublicTimeline: PropTypes.bool.isRequired, + local: PropTypes.bool, }; static defaultProps = { - showPublicTimeline: initialState.settings.known_fediverse, + local: !initialState.settings.known_fediverse, }; render () { - const { locale, hashtag, showPublicTimeline } = this.props; + const { locale, hashtag, local } = this.props; let timeline; if (hashtag) { timeline = <HashtagTimeline hashtag={hashtag} />; - } else if (showPublicTimeline) { - timeline = <PublicTimeline />; } else { - timeline = <CommunityTimeline />; + timeline = <PublicTimeline local={local} />; } return ( @@ -51,6 +48,7 @@ export default class TimelineContainer extends React.PureComponent { <Provider store={store}> <Fragment> {timeline} + {ReactDOM.createPortal( <ModalContainer />, document.getElementById('modal-container'), diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.js b/app/javascript/mastodon/features/notifications/components/column_settings.js index a334fd63c..60a86312a 100644 --- a/app/javascript/mastodon/features/notifications/components/column_settings.js +++ b/app/javascript/mastodon/features/notifications/components/column_settings.js @@ -89,6 +89,17 @@ export default class ColumnSettings extends React.PureComponent { <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'reblog']} onChange={onChange} label={soundStr} /> </div> </div> + + <div role='group' aria-labelledby='notifications-poll'> + <span id='notifications-poll' className='column-settings__section'><FormattedMessage id='notifications.column_settings.poll' defaultMessage='Poll results:' /></span> + + <div className='column-settings__row'> + <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'poll']} onChange={onChange} label={alertStr} /> + {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'poll']} onChange={this.onPushChange} label={pushStr} />} + <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'poll']} onChange={onChange} label={showStr} /> + <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'poll']} onChange={onChange} label={soundStr} /> + </div> + </div> </div> ); } diff --git a/app/javascript/mastodon/features/notifications/components/filter_bar.js b/app/javascript/mastodon/features/notifications/components/filter_bar.js index 6ae8b7491..3f3e6ab7d 100644 --- a/app/javascript/mastodon/features/notifications/components/filter_bar.js +++ b/app/javascript/mastodon/features/notifications/components/filter_bar.js @@ -7,6 +7,7 @@ const tooltips = defineMessages({ mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' }, favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Favourites' }, boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' }, + polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' }, follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' }, }); @@ -80,6 +81,13 @@ class FilterBar extends React.PureComponent { <Icon id='retweet' fixedWidth /> </button> <button + className={selectedFilter === 'poll' ? 'active' : ''} + onClick={this.onClick('poll')} + title={intl.formatMessage(tooltips.polls)} + > + <Icon id='tasks' fixedWidth /> + </button> + <button className={selectedFilter === 'follow' ? 'active' : ''} onClick={this.onClick('follow')} title={intl.formatMessage(tooltips.follows)} diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js index 9669b6e7d..4bdf09166 100644 --- a/app/javascript/mastodon/features/notifications/components/notification.js +++ b/app/javascript/mastodon/features/notifications/components/notification.js @@ -205,6 +205,38 @@ class Notification extends ImmutablePureComponent { ); } + renderPoll (notification) { + const { intl } = this.props; + + return ( + <HotKeys handlers={this.getHandlers()}> + <div className='notification notification-poll focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage({ id: 'notification.poll', defaultMessage: 'A poll you have voted in has ended' }), notification.get('created_at'))}> + <div className='notification__message'> + <div className='notification__favourite-icon-wrapper'> + <Icon id='tasks' fixedWidth /> + </div> + + <span title={notification.get('created_at')}> + <FormattedMessage id='notification.poll' defaultMessage='A poll you have voted in has ended' /> + </span> + </div> + + <StatusContainer + id={notification.get('status')} + account={notification.get('account')} + muted + withDismiss + hidden={this.props.hidden} + getScrollPosition={this.props.getScrollPosition} + updateScrollBottom={this.props.updateScrollBottom} + cachedMediaWidth={this.props.cachedMediaWidth} + cacheMediaWidth={this.props.cacheMediaWidth} + /> + </div> + </HotKeys> + ); + } + render () { const { notification } = this.props; const account = notification.get('account'); @@ -220,6 +252,8 @@ class Notification extends ImmutablePureComponent { return this.renderFavourite(notification, link); case 'reblog': return this.renderReblog(notification, link); + case 'poll': + return this.renderPoll(notification); } return null; diff --git a/app/javascript/mastodon/features/standalone/community_timeline/index.js b/app/javascript/mastodon/features/standalone/community_timeline/index.js deleted file mode 100644 index f917f41c9..000000000 --- a/app/javascript/mastodon/features/standalone/community_timeline/index.js +++ /dev/null @@ -1,71 +0,0 @@ -import React from 'react'; -import { connect } from 'react-redux'; -import PropTypes from 'prop-types'; -import StatusListContainer from '../../ui/containers/status_list_container'; -import { expandCommunityTimeline } from '../../../actions/timelines'; -import Column from '../../../components/column'; -import ColumnHeader from '../../../components/column_header'; -import { defineMessages, injectIntl } from 'react-intl'; -import { connectCommunityStream } from '../../../actions/streaming'; - -const messages = defineMessages({ - title: { id: 'standalone.public_title', defaultMessage: 'A look inside...' }, -}); - -export default @connect() -@injectIntl -class CommunityTimeline extends React.PureComponent { - - static propTypes = { - dispatch: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, - }; - - handleHeaderClick = () => { - this.column.scrollTop(); - } - - setRef = c => { - this.column = c; - } - - componentDidMount () { - const { dispatch } = this.props; - - dispatch(expandCommunityTimeline()); - this.disconnect = dispatch(connectCommunityStream()); - } - - componentWillUnmount () { - if (this.disconnect) { - this.disconnect(); - this.disconnect = null; - } - } - - handleLoadMore = maxId => { - this.props.dispatch(expandCommunityTimeline({ maxId })); - } - - render () { - const { intl } = this.props; - - return ( - <Column ref={this.setRef} label={intl.formatMessage(messages.title)}> - <ColumnHeader - icon='users' - title={intl.formatMessage(messages.title)} - onClick={this.handleHeaderClick} - /> - - <StatusListContainer - timelineId='community' - onLoadMore={this.handleLoadMore} - scrollKey='standalone_public_timeline' - trackScroll={false} - /> - </Column> - ); - } - -} diff --git a/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js b/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js index 333726f94..0880d98c8 100644 --- a/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js +++ b/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js @@ -2,13 +2,13 @@ import React from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import { expandHashtagTimeline } from '../../../actions/timelines'; -import { connectHashtagStream } from '../../../actions/streaming'; +import { expandHashtagTimeline } from 'mastodon/actions/timelines'; +import { connectHashtagStream } from 'mastodon/actions/streaming'; import Masonry from 'react-masonry-infinite'; import { List as ImmutableList } from 'immutable'; -import DetailedStatusContainer from '../../status/containers/detailed_status_container'; +import DetailedStatusContainer from 'mastodon/features/status/containers/detailed_status_container'; import { debounce } from 'lodash'; -import LoadingIndicator from '../../../components/loading_indicator'; +import LoadingIndicator from 'mastodon/components/loading_indicator'; const mapStateToProps = (state, { hashtag }) => ({ statusIds: state.getIn(['timelines', `hashtag:${hashtag}`, 'items'], ImmutableList()), diff --git a/app/javascript/mastodon/features/standalone/public_timeline/index.js b/app/javascript/mastodon/features/standalone/public_timeline/index.js index 618696eb1..10129e606 100644 --- a/app/javascript/mastodon/features/standalone/public_timeline/index.js +++ b/app/javascript/mastodon/features/standalone/public_timeline/index.js @@ -1,70 +1,112 @@ import React from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; -import StatusListContainer from '../../ui/containers/status_list_container'; -import { expandPublicTimeline } from '../../../actions/timelines'; -import Column from '../../../components/column'; -import ColumnHeader from '../../../components/column_header'; -import { defineMessages, injectIntl } from 'react-intl'; -import { connectPublicStream } from '../../../actions/streaming'; - -const messages = defineMessages({ - title: { id: 'standalone.public_title', defaultMessage: 'A look inside...' }, -}); - -export default @connect() -@injectIntl +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { expandPublicTimeline, expandCommunityTimeline } from 'mastodon/actions/timelines'; +import { connectPublicStream, connectCommunityStream } from 'mastodon/actions/streaming'; +import Masonry from 'react-masonry-infinite'; +import { List as ImmutableList, Map as ImmutableMap } from 'immutable'; +import DetailedStatusContainer from 'mastodon/features/status/containers/detailed_status_container'; +import { debounce } from 'lodash'; +import LoadingIndicator from 'mastodon/components/loading_indicator'; + +const mapStateToProps = (state, { local }) => { + const timeline = state.getIn(['timelines', local ? 'community' : 'public'], ImmutableMap()); + + return { + statusIds: timeline.get('items', ImmutableList()), + isLoading: timeline.get('isLoading', false), + hasMore: timeline.get('hasMore', false), + }; +}; + +export default @connect(mapStateToProps) class PublicTimeline extends React.PureComponent { static propTypes = { dispatch: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, + statusIds: ImmutablePropTypes.list.isRequired, + isLoading: PropTypes.bool.isRequired, + hasMore: PropTypes.bool.isRequired, + local: PropTypes.bool, }; - handleHeaderClick = () => { - this.column.scrollTop(); + componentDidMount () { + this._connect(); } - setRef = c => { - this.column = c; + componentDidUpdate (prevProps) { + if (prevProps.local !== this.props.local) { + this._disconnect(); + this._connect(); + } } - componentDidMount () { - const { dispatch } = this.props; + componentWillUnmount () { + this._disconnect(); + } + + _connect () { + const { dispatch, local } = this.props; - dispatch(expandPublicTimeline()); - this.disconnect = dispatch(connectPublicStream()); + dispatch(local ? expandCommunityTimeline() : expandPublicTimeline()); + this.disconnect = dispatch(local ? connectCommunityStream() : connectPublicStream()); } - componentWillUnmount () { + _disconnect () { if (this.disconnect) { this.disconnect(); this.disconnect = null; } } - handleLoadMore = maxId => { - this.props.dispatch(expandPublicTimeline({ maxId })); + handleLoadMore = () => { + const { dispatch, statusIds, local } = this.props; + const maxId = statusIds.last(); + + if (maxId) { + dispatch(local ? expandCommunityTimeline({ maxId }) : expandPublicTimeline({ maxId })); + } + } + + setRef = c => { + this.masonry = c; } + handleHeightChange = debounce(() => { + if (!this.masonry) { + return; + } + + this.masonry.forcePack(); + }, 50) + render () { - const { intl } = this.props; + const { statusIds, hasMore, isLoading } = this.props; + + const sizes = [ + { columns: 1, gutter: 0 }, + { mq: '415px', columns: 1, gutter: 10 }, + { mq: '640px', columns: 2, gutter: 10 }, + { mq: '960px', columns: 3, gutter: 10 }, + { mq: '1255px', columns: 3, gutter: 10 }, + ]; + + const loader = (isLoading && statusIds.isEmpty()) ? <LoadingIndicator key={0} /> : undefined; return ( - <Column ref={this.setRef} label={intl.formatMessage(messages.title)}> - <ColumnHeader - icon='globe' - title={intl.formatMessage(messages.title)} - onClick={this.handleHeaderClick} - /> - - <StatusListContainer - timelineId='public' - onLoadMore={this.handleLoadMore} - scrollKey='standalone_public_timeline' - trackScroll={false} - /> - </Column> + <Masonry ref={this.setRef} className='statuses-grid' hasMore={hasMore} loadMore={this.handleLoadMore} sizes={sizes} loader={loader}> + {statusIds.map(statusId => ( + <div className='statuses-grid__item' key={statusId}> + <DetailedStatusContainer + id={statusId} + compact + measureHeight + onHeightChange={this.handleHeightChange} + /> + </div> + )).toArray()} + </Masonry> ); } diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js index 5cd50f055..5c79f9f19 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.js +++ b/app/javascript/mastodon/features/status/components/detailed_status.js @@ -23,7 +23,7 @@ export default class DetailedStatus extends ImmutablePureComponent { }; static propTypes = { - status: ImmutablePropTypes.map.isRequired, + status: ImmutablePropTypes.map, onOpenMedia: PropTypes.func.isRequired, onOpenVideo: PropTypes.func.isRequired, onToggleHidden: PropTypes.func.isRequired, diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json index 868e54751..78a5648af 100644 --- a/app/javascript/mastodon/locales/defaultMessages.json +++ b/app/javascript/mastodon/locales/defaultMessages.json @@ -17,6 +17,10 @@ { "defaultMessage": "File upload limit exceeded.", "id": "upload_error.limit" + }, + { + "defaultMessage": "File upload not allowed with polls.", + "id": "upload_error.poll" } ], "path": "app/javascript/mastodon/actions/compose.json" @@ -910,6 +914,52 @@ { "descriptors": [ { + "defaultMessage": "Add a poll", + "id": "poll_button.add_poll" + }, + { + "defaultMessage": "Remove poll", + "id": "poll_button.remove_poll" + } + ], + "path": "app/javascript/mastodon/features/compose/components/poll_button.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Choice {number}", + "id": "compose_form.poll.option_placeholder" + }, + { + "defaultMessage": "Add a choice", + "id": "compose_form.poll.add_option" + }, + { + "defaultMessage": "Remove this choice", + "id": "compose_form.poll.remove_option" + }, + { + "defaultMessage": "Poll duration", + "id": "compose_form.poll.duration" + }, + { + "defaultMessage": "{number, plural, one {# minute} other {# minutes}}", + "id": "intervals.full.minutes" + }, + { + "defaultMessage": "{number, plural, one {# hour} other {# hours}}", + "id": "intervals.full.hours" + }, + { + "defaultMessage": "{number, plural, one {# day} other {# days}}", + "id": "intervals.full.days" + } + ], + "path": "app/javascript/mastodon/features/compose/components/poll_form.json" + }, + { + "descriptors": [ + { "defaultMessage": "Public", "id": "privacy.public.short" }, @@ -1853,6 +1903,10 @@ { "defaultMessage": "{name} boosted your status", "id": "notification.reblog" + }, + { + "defaultMessage": "Your poll has ended", + "id": "notification.poll" } ], "path": "app/javascript/mastodon/features/notifications/components/notification.json" @@ -1917,24 +1971,6 @@ { "descriptors": [ { - "defaultMessage": "A look inside...", - "id": "standalone.public_title" - } - ], - "path": "app/javascript/mastodon/features/standalone/community_timeline/index.json" - }, - { - "descriptors": [ - { - "defaultMessage": "A look inside...", - "id": "standalone.public_title" - } - ], - "path": "app/javascript/mastodon/features/standalone/public_timeline/index.json" - }, - { - "descriptors": [ - { "defaultMessage": "Delete", "id": "status.delete" }, diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index ae37322e8..eb82cd9a9 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -77,6 +77,10 @@ "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.", "compose_form.lock_disclaimer.lock": "locked", "compose_form.placeholder": "What's on your mind?", + "compose_form.poll.add_option": "Add a choice", + "compose_form.poll.duration": "Poll duration", + "compose_form.poll.option_placeholder": "Choice {number}", + "compose_form.poll.remove_option": "Remove this choice", "compose_form.publish": "Toot", "compose_form.publish_loud": "{publish}!", "compose_form.sensitive.marked": "Media is marked as sensitive", @@ -155,6 +159,9 @@ "home.column_settings.basic": "Basic", "home.column_settings.show_reblogs": "Show boosts", "home.column_settings.show_replies": "Show replies", + "intervals.full.days": "{number, plural, one {# day} other {# days}}", + "intervals.full.hours": "{number, plural, one {# hour} other {# hours}}", + "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}", "introduction.federation.action": "Next", "introduction.federation.federated.headline": "Federated", "introduction.federation.federated.text": "Public posts from other servers of the fediverse will appear in the federated timeline.", @@ -245,6 +252,7 @@ "notification.favourite": "{name} favourited your status", "notification.follow": "{name} followed you", "notification.mention": "{name} mentioned you", + "notification.poll": "A poll you have voted in has ended", "notification.reblog": "{name} boosted your status", "notifications.clear": "Clear notifications", "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?", @@ -269,6 +277,8 @@ "poll.refresh": "Refresh", "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", "poll.vote": "Vote", + "poll_button.add_poll": "Add a poll", + "poll_button.remove_poll": "Remove poll", "privacy.change": "Adjust status privacy", "privacy.direct.long": "Post to mentioned users only", "privacy.direct.short": "Direct", @@ -303,7 +313,6 @@ "search_results.hashtags": "Hashtags", "search_results.statuses": "Toots", "search_results.total": "{count, number} {count, plural, one {result} other {results}}", - "standalone.public_title": "A look inside...", "status.admin_account": "Open moderation interface for @{name}", "status.admin_status": "Open this status in the moderation interface", "status.block": "Block @{name}", @@ -361,6 +370,7 @@ "upload_area.title": "Drag & drop to upload", "upload_button.label": "Add media (JPEG, PNG, GIF, WebM, MP4, MOV)", "upload_error.limit": "File upload limit exceeded.", + "upload_error.poll": "File upload not allowed with polls.", "upload_form.description": "Describe for the visually impaired", "upload_form.focus": "Change preview", "upload_form.undo": "Delete", diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json index e2d3a98f3..6388c7e9c 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -77,6 +77,10 @@ "compose_form.lock_disclaimer": "あなたのアカウントは{locked}になっていません。誰でもあなたをフォローすることができ、フォロワー限定の投稿を見ることができます。", "compose_form.lock_disclaimer.lock": "承認制", "compose_form.placeholder": "今なにしてる?", + "compose_form.poll.add_option": "Add a choice", + "compose_form.poll.duration": "Poll duration", + "compose_form.poll.option_placeholder": "Choice {number}", + "compose_form.poll.remove_option": "Remove this choice", "compose_form.publish": "トゥート", "compose_form.publish_loud": "{publish}!", "compose_form.sensitive.marked": "メディアに閲覧注意が設定されています", @@ -155,6 +159,9 @@ "home.column_settings.basic": "基本設定", "home.column_settings.show_reblogs": "ブースト表示", "home.column_settings.show_replies": "返信表示", + "intervals.full.days": "{number, plural, one {# day} other {# days}}", + "intervals.full.hours": "{number, plural, one {# hour} other {# hours}}", + "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}", "introduction.federation.action": "次へ", "introduction.federation.federated.headline": "連合タイムライン", "introduction.federation.federated.text": "Fediverseの他のサーバーからの公開投稿は連合タイムラインに表示されます。", @@ -245,6 +252,7 @@ "notification.favourite": "{name}さんがあなたのトゥートをお気に入りに登録しました", "notification.follow": "{name}さんにフォローされました", "notification.mention": "{name}さんがあなたに返信しました", + "notification.poll": "Your poll has ended", "notification.reblog": "{name}さんがあなたのトゥートをブーストしました", "notifications.clear": "通知を消去", "notifications.clear_confirmation": "本当に通知を消去しますか?", @@ -269,6 +277,8 @@ "poll.refresh": "Refresh", "poll.total_votes": "{count, plural, one {# vote} other {# votes}}", "poll.vote": "Vote", + "poll_button.add_poll": "Add a poll", + "poll_button.remove_poll": "Remove poll", "privacy.change": "公開範囲を変更", "privacy.direct.long": "メンションしたユーザーだけに公開", "privacy.direct.short": "ダイレクト", @@ -303,7 +313,6 @@ "search_results.hashtags": "ハッシュタグ", "search_results.statuses": "トゥート", "search_results.total": "{count, number}件の結果", - "standalone.public_title": "今こんな話をしています...", "status.admin_account": "@{name} のモデレーション画面を開く", "status.admin_status": "このトゥートをモデレーション画面で開く", "status.block": "@{name}さんをブロック", @@ -361,6 +370,7 @@ "upload_area.title": "ドラッグ&ドロップでアップロード", "upload_button.label": "メディアを追加 (JPEG, PNG, GIF, WebM, MP4, MOV)", "upload_error.limit": "アップロードできる上限を超えています。", + "upload_error.poll": "File upload not allowed with polls.", "upload_form.description": "視覚障害者のための説明", "upload_form.focus": "焦点", "upload_form.undo": "削除", diff --git a/app/javascript/mastodon/reducers/push_notifications.js b/app/javascript/mastodon/reducers/push_notifications.js index 85628c6b1..317352b79 100644 --- a/app/javascript/mastodon/reducers/push_notifications.js +++ b/app/javascript/mastodon/reducers/push_notifications.js @@ -9,6 +9,7 @@ const initialState = Immutable.Map({ favourite: false, reblog: false, mention: false, + poll: false, }), isSubscribed: false, browserSupport: false, diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js index 2e1878cf7..a0eea137f 100644 --- a/app/javascript/mastodon/reducers/settings.js +++ b/app/javascript/mastodon/reducers/settings.js @@ -31,6 +31,7 @@ const initialState = ImmutableMap({ favourite: true, reblog: true, mention: true, + poll: true, }), quickFilter: ImmutableMap({ @@ -44,6 +45,7 @@ const initialState = ImmutableMap({ favourite: true, reblog: true, mention: true, + poll: true, }), sounds: ImmutableMap({ @@ -51,6 +53,7 @@ const initialState = ImmutableMap({ favourite: true, reblog: true, mention: true, + poll: true, }), }), diff --git a/app/javascript/mastodon/service_worker/web_push_locales.js b/app/javascript/mastodon/service_worker/web_push_locales.js index ce96ae297..5ce8c7b50 100644 --- a/app/javascript/mastodon/service_worker/web_push_locales.js +++ b/app/javascript/mastodon/service_worker/web_push_locales.js @@ -18,6 +18,7 @@ filenames.forEach(filename => { 'notification.follow': full['notification.follow'] || '', 'notification.mention': full['notification.mention'] || '', 'notification.reblog': full['notification.reblog'] || '', + 'notification.poll': full['notification.poll'] || '', 'status.show_more': full['status.show_more'] || '', 'status.reblog': full['status.reblog'] || '', diff --git a/app/javascript/styles/mastodon/about.scss b/app/javascript/styles/mastodon/about.scss index b078d4d24..465ef2c11 100644 --- a/app/javascript/styles/mastodon/about.scss +++ b/app/javascript/styles/mastodon/about.scss @@ -193,6 +193,7 @@ $small-breakpoint: 960px; } strong { + font-family: $font-display, sans-serif; font-weight: 500; font-size: 32px; line-height: 48px; @@ -280,168 +281,6 @@ $small-breakpoint: 960px; } .landing-page { - .grid { - display: grid; - grid-gap: 10px; - grid-template-columns: 1fr 2fr; - grid-auto-columns: 25%; - grid-auto-rows: max-content; - - .column-0 { - display: none; - } - - .column-1 { - grid-column: 1; - grid-row: 1; - } - - .column-2 { - grid-column: 2; - grid-row: 1; - } - - .column-3 { - grid-column: 3; - grid-row: 1 / 3; - } - - .column-4 { - grid-column: 1 / 3; - grid-row: 2; - } - } - - @media screen and (max-width: $small-breakpoint) { - .grid { - grid-template-columns: 40% 60%; - - .column-0 { - display: none; - } - - .column-1 { - grid-column: 1; - grid-row: 1; - - &.non-preview .landing-page__forms { - height: 100%; - } - } - - .column-2 { - grid-column: 2; - grid-row: 1 / 3; - - &.non-preview { - grid-column: 2; - grid-row: 1; - } - } - - .column-3 { - grid-column: 1; - grid-row: 2 / 4; - } - - .column-4 { - grid-column: 2; - grid-row: 3; - - &.non-preview { - grid-column: 1 / 3; - grid-row: 2; - } - } - } - } - - @media screen and (max-width: $column-breakpoint) { - .grid { - grid-template-columns: 100%; - - .column-0 { - display: block; - grid-column: 1; - grid-row: 1; - } - - .column-1 { - grid-column: 1; - grid-row: 3; - - .brand { - display: none; - } - } - - .column-2 { - grid-column: 1; - grid-row: 2; - - .landing-page__logo, - .landing-page__call-to-action { - display: none; - } - - &.non-preview { - grid-column: 1; - grid-row: 2; - } - } - - .column-3 { - grid-column: 1; - grid-row: 5; - } - - .column-4 { - grid-column: 1; - grid-row: 4; - - &.non-preview { - grid-column: 1; - grid-row: 4; - } - } - } - } - - .column-flex { - display: flex; - flex-direction: column; - } - - .separator-or { - position: relative; - margin: 40px 0; - text-align: center; - - &::before { - content: ""; - display: block; - width: 100%; - height: 0; - border-bottom: 1px solid rgba($ui-base-lighter-color, .6); - position: absolute; - top: 50%; - left: 0; - } - - span { - display: inline-block; - background: $ui-base-color; - font-size: 12px; - font-weight: 500; - color: $darker-text-color; - text-transform: uppercase; - position: relative; - z-index: 1; - padding: 0 8px; - cursor: default; - } - } - p, li { font-family: $font-sans-serif, sans-serif; @@ -458,28 +297,6 @@ $small-breakpoint: 960px; } } - .closed-registrations-message { - margin-top: 20px; - - &, - p { - text-align: center; - font-size: 12px; - line-height: 18px; - color: $darker-text-color; - margin-bottom: 0; - - a { - color: $highlight-text-color; - text-decoration: underline; - } - } - - p:last-child { - margin-bottom: 0; - } - } - em { display: inline; margin: 0; @@ -593,187 +410,6 @@ $small-breakpoint: 960px; } } - .container-alt { - width: 100%; - box-sizing: border-box; - max-width: 800px; - margin: 0 auto; - word-wrap: break-word; - } - - .header-wrapper { - padding-top: 15px; - background: $ui-base-color; - background: linear-gradient(150deg, lighten($ui-base-color, 8%), $ui-base-color); - position: relative; - - &.compact { - background: $ui-base-color; - padding-bottom: 15px; - - .hero .heading { - padding-bottom: 20px; - font-family: $font-sans-serif, sans-serif; - font-size: 16px; - font-weight: 400; - font-size: 16px; - line-height: 30px; - color: $darker-text-color; - - a { - color: $highlight-text-color; - text-decoration: underline; - } - } - } - } - - .brand { - a { - padding-left: 0; - padding-right: 0; - color: $white; - } - - img { - height: 32px; - position: relative; - top: 4px; - left: -10px; - } - } - - .header { - line-height: 30px; - overflow: hidden; - - .container-alt { - display: flex; - justify-content: space-between; - } - - .links { - position: relative; - z-index: 4; - - a { - display: flex; - justify-content: center; - align-items: center; - color: $darker-text-color; - text-decoration: none; - padding: 12px 16px; - line-height: 32px; - font-family: $font-display, sans-serif; - font-weight: 500; - font-size: 14px; - - &:hover { - color: $secondary-text-color; - } - } - - ul { - list-style: none; - margin: 0; - - li { - display: inline-block; - vertical-align: bottom; - margin: 0; - - &:first-child a { - padding-left: 0; - } - - &:last-child a { - padding-right: 0; - } - } - } - } - - .hero { - margin-top: 50px; - align-items: center; - position: relative; - - .heading { - position: relative; - z-index: 4; - padding-bottom: 150px; - } - - .simple_form, - .closed-registrations-message { - background: darken($ui-base-color, 4%); - width: 280px; - padding: 15px 20px; - border-radius: 4px 4px 0 0; - line-height: initial; - position: relative; - z-index: 4; - - .actions { - margin-bottom: 0; - - button, - .button, - .block-button { - margin-bottom: 0; - } - } - } - - .closed-registrations-message { - min-height: 330px; - display: flex; - flex-direction: column; - justify-content: space-between; - } - } - } - - .about-short { - background: darken($ui-base-color, 4%); - padding: 50px 0 30px; - font-family: $font-sans-serif, sans-serif; - font-size: 16px; - font-weight: 400; - font-size: 16px; - line-height: 30px; - color: $darker-text-color; - - a { - color: $highlight-text-color; - text-decoration: underline; - } - } - - &.alternative { - padding: 10px 0; - - .brand { - text-align: center; - padding: 30px 0; - margin-bottom: 10px; - - img { - position: static; - padding: 10px 0; - } - - @media screen and (max-width: $small-breakpoint) { - padding: 15px 0; - } - - @media screen and (max-width: $column-breakpoint) { - padding: 0; - margin-bottom: -10px; - } - } - } - &__information, &__forms { padding: 20px; @@ -967,353 +603,253 @@ $small-breakpoint: 960px; } } - &__forms { - height: 100%; - - @media screen and (max-width: $small-breakpoint) { - height: auto; - } - - @media screen and (max-width: $column-breakpoint) { - background: transparent; - box-shadow: none; - padding: 0 20px; - margin-top: 30px; - margin-bottom: 40px; - - .separator-or { - span { - background: darken($ui-base-color, 8%); - } + @media screen and (max-width: 840px) { + .information-board { + .container-alt { + padding-right: 20px; } - } - - hr { - margin: 40px 0; - } - .button { - display: block; - } - - .subtle-hint a { - text-decoration: none; + .panel { + position: static; + margin-top: 20px; + width: 100%; + border-radius: 4px; - &:hover, - &:focus, - &:active { - text-decoration: underline; + .panel-header { + text-align: center; + } } } } - #mastodon-timeline { - display: flex; - -webkit-overflow-scrolling: touch; - -ms-overflow-style: -ms-autohiding-scrollbar; - font-family: $font-sans-serif, sans-serif; - font-size: 13px; - line-height: 18px; - font-weight: 400; - color: $primary-text-color; - width: 100%; - flex: 1 1 auto; - overflow: hidden; - height: 100%; - - .column-header { - color: inherit; - font-family: inherit; - font-size: 16px; - line-height: inherit; - font-weight: inherit; - margin: 0; - padding: 0; - } - - .column { - padding: 0; - border-radius: 4px; - overflow: hidden; - width: 100%; - } - - .scrollable { - height: 400px; - } - - p { - font-size: inherit; - line-height: inherit; - font-weight: inherit; - color: $primary-text-color; - margin-bottom: 20px; - - &:last-child { - margin-bottom: 0; - } + @media screen and (max-width: 675px) { + .header-wrapper { + padding-top: 0; - a { - color: $secondary-text-color; - text-decoration: none; + &.compact { + padding-bottom: 0; } - } - - .attachment-list__list { - margin-left: 0; - list-style: none; - - li { - font-size: inherit; - line-height: inherit; - font-weight: inherit; - margin-bottom: 0; - - a { - color: $dark-text-color; - text-decoration: none; - &:hover { - text-decoration: underline; - } - } + &.compact .hero .heading { + text-align: initial; } } - @media screen and (max-width: $column-breakpoint) { - display: none; + .header .container-alt, + .features .container-alt { + display: block; } } - &__features { - & > p { - padding-right: 60px; - } - - .features-list { - margin: 40px 0; - margin-top: 30px; - } - - &__action { - text-align: center; - } + .cta { + margin: 20px; } +} - .features-list { - .features-list__row { - display: flex; - padding: 10px 0; - justify-content: space-between; - - .visual { - flex: 0 0 auto; - display: flex; - align-items: center; - margin-left: 15px; +.landing { + margin-bottom: 100px; - .fa { - display: block; - color: $darker-text-color; - font-size: 48px; - } - } + @media screen and (max-width: 738px) { + margin-bottom: 0; + } - .text { - font-size: 16px; - line-height: 30px; - color: $darker-text-color; + &__brand { + display: flex; + justify-content: center; + align-items: center; + padding: 100px; - h6 { - font-size: inherit; - line-height: inherit; - margin-bottom: 0; - } - } + img { + height: 52px; } - @media screen and (min-width: $small-breakpoint) { - display: grid; - grid-gap: 30px; - grid-template-columns: 1fr 1fr; - grid-auto-columns: 50%; - grid-auto-rows: max-content; + @media screen and (max-width: $no-gap-breakpoint) { + padding: 0; + margin-bottom: 30px; } } - .footer-links { - padding-bottom: 50px; - text-align: right; - color: $dark-text-color; + .directory { + margin-top: 30px; + background: transparent; + box-shadow: none; + border-radius: 0; + } - p { - font-size: 14px; - } + .hero-widget { + margin-top: 30px; + margin-bottom: 0; - a { - color: inherit; - text-decoration: underline; + h4 { + padding: 10px; + text-transform: uppercase; + font-weight: 700; + font-size: 13px; + color: $darker-text-color; } - } - &__footer { - margin-top: 10px; - text-align: center; - color: $dark-text-color; + &__text { + border-radius: 0; + padding-bottom: 0; + } - p { - font-size: 14px; + &__footer { + background: $ui-base-color; + padding: 10px; + border-radius: 0 0 4px 4px; + display: flex; - a { - color: inherit; - text-decoration: underline; + &__column { + flex: 1 1 50%; } } - } - @media screen and (max-width: 840px) { - .container-alt { - padding: 0 20px; - } + .account { + padding: 10px 0; + border-bottom: 0; - .information-board { - .container-alt { - padding-right: 20px; + .account__display-name { + display: flex; + align-items: center; } - .panel { - position: static; - margin-top: 20px; - width: 100%; - border-radius: 4px; - - .panel-header { - text-align: center; - } + .account__avatar { + width: 44px; + height: 44px; + background-size: 44px 44px; } } - } - @media screen and (max-width: 675px) { - .header-wrapper { - padding-top: 0; + &__counter { + padding: 10px; - &.compact { - padding-bottom: 0; + strong { + font-family: $font-display, sans-serif; + font-size: 15px; + font-weight: 700; + display: block; } - &.compact .hero .heading { - text-align: initial; + span { + font-size: 14px; + color: $darker-text-color; } } + } - .header .container-alt, - .features .container-alt { - display: block; - } - - .header { - .links { - padding-top: 15px; - background: darken($ui-base-color, 4%); + .simple_form .user_agreement .label_input > label { + font-weight: 400; + color: $darker-text-color; + } - a { - padding: 12px 8px; - } + .simple_form p.lead { + color: $darker-text-color; + font-size: 15px; + line-height: 20px; + font-weight: 400; + margin-bottom: 25px; + } - .nav { - display: flex; - flex-flow: row wrap; - justify-content: space-around; - } + &__grid { + max-width: 960px; + margin: 0 auto; + display: grid; + grid-template-columns: minmax(0, 50%) minmax(0, 50%); + grid-gap: 30px; - .brand img { - left: 0; - top: 0; - } - } + @media screen and (max-width: 738px) { + grid-template-columns: minmax(0, 100%); + grid-gap: 10px; - .hero { - margin-top: 30px; - padding: 0; + &__column-login { + grid-row: 1; + display: flex; + flex-direction: column; - .heading { - padding: 30px 20px; - text-align: center; + .box-widget { + order: 2; + flex: 0 0 auto; } - .simple_form, - .closed-registrations-message { - background: darken($ui-base-color, 8%); - width: 100%; - border-radius: 0; - box-sizing: border-box; + .hero-widget { + margin-top: 0; + margin-bottom: 10px; + order: 1; + flex: 0 0 auto; } } - } - } - - .cta { - margin: 20px; - } - &.tag-page { - @media screen and (max-width: $column-breakpoint) { - padding: 0; - - .container { - padding: 0; + &__column-registration { + grid-row: 2; } - #mastodon-timeline { - display: flex; - height: 100vh; - border-radius: 0; + .directory { + margin-top: 10px; } } - .grid { - @media screen and (min-width: $small-breakpoint) { - grid-template-columns: 33% 67%; - } - - .column-2 { - grid-column: 2; - grid-row: 1; - } - } + @media screen and (max-width: $no-gap-breakpoint) { + grid-gap: 0; - .brand { - text-align: unset; - padding: 0; + .hero-widget { + display: block; + margin-bottom: 0; + box-shadow: none; - img { - height: 48px; - width: auto; + &__img, + &__img img, + &__footer { + border-radius: 0; + } } - } - .cta { - margin: 0; - - .button { - margin-right: 4px; + .hero-widget, + .box-widget, + .directory__tag { + border-bottom: 1px solid lighten($ui-base-color, 8%); } - } - @media screen and (max-width: $column-breakpoint) { - .grid { - grid-gap: 0; + .directory { + margin-top: 0; - .column-1 { - grid-column: 1; - grid-row: 1; - } + &__tag { + margin-bottom: 0; - .column-2 { - display: none; + & > a, + & > div { + border-radius: 0; + box-shadow: none; + } + + &:last-child { + border-bottom: 0; + } } } } } } + +.brand { + position: relative; + text-decoration: none; +} + +.brand__tagline { + display: block; + position: absolute; + bottom: -10px; + left: 50px; + width: 300px; + color: $ui-primary-color; + text-decoration: none; + font-size: 14px; + + @media screen and (max-width: $no-gap-breakpoint) { + position: static; + width: auto; + margin-top: 20px; + color: $dark-text-color; + } +} + diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss index 4dbbaa1e8..42f53f525 100644 --- a/app/javascript/styles/mastodon/admin.scss +++ b/app/javascript/styles/mastodon/admin.scss @@ -705,3 +705,11 @@ a.name-tag, overflow: hidden; text-overflow: ellipsis; } + +.ellipsized-ip { + display: inline-block; + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: middle; +} diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss index bab982706..6051c1d00 100644 --- a/app/javascript/styles/mastodon/forms.scss +++ b/app/javascript/styles/mastodon/forms.scss @@ -68,6 +68,17 @@ code { top: 2px; left: 0; } + + label a { + color: $highlight-text-color; + text-decoration: underline; + + &:hover, + &:active, + &:focus { + text-decoration: none; + } + } } } @@ -305,7 +316,7 @@ code { box-shadow: none; } - &:focus:invalid { + &:focus:invalid:not(:placeholder-shown) { border-color: lighten($error-red, 12%); } @@ -346,6 +357,10 @@ code { } } + .input.disabled { + opacity: 0.5; + } + .actions { margin-top: 30px; display: flex; @@ -392,6 +407,10 @@ code { background-color: darken($ui-highlight-color, 5%); } + &:disabled:hover { + background-color: $ui-primary-color; + } + &.negative { background: $error-value-color; diff --git a/app/javascript/styles/mastodon/polls.scss b/app/javascript/styles/mastodon/polls.scss index 4f8c94d83..d8bc5473a 100644 --- a/app/javascript/styles/mastodon/polls.scss +++ b/app/javascript/styles/mastodon/polls.scss @@ -190,3 +190,15 @@ color: darken($simple-background-color, 14%); } } + +.muted .poll { + color: $dark-text-color; + + &__chart { + background: rgba(darken($ui-primary-color, 14%), 0.2); + + &.leading { + background: rgba($ui-highlight-color, 0.2); + } + } +} diff --git a/app/javascript/styles/mastodon/tables.scss b/app/javascript/styles/mastodon/tables.scss index adb75afe5..9e8785679 100644 --- a/app/javascript/styles/mastodon/tables.scss +++ b/app/javascript/styles/mastodon/tables.scss @@ -82,6 +82,10 @@ } } } + + &--invites tbody td { + vertical-align: middle; + } } .table-wrapper { diff --git a/app/javascript/styles/mastodon/widgets.scss b/app/javascript/styles/mastodon/widgets.scss index 1eaf30c5b..645192ea4 100644 --- a/app/javascript/styles/mastodon/widgets.scss +++ b/app/javascript/styles/mastodon/widgets.scss @@ -295,6 +295,11 @@ cursor: default; } + &.disabled > div { + opacity: 0.5; + cursor: default; + } + h4 { flex: 1 1 auto; font-size: 18px; diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 7e4e57ead..8fe7b9138 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -241,8 +241,13 @@ class ActivityPub::Activity::Create < ActivityPub::Activity def poll_vote? return false if replied_to_status.nil? || replied_to_status.poll.nil? || !replied_to_status.local? || !replied_to_status.poll.options.include?(@object['name']) - return true if replied_to_status.poll.expired? - replied_to_status.poll.votes.create!(account: @account, choice: replied_to_status.poll.options.index(@object['name']), uri: @object['id']) + + unless replied_to_status.poll.expired? + replied_to_status.poll.votes.create!(account: @account, choice: replied_to_status.poll.options.index(@object['name']), uri: @object['id']) + ActivityPub::DistributePollUpdateWorker.perform_in(3.minutes, replied_to_status.id) unless replied_to_status.poll.hide_totals? + end + + true end def resolve_thread(status) @@ -369,15 +374,6 @@ class ActivityPub::Activity::Create < ActivityPub::Activity @skip_download ||= DomainBlock.find_by(domain: @account.domain)&.reject_media? end - def invalid_origin?(url) - return true if unsupported_uri_scheme?(url) - - needle = Addressable::URI.parse(url).host - haystack = Addressable::URI.parse(@account.uri).host - - !haystack.casecmp(needle).zero? - end - def reply_to_local? !replied_to_status.nil? && replied_to_status.account.local? end diff --git a/app/lib/activitypub/activity/delete.rb b/app/lib/activitypub/activity/delete.rb index dc76dd3e2..4236af071 100644 --- a/app/lib/activitypub/activity/delete.rb +++ b/app/lib/activitypub/activity/delete.rb @@ -75,13 +75,4 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity def lock_options { redis: Redis.current, key: "create:#{object_uri}" } end - - def invalid_origin?(url) - return true if unsupported_uri_scheme?(url) - - needle = Addressable::URI.parse(url).host - haystack = Addressable::URI.parse(@account.uri).host - - !haystack.casecmp(needle).zero? - end end diff --git a/app/lib/activitypub/activity/update.rb b/app/lib/activitypub/activity/update.rb index 67dae3f81..bc9a63f98 100644 --- a/app/lib/activitypub/activity/update.rb +++ b/app/lib/activitypub/activity/update.rb @@ -4,7 +4,11 @@ class ActivityPub::Activity::Update < ActivityPub::Activity SUPPORTED_TYPES = %w(Application Group Organization Person Service).freeze def perform - update_account if equals_or_includes_any?(@object['type'], SUPPORTED_TYPES) + if equals_or_includes_any?(@object['type'], SUPPORTED_TYPES) + update_account + elsif equals_or_includes_any?(@object['type'], %w(Question)) + update_poll + end end private @@ -14,4 +18,13 @@ class ActivityPub::Activity::Update < ActivityPub::Activity ActivityPub::ProcessAccountService.new.call(@account.username, @account.domain, @object, signed_with_known_key: true) end + + def update_poll + return reject_payload! if invalid_origin?(@object['id']) + + status = Status.find_by(uri: object_uri, account_id: @account.id) + return if status.nil? || status.poll.nil? + + ActivityPub::ProcessPollService.new.call(status.poll, @object) + end end diff --git a/app/lib/request.rb b/app/lib/request.rb index ef4aeaf29..e555ae6a1 100644 --- a/app/lib/request.rb +++ b/app/lib/request.rb @@ -171,7 +171,7 @@ class Request outer_e = nil Resolv::DNS.open do |dns| - dns.timeouts = 1 + dns.timeouts = 5 addresses = dns.getaddresses(host).take(2) time_slot = 10.0 / addresses.size diff --git a/app/mailers/admin_mailer.rb b/app/mailers/admin_mailer.rb index a30468eb8..db154cad5 100644 --- a/app/mailers/admin_mailer.rb +++ b/app/mailers/admin_mailer.rb @@ -14,4 +14,14 @@ class AdminMailer < ApplicationMailer mail to: @me.user_email, subject: I18n.t('admin_mailer.new_report.subject', instance: @instance, id: @report.id) end end + + def new_pending_account(recipient, user) + @account = user.account + @me = recipient + @instance = Rails.configuration.x.local_domain + + locale_for_account(@me) do + mail to: @me.user_email, subject: I18n.t('admin_mailer.new_pending_account.subject', instance: @instance, username: @account.username) + end + end end diff --git a/app/models/account.rb b/app/models/account.rb index 79eecc306..6b539f004 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -108,6 +108,8 @@ class Account < ApplicationRecord :current_sign_in_ip, :current_sign_in_at, :confirmed?, + :approved?, + :pending?, :admin?, :moderator?, :staff?, @@ -474,6 +476,7 @@ class Account < ApplicationRecord before_create :generate_keys before_validation :prepare_contents, if: :local? + before_validation :prepare_username, on: :create before_destroy :clean_feed_manager private @@ -483,6 +486,10 @@ class Account < ApplicationRecord note&.strip! end + def prepare_username + username&.squish! + end + def generate_keys return unless local? && !Rails.env.test? diff --git a/app/models/account_filter.rb b/app/models/account_filter.rb index b10f50db7..d2503100c 100644 --- a/app/models/account_filter.rb +++ b/app/models/account_filter.rb @@ -22,7 +22,7 @@ class AccountFilter def set_defaults! params['local'] = '1' if params['remote'].blank? - params['active'] = '1' if params['suspended'].blank? && params['silenced'].blank? + params['active'] = '1' if params['suspended'].blank? && params['silenced'].blank? && params['pending'].blank? end def scope_for(key, value) @@ -35,6 +35,8 @@ class AccountFilter Account.where(domain: value) when 'active' Account.without_suspended + when 'pending' + accounts_with_users.merge User.pending when 'silenced' Account.silenced when 'suspended' diff --git a/app/models/concerns/expireable.rb b/app/models/concerns/expireable.rb index 2c0631476..f7d2bab49 100644 --- a/app/models/concerns/expireable.rb +++ b/app/models/concerns/expireable.rb @@ -18,7 +18,11 @@ module Expireable end def expired? - !expires_at.nil? && expires_at < Time.now.utc + expires? && expires_at < Time.now.utc + end + + def expires? + !expires_at.nil? end end end diff --git a/app/models/concerns/ldap_authenticable.rb b/app/models/concerns/ldap_authenticable.rb new file mode 100644 index 000000000..e1b5e3832 --- /dev/null +++ b/app/models/concerns/ldap_authenticable.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module LdapAuthenticable + extend ActiveSupport::Concern + + def ldap_setup(_attributes) + self.confirmed_at = Time.now.utc + self.admin = false + + save! + end + + class_methods do + def ldap_get_user(attributes = {}) + resource = joins(:account).find_by(accounts: { username: attributes[Devise.ldap_uid.to_sym].first }) + + if resource.blank? + resource = new(email: attributes[:mail].first, agreement: true, account_attributes: { username: attributes[Devise.ldap_uid.to_sym].first }) + resource.ldap_setup(attributes) + end + + resource + end + end +end diff --git a/app/models/concerns/omniauthable.rb b/app/models/concerns/omniauthable.rb index 4dd2e9383..1b28b8162 100644 --- a/app/models/concerns/omniauthable.rb +++ b/app/models/concerns/omniauthable.rb @@ -7,6 +7,8 @@ module Omniauthable TEMP_EMAIL_REGEX = /\Achange@me/ included do + devise :omniauthable + def omniauth_providers Devise.omniauth_configs.keys end diff --git a/app/models/concerns/pam_authenticable.rb b/app/models/concerns/pam_authenticable.rb new file mode 100644 index 000000000..2f651c1a3 --- /dev/null +++ b/app/models/concerns/pam_authenticable.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module PamAuthenticable + extend ActiveSupport::Concern + + included do + devise :pam_authenticatable if ENV['PAM_ENABLED'] == 'true' + + def pam_conflict(_attributes) + # Block pam login tries on traditional account + end + + def pam_conflict? + if Devise.pam_authentication + encrypted_password.present? && pam_managed_user? + else + false + end + end + + def pam_get_name + if account.present? + account.username + else + super + end + end + + def pam_setup(_attributes) + account = Account.new(username: pam_get_name) + account.save!(validate: false) + + self.email = "#{account.username}@#{find_pam_suffix}" if email.nil? && find_pam_suffix + self.confirmed_at = Time.now.utc + self.admin = false + self.account = account + + account.destroy! unless save + end + + def self.pam_get_user(attributes = {}) + return nil unless attributes[:email] + + resource = begin + if Devise.check_at_sign && !attributes[:email].index('@') + joins(:account).find_by(accounts: { username: attributes[:email] }) + else + find_by(email: attributes[:email]) + end + end + + if resource.nil? + resource = new(email: attributes[:email], agreement: true) + + if Devise.check_at_sign && !resource[:email].index('@') + resource[:email] = Rpam2.getenv(resource.find_pam_service, attributes[:email], attributes[:password], 'email', false) + resource[:email] = "#{attributes[:email]}@#{resource.find_pam_suffix}" unless resource[:email] + end + end + + resource + end + + def self.authenticate_with_pam(attributes = {}) + super if Devise.pam_authentication + end + end +end diff --git a/app/models/concerns/user_roles.rb b/app/models/concerns/user_roles.rb new file mode 100644 index 000000000..58dffdc46 --- /dev/null +++ b/app/models/concerns/user_roles.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module UserRoles + extend ActiveSupport::Concern + + included do + scope :admins, -> { where(admin: true) } + scope :moderators, -> { where(moderator: true) } + scope :staff, -> { admins.or(moderators) } + end + + def staff? + admin? || moderator? + end + + def role + if admin? + 'admin' + elsif moderator? + 'moderator' + else + 'user' + end + end + + def role?(role) + case role + when 'user' + true + when 'moderator' + staff? + when 'admin' + admin? + else + false + end + end + + def promote! + if moderator? + update!(moderator: false, admin: true) + elsif !admin? + update!(moderator: true) + end + end + + def demote! + if admin? + update!(admin: false, moderator: true) + elsif moderator? + update!(moderator: false) + end + end +end diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb index d568200ed..929c65793 100644 --- a/app/models/form/admin_settings.rb +++ b/app/models/form/admin_settings.rb @@ -18,8 +18,8 @@ class Form::AdminSettings :site_extended_description=, :site_terms, :site_terms=, - :open_registrations, - :open_registrations=, + :registrations_mode, + :registrations_mode=, :closed_registrations_message, :closed_registrations_message=, :open_deletion, diff --git a/app/models/notification.rb b/app/models/notification.rb index 2f0a9b78c..982136c05 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -22,6 +22,7 @@ class Notification < ApplicationRecord follow: 'Follow', follow_request: 'FollowRequest', favourite: 'Favourite', + poll: 'Poll', }.freeze STATUS_INCLUDES = [:account, :application, :media_attachments, :tags, active_mentions: :account, reblog: [:account, :application, :media_attachments, :tags, active_mentions: :account]].freeze @@ -35,6 +36,7 @@ class Notification < ApplicationRecord belongs_to :follow, foreign_type: 'Follow', foreign_key: 'activity_id', optional: true belongs_to :follow_request, foreign_type: 'FollowRequest', foreign_key: 'activity_id', optional: true belongs_to :favourite, foreign_type: 'Favourite', foreign_key: 'activity_id', optional: true + belongs_to :poll, foreign_type: 'Poll', foreign_key: 'activity_id', optional: true validates :account_id, uniqueness: { scope: [:activity_type, :activity_id] } validates :activity_type, inclusion: { in: TYPE_CLASS_MAP.values } @@ -44,7 +46,7 @@ class Notification < ApplicationRecord where(activity_type: types) } - cache_associated :from_account, status: STATUS_INCLUDES, mention: [status: STATUS_INCLUDES], favourite: [:account, status: STATUS_INCLUDES], follow: :account + cache_associated :from_account, status: STATUS_INCLUDES, mention: [status: STATUS_INCLUDES], favourite: [:account, status: STATUS_INCLUDES], follow: :account, poll: [status: STATUS_INCLUDES] def type @type ||= TYPE_CLASS_MAP.invert[activity_type].to_sym @@ -58,6 +60,8 @@ class Notification < ApplicationRecord favourite&.status when :mention mention&.status + when :poll + poll&.status end end @@ -97,7 +101,7 @@ class Notification < ApplicationRecord return unless new_record? case activity_type - when 'Status', 'Follow', 'Favourite', 'FollowRequest' + when 'Status', 'Follow', 'Favourite', 'FollowRequest', 'Poll' self.from_account_id = activity&.account_id when 'Mention' self.from_account_id = activity&.status&.account_id diff --git a/app/models/poll.rb b/app/models/poll.rb index 09f0b65ec..6df230337 100644 --- a/app/models/poll.rb +++ b/app/models/poll.rb @@ -26,6 +26,8 @@ class Poll < ApplicationRecord has_many :votes, class_name: 'PollVote', inverse_of: :poll, dependent: :destroy + has_many :notifications, as: :activity, dependent: :destroy + validates :options, presence: true validates :expires_at, presence: true, if: :local? validates_with PollValidator, on: :create, if: :local? diff --git a/app/models/tag.rb b/app/models/tag.rb index 788a678bd..7db76d157 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -72,6 +72,14 @@ class Tag < ApplicationRecord .limit(limit) .offset(offset) end + + def find_normalized(name) + find_by(name: name.mb_chars.downcase.to_s) + end + + def find_normalized!(name) + find_normalized(name) || raise(ActiveRecord::RecordNotFound) + end end private diff --git a/app/models/user.rb b/app/models/user.rb index f91ed7015..47657a670 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -37,11 +37,12 @@ # remember_token :string # chosen_languages :string is an Array # created_by_application_id :bigint(8) +# approved :boolean default(TRUE), not null # class User < ApplicationRecord include Settings::Extend - include Omniauthable + include UserRoles # The home and list feeds will be stored in Redis for this amount # of time, and status fan-out to followers will include only people @@ -61,9 +62,9 @@ class User < ApplicationRecord devise :registerable, :recoverable, :rememberable, :trackable, :validatable, :confirmable - devise :pam_authenticatable if ENV['PAM_ENABLED'] == 'true' - - devise :omniauthable + include Omniauthable + include PamAuthenticable + include LdapAuthenticable belongs_to :account, inverse_of: :user belongs_to :invite, counter_cache: :uses, optional: true @@ -79,9 +80,8 @@ class User < ApplicationRecord validates :agreement, acceptance: { allow_nil: false, accept: [true, 'true', '1'] }, on: :create scope :recent, -> { order(id: :desc) } - scope :admins, -> { where(admin: true) } - scope :moderators, -> { where(moderator: true) } - scope :staff, -> { admins.or(moderators) } + scope :pending, -> { where(approved: false) } + scope :approved, -> { where(approved: true) } scope :confirmed, -> { where.not(confirmed_at: nil) } scope :enabled, -> { where(disabled: false) } scope :inactive, -> { where(arel_table[:current_sign_in_at].lt(ACTIVE_DURATION.ago)) } @@ -90,6 +90,7 @@ class User < ApplicationRecord scope :emailable, -> { confirmed.enabled.joins(:account).merge(Account.searchable) } before_validation :sanitize_languages + before_create :set_approved # This avoids a deprecation warning from Rails 5.1 # It seems possible that a future release of devise-two-factor will @@ -104,39 +105,6 @@ class User < ApplicationRecord attr_reader :invite_code - def pam_conflict(_) - # block pam login tries on traditional account - nil - end - - def pam_conflict? - return false unless Devise.pam_authentication - encrypted_password.present? && pam_managed_user? - end - - def pam_get_name - return account.username if account.present? - super - end - - def pam_setup(_attributes) - acc = Account.new(username: pam_get_name) - acc.save!(validate: false) - - self.email = "#{acc.username}@#{find_pam_suffix}" if email.nil? && find_pam_suffix - self.confirmed_at = Time.now.utc - self.admin = false - self.account = acc - - acc.destroy! unless save - end - - def ldap_setup(_attributes) - self.confirmed_at = Time.now.utc - self.admin = false - save! - end - def confirmed? confirmed_at.present? end @@ -145,33 +113,6 @@ class User < ApplicationRecord invite_id.present? end - def staff? - admin? || moderator? - end - - def role - if admin? - 'admin' - elsif moderator? - 'moderator' - else - 'user' - end - end - - def role?(role) - case role - when 'user' - true - when 'moderator' - staff? - when 'admin' - admin? - else - false - end - end - def disable! update!(disabled: true, last_sign_in_at: current_sign_in_at, @@ -186,7 +127,12 @@ class User < ApplicationRecord new_user = !confirmed? super - prepare_new_user! if new_user + + if new_user && approved? + prepare_new_user! + elsif new_user + notify_staff_about_pending_account! + end end def confirm! @@ -194,28 +140,32 @@ class User < ApplicationRecord skip_confirmation! save! - prepare_new_user! if new_user + + prepare_new_user! if new_user && approved? end - def update_tracked_fields!(request) - super - prepare_returning_user! + def pending? + !approved? end - def promote! - if moderator? - update!(moderator: false, admin: true) - elsif !admin? - update!(moderator: true) - end + def active_for_authentication? + super && approved? end - def demote! - if admin? - update!(admin: false, moderator: true) - elsif moderator? - update!(moderator: false) - end + def inactive_message + !approved? ? :pending : super + end + + def approve! + return if approved? + + update!(approved: true) + prepare_new_user! + end + + def update_tracked_fields!(request) + super + prepare_returning_user! end def disable_two_factor! @@ -297,43 +247,6 @@ class User < ApplicationRecord super end - def self.pam_get_user(attributes = {}) - return nil unless attributes[:email] - - resource = - if Devise.check_at_sign && !attributes[:email].index('@') - joins(:account).find_by(accounts: { username: attributes[:email] }) - else - find_by(email: attributes[:email]) - end - - if resource.blank? - resource = new(email: attributes[:email], agreement: true) - - if Devise.check_at_sign && !resource[:email].index('@') - resource[:email] = Rpam2.getenv(resource.find_pam_service, attributes[:email], attributes[:password], 'email', false) - resource[:email] = "#{attributes[:email]}@#{resource.find_pam_suffix}" unless resource[:email] - end - end - resource - end - - def self.ldap_get_user(attributes = {}) - resource = joins(:account).find_by(accounts: { username: attributes[Devise.ldap_uid.to_sym].first }) - - if resource.blank? - resource = new(email: attributes[:mail].first, agreement: true, account_attributes: { username: attributes[Devise.ldap_uid.to_sym].first }) - resource.ldap_setup(attributes) - end - - resource - end - - def self.authenticate_with_pam(attributes = {}) - return nil unless Devise.pam_authentication - super - end - def show_all_media? setting_display_media == 'show_all' end @@ -350,6 +263,10 @@ class User < ApplicationRecord private + def set_approved + self.approved = Setting.registrations_mode == 'open' || invited? + end + def sanitize_languages return if chosen_languages.nil? chosen_languages.reject!(&:blank?) @@ -367,6 +284,13 @@ class User < ApplicationRecord regenerate_feed! if needs_feed_update? end + def notify_staff_about_pending_account! + User.staff.includes(:account).each do |u| + next unless u.allows_report_emails? + AdminMailer.new_pending_account(u.account, self).deliver_later + end + end + def regenerate_feed! return unless Redis.current.setnx("account:#{account_id}:regeneration", true) Redis.current.expire("account:#{account_id}:regeneration", 1.day.seconds) diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index 57af5c61c..d832bff75 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -21,6 +21,14 @@ class UserPolicy < ApplicationPolicy staff? end + def approve? + staff? && !record.approved? + end + + def reject? + staff? && !record.approved? + end + def disable? staff? && !record.admin? end @@ -36,7 +44,7 @@ class UserPolicy < ApplicationPolicy private def promoteable? - !record.staff? || !record.admin? + record.approved? && (!record.staff? || !record.admin?) end def demoteable? diff --git a/app/presenters/instance_presenter.rb b/app/presenters/instance_presenter.rb index d8670f124..94a2c1692 100644 --- a/app/presenters/instance_presenter.rb +++ b/app/presenters/instance_presenter.rb @@ -2,9 +2,7 @@ class InstancePresenter delegate( - :closed_registrations_message, :site_contact_email, - :open_registrations, :site_title, :site_short_description, :site_description, @@ -21,6 +19,10 @@ class InstancePresenter Rails.cache.fetch('user_count') { User.confirmed.joins(:account).merge(Account.without_suspended).count } end + def active_user_count + Rails.cache.fetch('active_user_count') { Redis.current.pfcount(*(0..3).map { |i| "activity:logins:#{i.weeks.ago.utc.to_date.cweek}" }) } + end + def status_count Rails.cache.fetch('local_status_count') { Account.local.joins(:account_stat).sum('account_stats.statuses_count') }.to_i end @@ -29,6 +31,10 @@ class InstancePresenter Rails.cache.fetch('distinct_domain_count') { Account.distinct.count(:domain) } end + def sample_accounts + Rails.cache.fetch('sample_accounts', expires_in: 12.hours) { Account.discoverable.popular.limit(3) } + end + def version_number Mastodon::Version end diff --git a/app/serializers/activitypub/update_poll_serializer.rb b/app/serializers/activitypub/update_poll_serializer.rb new file mode 100644 index 000000000..f7933346f --- /dev/null +++ b/app/serializers/activitypub/update_poll_serializer.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class ActivityPub::UpdatePollSerializer < ActiveModel::Serializer + attributes :id, :type, :actor, :to + + has_one :object, serializer: ActivityPub::NoteSerializer + + def id + [ActivityPub::TagManager.instance.uri_for(object), '#updates/', object.poll.updated_at.to_i].join + end + + def type + 'Update' + end + + def actor + ActivityPub::TagManager.instance.uri_for(object) + end + + def to + ActivityPub::TagManager.instance.to(object) + end + + def cc + ActivityPub::TagManager.instance.cc(object) + end +end diff --git a/app/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb index 30e8dcbc1..97fed63d1 100644 --- a/app/serializers/rest/instance_serializer.rb +++ b/app/serializers/rest/instance_serializer.rb @@ -65,7 +65,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer end def registrations - Setting.open_registrations && !Rails.configuration.x.single_user_mode + Setting.registrations_mode != 'none' && !Rails.configuration.x.single_user_mode end private diff --git a/app/serializers/rest/notification_serializer.rb b/app/serializers/rest/notification_serializer.rb index 541a6b8b5..80812ad0d 100644 --- a/app/serializers/rest/notification_serializer.rb +++ b/app/serializers/rest/notification_serializer.rb @@ -11,6 +11,6 @@ class REST::NotificationSerializer < ActiveModel::Serializer end def status_type? - [:favourite, :reblog, :mention].include?(object.type) + [:favourite, :reblog, :mention, :poll].include?(object.type) end end diff --git a/app/services/activitypub/fetch_remote_poll_service.rb b/app/services/activitypub/fetch_remote_poll_service.rb index 4f9814fcd..44a23712c 100644 --- a/app/services/activitypub/fetch_remote_poll_service.rb +++ b/app/services/activitypub/fetch_remote_poll_service.rb @@ -4,54 +4,7 @@ class ActivityPub::FetchRemotePollService < BaseService include JsonLdHelper def call(poll, on_behalf_of = nil) - @json = fetch_resource(poll.status.uri, true, on_behalf_of) - - return unless supported_context? && expected_type? - - expires_at = begin - if @json['closed'].is_a?(String) - @json['closed'] - elsif !@json['closed'].nil? && !@json['closed'].is_a?(FalseClass) - Time.now.utc - else - @json['endTime'] - end - end - - items = begin - if @json['anyOf'].is_a?(Array) - @json['anyOf'] - else - @json['oneOf'] - end - end - - latest_options = items.map { |item| item['name'].presence || item['content'] } - - # If for some reasons the options were changed, it invalidates all previous - # votes, so we need to remove them - poll.votes.delete_all if latest_options != poll.options - - begin - poll.update!( - last_fetched_at: Time.now.utc, - expires_at: expires_at, - options: latest_options, - cached_tallies: items.map { |item| item.dig('replies', 'totalItems') || 0 } - ) - rescue ActiveRecord::StaleObjectError - poll.reload - retry - end - end - - private - - def supported_context? - super(@json) - end - - def expected_type? - equals_or_includes_any?(@json['type'], %w(Question)) + json = fetch_resource(poll.status.uri, true, on_behalf_of) + ActivityPub::ProcessPollService.new.call(poll, json) end end diff --git a/app/services/activitypub/fetch_replies_service.rb b/app/services/activitypub/fetch_replies_service.rb index 569d0d7c1..8cb309e52 100644 --- a/app/services/activitypub/fetch_replies_service.rb +++ b/app/services/activitypub/fetch_replies_service.rb @@ -46,13 +46,4 @@ class ActivityPub::FetchRepliesService < BaseService # Also limit to 5 fetched replies to limit potential for DoS. @items.map { |item| value_or_id(item) }.reject { |uri| invalid_origin?(uri) }.take(5) end - - def invalid_origin?(url) - return true if unsupported_uri_scheme?(url) - - needle = Addressable::URI.parse(url).host - haystack = Addressable::URI.parse(@account.uri).host - - !haystack.casecmp(needle).zero? - end end diff --git a/app/services/activitypub/process_poll_service.rb b/app/services/activitypub/process_poll_service.rb new file mode 100644 index 000000000..ee248169d --- /dev/null +++ b/app/services/activitypub/process_poll_service.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +class ActivityPub::ProcessPollService < BaseService + include JsonLdHelper + + def call(poll, json) + @json = json + return unless supported_context? && expected_type? + + previous_expires_at = poll.expires_at + + expires_at = begin + if @json['closed'].is_a?(String) + @json['closed'] + elsif !@json['closed'].nil? && !@json['closed'].is_a?(FalseClass) + Time.now.utc + else + @json['endTime'] + end + end + + items = begin + if @json['anyOf'].is_a?(Array) + @json['anyOf'] + else + @json['oneOf'] + end + end + + latest_options = items.map { |item| item['name'].presence || item['content'] } + + # If for some reasons the options were changed, it invalidates all previous + # votes, so we need to remove them + poll.votes.delete_all if latest_options != poll.options + + begin + poll.update!( + last_fetched_at: Time.now.utc, + expires_at: expires_at, + options: latest_options, + cached_tallies: items.map { |item| item.dig('replies', 'totalItems') || 0 } + ) + rescue ActiveRecord::StaleObjectError + poll.reload + retry + end + + # If the poll had no expiration date set but now has, and people have voted, + # schedule a notification. + if previous_expires_at.nil? && poll.expires_at.present? && poll.votes.exists? + PollExpirationNotifyWorker.perform_at(poll.expires_at + 5.minutes, poll.id) + end + end + + private + + def supported_context? + super(@json) + end + + def expected_type? + equals_or_includes_any?(@json['type'], %w(Question)) + end +end diff --git a/app/services/app_sign_up_service.rb b/app/services/app_sign_up_service.rb index d621cc462..6dee9cd81 100644 --- a/app/services/app_sign_up_service.rb +++ b/app/services/app_sign_up_service.rb @@ -18,6 +18,6 @@ class AppSignUpService < BaseService private def allowed_registrations? - Setting.open_registrations && !Rails.configuration.x.single_user_mode + Setting.registrations_mode != 'none' && !Rails.configuration.x.single_user_mode end end diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb index b80ceef03..b5c721589 100644 --- a/app/services/notify_service.rb +++ b/app/services/notify_service.rb @@ -38,6 +38,10 @@ class NotifyService < BaseService false end + def blocked_poll? + false + end + def following_sender? return @following_sender if defined?(@following_sender) @following_sender = @recipient.following?(@notification.from_account) || @recipient.requested?(@notification.from_account) @@ -88,7 +92,7 @@ class NotifyService < BaseService def blocked? blocked = @recipient.suspended? # Skip if the recipient account is suspended anyway - blocked ||= from_self? # Skip for interactions with self + blocked ||= from_self? && @notification.type != :poll # Skip for interactions with self return blocked if message? && from_staff? diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index 8a9d26c56..b9952369d 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -91,10 +91,13 @@ class PostStatusService < BaseService def postprocess_status! LinkCrawlWorker.perform_async(@status.id) unless @status.spoiler_text? DistributionWorker.perform_async(@status.id) + unless @status.local_only? Pubsubhubbub::DistributionWorker.perform_async(@status.stream_entry.id) ActivityPub::DistributionWorker.perform_async(@status.id) end + + PollExpirationNotifyWorker.perform_at(@status.poll.expires_at, @status.poll.id) if @status.poll end def validate_media! diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb index 99c8e6cbb..7eec11ddf 100644 --- a/app/services/remove_status_service.rb +++ b/app/services/remove_status_service.rb @@ -10,7 +10,7 @@ class RemoveStatusService < BaseService @account = status.account @tags = status.tags.pluck(:name).to_a @mentions = status.active_mentions.includes(:account).to_a - @reblogs = status.reblogs.to_a + @reblogs = status.reblogs.includes(:account).to_a @stream_entry = status.stream_entry @options = options @@ -78,8 +78,8 @@ class RemoveStatusService < BaseService end # ActivityPub - ActivityPub::DeliveryWorker.push_bulk(target_accounts.select(&:activitypub?).uniq(&:inbox_url)) do |target_account| - [signed_activity_json, @account.id, target_account.inbox_url] + ActivityPub::DeliveryWorker.push_bulk(target_accounts.select(&:activitypub?).uniq(&:preferred_inbox_url)) do |target_account| + [signed_activity_json, @account.id, target_account.preferred_inbox_url] end end diff --git a/app/services/vote_service.rb b/app/services/vote_service.rb index 5b80da03a..0cace6c00 100644 --- a/app/services/vote_service.rb +++ b/app/services/vote_service.rb @@ -19,8 +19,27 @@ class VoteService < BaseService end end - return if @poll.account.local? + if @poll.account.local? + distribute_poll! + else + deliver_votes! + queue_final_poll_check! + end + end + + private + + def distribute_poll! + return if @poll.hide_totals? + ActivityPub::DistributePollUpdateWorker.perform_in(3.minutes, @poll.status.id) + end + + def queue_final_poll_check! + return unless @poll.expires? + PollExpirationNotifyWorker.perform_at(@poll.expires_at + 5.minutes, @poll.id) + end + def deliver_votes! @votes.each do |vote| ActivityPub::DeliveryWorker.perform_async( build_json(vote), @@ -30,8 +49,6 @@ class VoteService < BaseService end end - private - def build_json(vote) ActiveModelSerializers::SerializableResource.new( vote, diff --git a/app/views/about/_features.html.haml b/app/views/about/_features.html.haml deleted file mode 100644 index 8fbc6b760..000000000 --- a/app/views/about/_features.html.haml +++ /dev/null @@ -1,25 +0,0 @@ -.features-list - .features-list__row - .text - %h6= t 'about.features.real_conversation_title' - = t 'about.features.real_conversation_body' - .visual - = fa_icon 'fw comments' - .features-list__row - .text - %h6= t 'about.features.not_a_product_title' - = t 'about.features.not_a_product_body' - .visual - = fa_icon 'fw users' - .features-list__row - .text - %h6= t 'about.features.within_reach_title' - = t 'about.features.within_reach_body' - .visual - = fa_icon 'fw mobile' - .features-list__row - .text - %h6= t 'about.features.humane_approach_title' - = t 'about.features.humane_approach_body' - .visual - = fa_icon 'fw leaf' diff --git a/app/views/about/_forms.html.haml b/app/views/about/_forms.html.haml deleted file mode 100644 index 78a422690..000000000 --- a/app/views/about/_forms.html.haml +++ /dev/null @@ -1,15 +0,0 @@ -- if @instance_presenter.open_registrations - = render 'registration' -- else - = link_to t('auth.register_elsewhere'), 'https://joinmastodon.org/#getting-started', class: 'button button-primary' - - .closed-registrations-message - - if @instance_presenter.closed_registrations_message.blank? - %p= t('about.closed_registrations') - - else - = @instance_presenter.closed_registrations_message.html_safe - -.separator-or - %span= t('auth.or') - -= link_to t('auth.login'), new_user_session_path, class: 'button button-alternative-2 webapp-btn' diff --git a/app/views/about/_links.html.haml b/app/views/about/_links.html.haml deleted file mode 100644 index 381f301f9..000000000 --- a/app/views/about/_links.html.haml +++ /dev/null @@ -1,16 +0,0 @@ -.container-alt.links - .brand - = link_to root_url do - = image_tag asset_pack_path('logo_full.svg'), alt: 'Mastodon' - - %ul.nav - %li - - if user_signed_in? - = link_to t('settings.back'), root_url, class: 'webapp-btn' - - else - = link_to t('auth.login'), new_user_session_path, class: 'webapp-btn' - %li= link_to t('about.about_this'), about_more_path - %li - = link_to 'https://joinmastodon.org/#getting-started' do - = "#{t('about.other_instances')}" - %i.fa.fa-external-link{ style: 'padding-left: 5px;' } diff --git a/app/views/about/_login.html.haml b/app/views/about/_login.html.haml new file mode 100644 index 000000000..d286f0d3c --- /dev/null +++ b/app/views/about/_login.html.haml @@ -0,0 +1,13 @@ += simple_form_for(new_user, url: user_session_path) do |f| + .fields-group + - if use_seamless_external_login? + = f.input :email, placeholder: t('simple_form.labels.defaults.username_or_email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.username_or_email') }, hint: false + - else + = f.input :email, placeholder: t('simple_form.labels.defaults.email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.email') }, hint: false + + = f.input :password, placeholder: t('simple_form.labels.defaults.password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.password') }, hint: false + + .actions + = f.button :button, t('auth.login'), type: :submit, class: 'button button-primary' + + %p.hint.subtle-hint= link_to t('auth.trouble_logging_in'), new_user_password_path diff --git a/app/views/about/_registration.html.haml b/app/views/about/_registration.html.haml index ee4f8fe2e..9cb4eb2bc 100644 --- a/app/views/about/_registration.html.haml +++ b/app/views/about/_registration.html.haml @@ -1,12 +1,16 @@ = simple_form_for(new_user, url: user_registration_path) do |f| - = f.simple_fields_for :account do |account_fields| - = account_fields.input :username, wrapper: :with_label, autofocus: true, label: false, required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.username'), :autocomplete => 'off', placeholder: t('simple_form.labels.defaults.username') }, append: "@#{site_hostname}", hint: false + %p.lead= t('about.federation_hint_html', instance: content_tag(:strong, site_hostname)) - = f.input :email, placeholder: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email'), :autocomplete => 'off' }, hint: false - = f.input :password, placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'off' }, hint: false - = f.input :password_confirmation, placeholder: t('simple_form.labels.defaults.confirm_password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_password'), :autocomplete => 'off' }, hint: false + .fields-group + = f.simple_fields_for :account do |account_fields| + = account_fields.input :username, wrapper: :with_label, autofocus: true, label: false, required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.username'), :autocomplete => 'off', placeholder: t('simple_form.labels.defaults.username') }, append: "@#{site_hostname}", hint: false, disabled: closed_registrations? - .actions - = f.button :button, t('auth.register'), type: :submit, class: 'button button-primary' + = f.input :email, placeholder: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email'), :autocomplete => 'off' }, hint: false, disabled: closed_registrations? + = f.input :password, placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'off' }, hint: false, disabled: closed_registrations? + = f.input :password_confirmation, placeholder: t('simple_form.labels.defaults.confirm_password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_password'), :autocomplete => 'off' }, hint: false, disabled: closed_registrations? + + .fields-group + = f.input :agreement, as: :boolean, wrapper: :with_label, label: t('auth.checkbox_agreement_html', rules_path: about_more_path, terms_path: terms_path), disabled: closed_registrations? - %p.hint.subtle-hint=t('auth.agreement_html', rules_path: about_more_path, terms_path: terms_path) + .actions + = f.button :button, sign_up_message, type: :submit, class: 'button button-primary', disabled: closed_registrations? diff --git a/app/views/about/show.html.haml b/app/views/about/show.html.haml index 6c28f83ce..15d0af64e 100644 --- a/app/views/about/show.html.haml +++ b/app/views/about/show.html.haml @@ -3,143 +3,76 @@ - content_for :header_tags do %link{ rel: 'canonical', href: about_url }/ - %script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json) = render partial: 'shared/og' -.landing-page.alternative - .container - .grid - .column-0 - .brand - = link_to root_url do - = image_tag asset_pack_path('logo_full.svg'), alt: 'Mastodon' - - - if Setting.timeline_preview - .column-1 - .landing-page__forms - .brand - = link_to root_url do - = image_tag asset_pack_path('logo_full.svg'), alt: 'Mastodon' - - = render 'forms' - - - else - .column-1.non-preview - .landing-page__forms - .brand - = link_to root_url do - = image_tag asset_pack_path('logo_full.svg'), alt: 'Mastodon' - - = render 'forms' - - - if Setting.timeline_preview - .column-2 - .landing-page__hero - = image_tag @instance_presenter.hero&.file&.url || @instance_presenter.thumbnail&.file&.url || asset_pack_path('preview.jpg'), alt: @instance_presenter.site_title - - .landing-page__information - .landing-page__short-description - .row - .landing-page__logo - = image_tag asset_pack_path('logo_transparent.svg'), alt: 'Mastodon' - - %h1 - = @instance_presenter.site_title - %small!= t 'about.hosted_on', domain: content_tag(:span, site_hostname) - - %p= @instance_presenter.site_description.html_safe.presence || t('about.generic_description', domain: site_hostname) - - .landing-page__call-to-action{ dir: 'ltr' } - .row - .row__information-board - .information-board__section - %span= t 'about.user_count_before' - %strong= number_with_delimiter @instance_presenter.user_count - %span= t 'about.user_count_after', count: @instance_presenter.user_count - .information-board__section - %span= t 'about.status_count_before' - %strong= number_with_delimiter @instance_presenter.status_count - %span= t 'about.status_count_after', count: @instance_presenter.status_count - .row__mascot - .landing-page__mascot - = image_tag @instance_presenter.mascot&.file&.url || asset_pack_path('elephant_ui_plane.svg'), alt: '' - - - else - .column-2.non-preview - .landing-page__hero - = image_tag @instance_presenter.hero&.file&.url || @instance_presenter.thumbnail&.file&.url || asset_pack_path('preview.jpg'), alt: @instance_presenter.site_title - - .landing-page__information - .landing-page__short-description - .row - .landing-page__logo - = image_tag asset_pack_path('logo_transparent.svg'), alt: 'Mastodon' - - %h1 - = @instance_presenter.site_title - %small!= t 'about.hosted_on', domain: content_tag(:span, site_hostname) - - %p= @instance_presenter.site_description.html_safe.presence || t('about.generic_description', domain: site_hostname) - - .landing-page__call-to-action - .row - .row__information-board - .information-board__section - %span= t 'about.user_count_before' - %strong= number_with_delimiter @instance_presenter.user_count - %span= t 'about.user_count_after', count: @instance_presenter.user_count - .information-board__section - %span= t 'about.status_count_before' - %strong= number_with_delimiter @instance_presenter.status_count - %span= t 'about.status_count_after', count: @instance_presenter.status_count - .row__mascot - .landing-page__mascot - = image_tag @instance_presenter.mascot&.file&.url || asset_pack_path('elephant_ui_plane.svg'), alt: '' - - - if Setting.timeline_preview - .column-3 - #mastodon-timeline{ data: { props: Oj.dump(default_props) } } - - - if Setting.timeline_preview - .column-4.landing-page__information - .landing-page__features - .features-list - %div - %h3= t 'about.what_is_mastodon' - %p= t 'about.about_mastodon_html' - %div.contact - %h3= t 'about.administered_by' - = account_link_to(@instance_presenter.contact_account, link_to(t('about.learn_more'), about_more_path, class: 'button button-alternative')) - - = render 'features' - - .landing-page__features__action - = link_to t('about.learn_more'), 'https://joinmastodon.org/', class: 'button button-alternative' - - .landing-page__footer +.landing + .landing__brand + = link_to root_url, class: 'brand' do + = image_tag asset_pack_path('logo_full.svg'), alt: 'Mastodon' + %span.brand__tagline=t 'about.tagline' + + .landing__grid + .landing__grid__column.landing__grid__column-registration + .box-widget + = render 'registration' + + .directory + .directory__tag{ class: Setting.profile_directory ? nil : 'disabled' } + = optional_link_to Setting.profile_directory, explore_path do + %h4 + = fa_icon 'address-book fw' + = t('about.discover_users') + %small= t('about.browse_directory') + + .avatar-stack + - @instance_presenter.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' + + .directory__tag{ class: Setting.timeline_preview ? nil : 'disabled' } + = optional_link_to Setting.timeline_preview, public_timeline_path do + %h4 + = fa_icon 'globe fw' + = t('about.see_whats_happening') + %small= t('about.browse_public_posts') + + .directory__tag + = link_to 'https://joinmastodon.org/apps', target: '_blank', rel: 'noopener' do + %h4 + = fa_icon 'tablet fw' + = t('about.get_apps') + %small= t('about.apps_platforms') + + .landing__grid__column.landing__grid__column-login + .box-widget + = render 'login' + + .hero-widget + .hero-widget__img + = image_tag @instance_presenter.hero&.file&.url || @instance_presenter.thumbnail&.file&.url || asset_pack_path('preview.jpg'), alt: @instance_presenter.site_title + + - if @instance_presenter.site_short_description.present? + .hero-widget__text %p - = link_to t('about.source_code'), @instance_presenter.source_url - = " (#{@instance_presenter.version_number})" - - - else - .column-4.non-preview.landing-page__information - .landing-page__features - .features-list - %div - %h3= t 'about.what_is_mastodon' - %p= t 'about.about_mastodon_html' - %div.contact - %h3= t 'about.administered_by' - = account_link_to(@instance_presenter.contact_account, link_to(t('about.learn_more'), about_more_path, class: 'button button-alternative')) - - = render 'features' - - .landing-page__features__action - = link_to t('about.learn_more'), 'https://joinmastodon.org/', class: 'button button-alternative' - - .landing-page__footer - %p - = link_to t('about.source_code'), @instance_presenter.source_url - = " (#{@instance_presenter.version_number})" - -#modal-container + = @instance_presenter.site_short_description.html_safe.presence + = link_to about_more_path do + = t('about.learn_more') + = fa_icon 'angle-double-right' + + .hero-widget__footer + .hero-widget__footer__column + %h4= t 'about.administered_by' + + = account_link_to @instance_presenter.contact_account + + .hero-widget__footer__column + %h4= t 'about.server_stats' + + %div{ style: 'display: flex' } + .hero-widget__counter{ style: 'width: 50%' } + %strong= number_to_human @instance_presenter.user_count, strip_insignificant_zeros: true + %span= t 'about.user_count_after', count: @instance_presenter.user_count + .hero-widget__counter{ style: 'width: 50%' } + %strong= number_to_human @instance_presenter.active_user_count, strip_insignificant_zeros: true + %span + = t 'about.active_count_after' + %abbr{ title: t('about.active_footnote') } * diff --git a/app/views/admin/accounts/_account.html.haml b/app/views/admin/accounts/_account.html.haml index 1e1bb1812..eba3ad804 100644 --- a/app/views/admin/accounts/_account.html.haml +++ b/app/views/admin/accounts/_account.html.haml @@ -5,7 +5,7 @@ %div{ style: 'margin: -2px 0' }= account_badge(account, all: true) %td - if account.user_current_sign_in_ip - %samp= account.user_current_sign_in_ip + %samp.ellipsized-ip{ title: account.user_current_sign_in_ip }= account.user_current_sign_in_ip - else \- %td @@ -14,5 +14,9 @@ - else \- %td - = table_link_to 'circle', t('admin.accounts.web'), web_path("accounts/#{account.id}") - = table_link_to 'globe', t('admin.accounts.public'), TagManager.instance.url_for(account) + - if account.local? && account.user_pending? + = table_link_to 'check', t('admin.accounts.approve'), approve_admin_account_path(account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') } if can?(:approve, account.user) + = table_link_to 'times', t('admin.accounts.reject'), reject_admin_account_path(account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') } if can?(:reject, account.user) + - else + = table_link_to 'circle', t('admin.accounts.web'), web_path("accounts/#{account.id}") + = table_link_to 'globe', t('admin.accounts.public'), TagManager.instance.url_for(account) diff --git a/app/views/admin/accounts/index.html.haml b/app/views/admin/accounts/index.html.haml index 345f74f90..66808add7 100644 --- a/app/views/admin/accounts/index.html.haml +++ b/app/views/admin/accounts/index.html.haml @@ -10,9 +10,10 @@ .filter-subset %strong= t('admin.accounts.moderation.title') %ul - %li= filter_link_to t('admin.accounts.moderation.active'), silenced: nil, suspended: nil - %li= filter_link_to t('admin.accounts.moderation.silenced'), silenced: '1', suspended: nil - %li= filter_link_to t('admin.accounts.moderation.suspended'), suspended: '1', silenced: nil + %li= filter_link_to t('admin.accounts.moderation.pending'), pending: '1', silenced: nil, suspended: nil + %li= filter_link_to t('admin.accounts.moderation.active'), silenced: nil, suspended: nil, pending: nil + %li= filter_link_to t('admin.accounts.moderation.silenced'), silenced: '1', suspended: nil, pending: nil + %li= filter_link_to t('admin.accounts.moderation.suspended'), suspended: '1', silenced: nil, pending: nil .filter-subset %strong= t('admin.accounts.role') %ul diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml index 7ac73bd07..7494c9fa2 100644 --- a/app/views/admin/accounts/show.html.haml +++ b/app/views/admin/accounts/show.html.haml @@ -37,6 +37,8 @@ %span.red= t('admin.accounts.disabled') - elsif @account.local? && !@account.user&.confirmed? %span.neutral= t('admin.accounts.confirming') + - elsif @account.local? && !@account.user_approved? + %span.neutral= t('admin.accounts.pending') - else %span.neutral= t('admin.accounts.no_limits_imposed') .dashboard__counters__label= t 'admin.accounts.login_status' @@ -95,7 +97,7 @@ %td - if @account.user&.disabled? = table_link_to 'unlock', t('admin.accounts.enable'), enable_admin_account_path(@account.id), method: :post if can?(:enable, @account.user) - - else + - elsif @account.user_approved? = table_link_to 'lock', t('admin.accounts.disable'), new_admin_account_action_path(@account.id, type: 'disable') if can?(:disable, @account.user) %tr @@ -144,26 +146,30 @@ = link_to t('admin.accounts.reset_password'), admin_account_reset_path(@account.id), method: :create, class: 'button' if can?(:reset_password, @account.user) - if @account.user&.otp_required_for_login? = link_to t('admin.accounts.disable_two_factor_authentication'), admin_user_two_factor_authentication_path(@account.user.id), method: :delete, class: 'button' if can?(:disable_2fa, @account.user) - - unless @account.memorial? + - if !@account.memorial? && @account.user_approved? = link_to t('admin.accounts.memorialize'), memorialize_admin_account_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button button--destructive' if can?(:memorialize, @account) - else = link_to t('admin.accounts.redownload'), redownload_admin_account_path(@account.id), method: :post, class: 'button' if can?(:redownload, @account) %div{ style: 'float: left' } - - if @account.local? + - if @account.local? && @account.user_approved? = link_to t('admin.accounts.warn'), new_admin_account_action_path(@account.id, type: 'none'), class: 'button' if can?(:warn, @account) - if @account.silenced? = link_to t('admin.accounts.undo_silenced'), unsilence_admin_account_path(@account.id), method: :post, class: 'button' if can?(:unsilence, @account) - - else + - elsif !@account.local? || @account.user_approved? = link_to t('admin.accounts.silence'), new_admin_account_action_path(@account.id, type: 'silence'), class: 'button button--destructive' if can?(:silence, @account) - if @account.local? + - if @account.user_pending? + = link_to t('admin.accounts.approve'), approve_admin_account_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button' if can?(:approve, @account.user) + = link_to t('admin.accounts.reject'), reject_admin_account_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button button--destructive' if can?(:reject, @account.user) + - unless @account.user_confirmed? = link_to t('admin.accounts.confirm'), admin_account_confirmation_path(@account.id), method: :post, class: 'button' if can?(:confirm, @account.user) - if @account.suspended? = link_to t('admin.accounts.undo_suspension'), unsuspend_admin_account_path(@account.id), method: :post, class: 'button' if can?(:unsuspend, @account) - - else + - elsif !@account.local? || @account.user_approved? = link_to t('admin.accounts.perform_full_suspension'), new_admin_account_action_path(@account.id, type: 'suspend'), class: 'button button--destructive' if can?(:suspend, @account) - unless @account.local? diff --git a/app/views/admin/invites/_invite.html.haml b/app/views/admin/invites/_invite.html.haml index d7b697286..ee0eacaf5 100644 --- a/app/views/admin/invites/_invite.html.haml +++ b/app/views/admin/invites/_invite.html.haml @@ -1,21 +1,29 @@ %tr %td + .input-copy + .input-copy__wrapper + %input{ type: :text, maxlength: '999', spellcheck: 'false', readonly: 'true', value: public_invite_url(invite_code: invite.code) } + %button{ type: :button }= t('generic.copy') + + %td .name-tag = image_tag invite.user.account.avatar.url(:original), alt: '', width: 16, height: 16, class: 'avatar' %span.username= invite.user.account.username - %td - = invite.uses - = " / #{invite.max_uses}" unless invite.max_uses.nil? - %td - - if invite.expired? + + - if invite.expired? + %td{ colspan: 2 } = t('invites.expired') - - else + - else + %td + = fa_icon 'user fw' + = invite.uses + = " / #{invite.max_uses}" unless invite.max_uses.nil? + %td - if invite.expires_at.nil? ∞ - else %time.formatted{ datetime: invite.expires_at.iso8601, title: l(invite.expires_at) } = l invite.expires_at - %td= table_link_to 'link', public_invite_url(invite_code: invite.code), public_invite_url(invite_code: invite.code) %td - if !invite.expired? && policy(invite).destroy? = table_link_to 'times', t('invites.delete'), admin_invite_path(invite), method: :delete diff --git a/app/views/admin/invites/index.html.haml b/app/views/admin/invites/index.html.haml index 42159e9f3..ee6ba0f57 100644 --- a/app/views/admin/invites/index.html.haml +++ b/app/views/admin/invites/index.html.haml @@ -18,15 +18,15 @@ %hr.spacer/ -.table-wrapper - %table.table +.table-wrapper.simple_form + %table.table.table--invites %thead %tr %th + %th %th= t('invites.table.uses') %th= t('invites.table.expires_at') %th - %th %tbody = render @invites diff --git a/app/views/admin/settings/edit.html.haml b/app/views/admin/settings/edit.html.haml index e3ceb4344..9995e0b2a 100644 --- a/app/views/admin/settings/edit.html.haml +++ b/app/views/admin/settings/edit.html.haml @@ -6,8 +6,11 @@ .fields-group = f.input :site_title, wrapper: :with_label, label: t('admin.settings.site_title') - .fields-group - = f.input :flavour_and_skin, collection: Themes.instance.flavours_and_skins, group_label_method: lambda { |(flavour, _)| I18n.t("flavours.#{flavour}.name", default: flavour) }, wrapper: :with_label, include_blank: false, as: :grouped_select, label_method: :last, value_method: lambda { |value| value.join('/') }, group_method: :last + .fields-row + .fields-row__column.fields-row__column-6.fields-group + = f.input :flavour_and_skin, collection: Themes.instance.flavours_and_skins, group_label_method: lambda { |(flavour, _)| I18n.t("flavours.#{flavour}.name", default: flavour) }, wrapper: :with_label, include_blank: false, as: :grouped_select, label_method: :last, value_method: lambda { |value| value.join('/') }, group_method: :last + .fields-row__column.fields-row__column-6.fields-group + = f.input :registrations_mode, collection: %w(open approved none), wrapper: :with_label, label: t('admin.settings.registrations_mode.title'), include_blank: false, label_method: lambda { |mode| I18n.t("admin.settings.registrations_mode.modes.#{mode}") } .fields-row .fields-row__column.fields-row__column-6.fields-group @@ -48,9 +51,6 @@ = f.input :show_staff_badge, as: :boolean, wrapper: :with_label, label: t('admin.settings.show_staff_badge.title'), hint: t('admin.settings.show_staff_badge.desc_html') .fields-group - = f.input :open_registrations, as: :boolean, wrapper: :with_label, label: t('admin.settings.registrations.open.title'), hint: t('admin.settings.registrations.open.desc_html') - - .fields-group = f.input :open_deletion, as: :boolean, wrapper: :with_label, label: t('admin.settings.registrations.deletion.title'), hint: t('admin.settings.registrations.deletion.desc_html') .fields-group diff --git a/app/views/admin_mailer/new_pending_account.text.erb b/app/views/admin_mailer/new_pending_account.text.erb new file mode 100644 index 000000000..ed31ae2eb --- /dev/null +++ b/app/views/admin_mailer/new_pending_account.text.erb @@ -0,0 +1,8 @@ +<%= raw t('application_mailer.salutation', name: display_name(@me)) %> + +<%= raw t('admin_mailer.new_pending_account.body') %> + +<%= raw t('admin.accounts.email') %>: <%= @account.user_email %> +<%= raw t('admin.accounts.most_recent_ip') %>: <%= @account.user_current_sign_in_ip %> + +<%= raw t('application_mailer.view')%> <%= admin_account_url(@account.id) %> diff --git a/app/views/auth/registrations/new.html.haml b/app/views/auth/registrations/new.html.haml index 72ce8e531..1caf2b401 100644 --- a/app/views/auth/registrations/new.html.haml +++ b/app/views/auth/registrations/new.html.haml @@ -29,6 +29,6 @@ %p.hint= t('auth.agreement_html', rules_path: about_more_path, terms_path: terms_path) .actions - = f.button :button, t('auth.register'), type: :submit + = f.button :button, sign_up_message, type: :submit .form-footer= render 'auth/shared/links' diff --git a/app/views/auth/shared/_links.html.haml b/app/views/auth/shared/_links.html.haml index 516c625a6..3c68ccd22 100644 --- a/app/views/auth/shared/_links.html.haml +++ b/app/views/auth/shared/_links.html.haml @@ -3,7 +3,7 @@ %li= link_to t('auth.login'), new_session_path(resource_name) - if devise_mapping.registerable? && controller_name != 'registrations' - %li= link_to t('auth.register'), open_registrations? ? new_registration_path(resource_name) : 'https://joinmastodon.org/#getting-started' + %li= link_to t('auth.register'), available_sign_up_path - if devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations' %li= link_to t('auth.forgot_password'), new_password_path(resource_name) diff --git a/app/views/invites/_invite.html.haml b/app/views/invites/_invite.html.haml index 1c7ec311d..4240aa3e7 100644 --- a/app/views/invites/_invite.html.haml +++ b/app/views/invites/_invite.html.haml @@ -1,17 +1,25 @@ %tr %td - = invite.uses - = " / #{invite.max_uses}" unless invite.max_uses.nil? - %td - - if invite.expired? + .input-copy + .input-copy__wrapper + %input{ type: :text, maxlength: '999', spellcheck: 'false', readonly: 'true', value: public_invite_url(invite_code: invite.code) } + %button{ type: :button }= t('generic.copy') + + - if invite.expired? + %td{ colspan: 2 } = t('invites.expired') - - else + - else + %td + = fa_icon 'user fw' + = invite.uses + = " / #{invite.max_uses}" unless invite.max_uses.nil? + %td - if invite.expires_at.nil? ∞ - else %time.formatted{ datetime: invite.expires_at.iso8601, title: l(invite.expires_at) } = l invite.expires_at - %td= table_link_to 'link', public_invite_url(invite_code: invite.code), public_invite_url(invite_code: invite.code) + %td - if !invite.expired? && policy(invite).destroy? = table_link_to 'times', t('invites.delete'), invite_path(invite), method: :delete diff --git a/app/views/invites/index.html.haml b/app/views/invites/index.html.haml index fb827f6e6..61420ab1e 100644 --- a/app/views/invites/index.html.haml +++ b/app/views/invites/index.html.haml @@ -8,12 +8,13 @@ %hr.spacer/ -%table.table - %thead - %tr - %th= t('invites.table.uses') - %th= t('invites.table.expires_at') - %th - %th - %tbody - = render @invites +.simple_form + %table.table.table--invites + %thead + %tr + %th + %th= t('invites.table.uses') + %th= t('invites.table.expires_at') + %th + %tbody + = render @invites diff --git a/app/views/layouts/public.html.haml b/app/views/layouts/public.html.haml index c1c0f4b87..1d3519b8a 100644 --- a/app/views/layouts/public.html.haml +++ b/app/views/layouts/public.html.haml @@ -1,22 +1,22 @@ - content_for :content do .public-layout - .container - %nav.header - .nav-left - = link_to root_url, class: 'brand' do - = image_tag asset_pack_path('logo_full.svg'), alt: 'Mastodon' + - unless @hide_navbar + .container + %nav.header + .nav-left + = link_to root_url, class: 'brand' do + = image_tag asset_pack_path('logo_full.svg'), alt: 'Mastodon' - - if Setting.profile_directory - = link_to t('directories.directory'), explore_path, class: 'nav-link optional' - = link_to t('about.about_this'), about_more_path, class: 'nav-link optional' - = link_to t('about.apps'), 'https://joinmastodon.org/apps', class: 'nav-link optional' - .nav-center - .nav-right - - if user_signed_in? - = link_to t('settings.back'), root_url, class: 'nav-link nav-button webapp-btn' - - else - = link_to t('auth.login'), new_user_session_path, class: 'webapp-btn nav-link nav-button' - = link_to t('auth.register'), open_registrations? ? new_user_registration_path : 'https://joinmastodon.org/#getting-started', class: 'webapp-btn nav-link nav-button' + = link_to t('directories.directory'), explore_path, class: 'nav-link optional' if Setting.profile_directory + = link_to t('about.about_this'), about_more_path, class: 'nav-link optional' + = link_to t('about.apps'), 'https://joinmastodon.org/apps', class: 'nav-link optional' + .nav-center + .nav-right + - if user_signed_in? + = link_to t('settings.back'), root_url, class: 'nav-link nav-button webapp-btn' + - else + = link_to t('auth.login'), new_user_session_path, class: 'webapp-btn nav-link nav-button' + = link_to t('auth.register'), available_sign_up_path, class: 'webapp-btn nav-link nav-button' .container= yield diff --git a/app/views/public_timelines/show.html.haml b/app/views/public_timelines/show.html.haml new file mode 100644 index 000000000..f80157c67 --- /dev/null +++ b/app/views/public_timelines/show.html.haml @@ -0,0 +1,13 @@ +- content_for :page_title do + = t('about.see_whats_happening') + +- content_for :header_tags do + %meta{ name: 'robots', content: 'noindex' }/ + %script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json) + +.page-header + %h1= t('about.see_whats_happening') + %p= t('about.browse_public_posts') + +#mastodon-timeline{ data: { props: Oj.dump(default_props) }} +#modal-container diff --git a/app/views/remote_follow/new.html.haml b/app/views/remote_follow/new.html.haml index 5cf6977ba..4e9601f6a 100644 --- a/app/views/remote_follow/new.html.haml +++ b/app/views/remote_follow/new.html.haml @@ -1,6 +1,5 @@ - content_for :header_tags do - - if @account.user&.setting_noindex - %meta{ name: 'robots', content: 'noindex' }/ + %meta{ name: 'robots', content: 'noindex' }/ .form-container .follow-prompt @@ -18,4 +17,4 @@ %p.hint.subtle-hint = t('remote_follow.reason_html', instance: site_hostname) - = t('remote_follow.no_account_html', sign_up_path: open_registrations? ? new_user_registration_path : 'https://joinmastodon.org/#getting-started') + = t('remote_follow.no_account_html', sign_up_path: available_sign_up_path) diff --git a/app/views/remote_interaction/new.html.haml b/app/views/remote_interaction/new.html.haml index a0b106814..c8c08991f 100644 --- a/app/views/remote_interaction/new.html.haml +++ b/app/views/remote_interaction/new.html.haml @@ -1,3 +1,6 @@ +- content_for :header_tags do + %meta{ name: 'robots', content: 'noindex' }/ + .form-container .follow-prompt %h2= t("remote_interaction.#{@interaction_type}.prompt") @@ -18,4 +21,4 @@ %p.hint.subtle-hint = t('remote_follow.reason_html', instance: site_hostname) - = t('remote_follow.no_account_html', sign_up_path: open_registrations? ? new_user_registration_path : 'https://joinmastodon.org/#getting-started') + = t('remote_follow.no_account_html', sign_up_path: available_sign_up_path) diff --git a/app/views/tags/show.html.haml b/app/views/tags/show.html.haml index 850160ac1..1a9c58983 100644 --- a/app/views/tags/show.html.haml +++ b/app/views/tags/show.html.haml @@ -2,6 +2,7 @@ = "##{@tag.name}" - content_for :header_tags do + %meta{ name: 'robots', content: 'noindex' }/ %link{ rel: 'alternate', type: 'application/rss+xml', href: tag_url(@tag, format: 'rss') }/ %script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json) diff --git a/app/views/user_mailer/confirmation_instructions.html.haml b/app/views/user_mailer/confirmation_instructions.html.haml index f75f7529a..70d0f5a24 100644 --- a/app/views/user_mailer/confirmation_instructions.html.haml +++ b/app/views/user_mailer/confirmation_instructions.html.haml @@ -36,7 +36,7 @@ %tbody %tr %td.column-cell.text-center - %p= t 'devise.mailer.confirmation_instructions.explanation', host: site_hostname + %p= t @resource.approved? ? 'devise.mailer.confirmation_instructions.explanation' : 'devise.mailer.confirmation_instructions.explanation_when_pending', host: site_hostname %table.email-table{ cellspacing: 0, cellpadding: 0 } %tbody diff --git a/app/views/user_mailer/confirmation_instructions.text.erb b/app/views/user_mailer/confirmation_instructions.text.erb index 65b4626c6..aad91cd9d 100644 --- a/app/views/user_mailer/confirmation_instructions.text.erb +++ b/app/views/user_mailer/confirmation_instructions.text.erb @@ -2,7 +2,7 @@ === -<%= t 'devise.mailer.confirmation_instructions.explanation', host: site_hostname %> +<%= t @resource.approved? ? 'devise.mailer.confirmation_instructions.explanation' : 'devise.mailer.confirmation_instructions.explanation_when_pending', host: site_hostname %> => <%= confirmation_url(@resource, confirmation_token: @token, redirect_to_app: @resource.created_by_application ? 'true' : nil) %> diff --git a/app/workers/activitypub/distribute_poll_update_worker.rb b/app/workers/activitypub/distribute_poll_update_worker.rb new file mode 100644 index 000000000..5536bd744 --- /dev/null +++ b/app/workers/activitypub/distribute_poll_update_worker.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +class ActivityPub::DistributePollUpdateWorker + include Sidekiq::Worker + + sidekiq_options queue: 'push', unique: :until_executed, retry: 0 + + def perform(status_id) + @status = Status.find(status_id) + @account = @status.account + + return if @status.poll.nil? || @status.local_only? + + ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url| + [payload, @account.id, inbox_url] + end + + relay! if relayable? + rescue ActiveRecord::RecordNotFound + true + end + + private + + def relayable? + @status.public_visibility? + end + + def inboxes + return @inboxes if defined?(@inboxes) + + @inboxes = [@status.mentions, @status.reblogs, @status.poll.votes].flat_map do |relation| + relation.includes(:account).map do |record| + record.account.preferred_inbox_url if !record.account.local? && record.account.activitypub? + end + end + + @inboxes.concat(@account.followers.inboxes) unless @status.direct_visibility? + @inboxes.uniq! + @inboxes.compact! + @inboxes + end + + def signed_payload + Oj.dump(ActivityPub::LinkedDataSignature.new(unsigned_payload).sign!(@account)) + end + + def unsigned_payload + ActiveModelSerializers::SerializableResource.new( + @status, + serializer: ActivityPub::UpdatePollSerializer, + adapter: ActivityPub::Adapter + ).as_json + end + + def payload + @payload ||= @status.distributable? ? signed_payload : Oj.dump(unsigned_payload) + end + + def relay! + ActivityPub::DeliveryWorker.push_bulk(Relay.enabled.pluck(:inbox_url)) do |inbox_url| + [payload, @account.id, inbox_url] + end + end +end diff --git a/app/workers/poll_expiration_notify_worker.rb b/app/workers/poll_expiration_notify_worker.rb new file mode 100644 index 000000000..e08f0c249 --- /dev/null +++ b/app/workers/poll_expiration_notify_worker.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class PollExpirationNotifyWorker + include Sidekiq::Worker + + sidekiq_options unique: :until_executed + + def perform(poll_id) + poll = Poll.find(poll_id) + + # Notify poll owner and remote voters + if poll.local? + ActivityPub::DistributePollUpdateWorker.perform_async(poll.status.id) + NotifyService.new.call(poll.account, poll) + end + + # Notify local voters + poll.votes.includes(:account).map(&:account).select(&:local?).each do |account| + NotifyService.new.call(account, poll) + end + rescue ActiveRecord::RecordNotFound + true + end +end |