diff options
397 files changed, 9421 insertions, 2509 deletions
diff --git a/.env.production.sample b/.env.production.sample index 1d8a177aa..3e054db84 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -26,7 +26,7 @@ LOCAL_HTTPS=true # ALTERNATE_DOMAINS=example1.com,example2.com # Application secrets -# Generate each with the `rake secret` task (`docker-compose run --rm web rake secret` if you use docker compose) +# Generate each with the `RAILS_ENV=production bundle exec rake secret` task (`docker-compose run --rm web rake secret` if you use docker compose) PAPERCLIP_SECRET= SECRET_KEY_BASE= OTP_SECRET= @@ -36,7 +36,7 @@ OTP_SECRET= # You should only generate this once per instance. If you later decide to change it, all push subscription will # be invalidated, requiring the users to access the website again to resubscribe. # -# Generate with `rake mastodon:webpush:generate_vapid_key` task (`docker-compose run --rm web rake mastodon:webpush:generate_vapid_key` if you use docker compose) +# Generate with `RAILS_ENV=production bundle exec rake mastodon:webpush:generate_vapid_key` task (`docker-compose run --rm web rake mastodon:webpush:generate_vapid_key` if you use docker compose) # # For more information visit https://rossta.net/blog/using-the-web-push-api-with-vapid.html VAPID_PRIVATE_KEY= @@ -98,6 +98,15 @@ SMTP_FROM_ADDRESS=notifications@example.com # S3_ENDPOINT= # S3_SIGNATURE_VERSION= +# Swift (optional) +# SWIFT_ENABLED=true +# SWIFT_USERNAME= +# SWIFT_TENANT= +# SWIFT_PASSWORD= +# SWIFT_AUTH_URL= +# SWIFT_CONTAINER= +# SWIFT_OBJECT_URL= + # Optional alias for S3 if you want to use Cloudfront or Cloudflare in front # S3_CLOUDFRONT_HOST= diff --git a/.eslintrc.yml b/.eslintrc.yml index fd2ba46dd..1c60cbdb3 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -49,6 +49,7 @@ rules: - warn - allow: - error + - warn no-fallthrough: error no-irregular-whitespace: error no-mixed-spaces-and-tabs: warn diff --git a/.rubocop.yml b/.rubocop.yml index ae3697174..a36aa5cae 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -10,6 +10,7 @@ AllCops: - 'node_modules/**/*' - 'Vagrantfile' - 'vendor/**/*' + - 'lib/json_ld/*' Bundler/OrderedGems: Enabled: false diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 000000000..42fc73ded --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,15 @@ +# CODEOWNERS for tootsuite/mastodon + +# Translators +# To add translator, copy these lines, replace `fr` with appropriate language code and replace `@żelipapą` with user's GitHub nickname preceded by `@` sign or e-mail address. +# /app/javascript/mastodon/locales/fr.json @żelipapą +# /app/views/user_mailer/*.fr.html.erb @żelipapą +# /app/views/user_mailer/*.fr.text.erb @żelipapą +# /config/locales/*.fr.yml @żelipapą +# /config/locales/fr.yml @żelipapą + +/app/javascript/mastodon/locales/pl.json @m4sk1n +/app/views/user_mailer/*.pl.html.erb @m4sk1n +/app/views/user_mailer/*.pl.text.erb @m4sk1n +/config/locales/*.pl.yml @m4sk1n +/config/locales/pl.yml @m4sk1n diff --git a/Dockerfile b/Dockerfile index 398628a48..15138065b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM ruby:2.4.1-alpine +FROM ruby:2.4.1-alpine3.6 LABEL maintainer="https://github.com/tootsuite/mastodon" \ description="A GNU Social-compatible microblogging server" @@ -14,9 +14,7 @@ EXPOSE 3000 4000 WORKDIR /mastodon -RUN echo "@edge https://nl.alpinelinux.org/alpine/edge/main" >> /etc/apk/repositories \ - && echo "@edge https://nl.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories \ - && apk -U upgrade \ +RUN apk -U upgrade \ && apk add -t build-dependencies \ build-base \ icu-dev \ @@ -31,15 +29,15 @@ RUN echo "@edge https://nl.alpinelinux.org/alpine/edge/main" >> /etc/apk/reposit file \ git \ icu-libs \ - imagemagick@edge \ + imagemagick \ libidn \ libpq \ - nodejs-npm@edge \ - nodejs@edge \ + nodejs-npm \ + nodejs \ protobuf \ su-exec \ tini \ - yarn@edge \ + yarn \ && update-ca-certificates \ && wget -O libiconv.tar.gz "http://ftp.gnu.org/pub/gnu/libiconv/libiconv-$LIBICONV_VERSION.tar.gz" \ && echo "$LIBICONV_DOWNLOAD_SHA256 *libiconv.tar.gz" | sha256sum -c - \ diff --git a/Gemfile b/Gemfile index f4182bff5..486e72cc4 100644 --- a/Gemfile +++ b/Gemfile @@ -15,6 +15,7 @@ gem 'pghero', '~> 1.7' gem 'dotenv-rails', '~> 2.2' gem 'aws-sdk', '~> 2.9' +gem 'fog-openstack', '~> 0.1' gem 'paperclip', '~> 5.1' gem 'paperclip-av-transcoder', '~> 0.6' @@ -22,7 +23,7 @@ gem 'active_model_serializers', '~> 0.10' gem 'addressable', '~> 2.5' gem 'bootsnap' gem 'browser' -gem 'charlock_holmes', '~> 0.7.3' +gem 'charlock_holmes', '~> 0.7.5' gem 'cld3', '~> 3.1' gem 'devise', '~> 4.2' gem 'devise-two-factor', '~> 3.0' @@ -68,6 +69,9 @@ gem 'tzinfo-data', '~> 1.2017' gem 'webpacker', '~> 2.0' gem 'webpush' +gem 'json-ld-preloaded', '~> 2.2.1' +gem 'rdf-normalize', '~> 0.3.1' + group :development, :test do gem 'fabrication', '~> 2.16' gem 'fuubar', '~> 2.2' diff --git a/Gemfile.lock b/Gemfile.lock index 7a4dbab85..ef99e0d7b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -44,8 +44,8 @@ GEM i18n (~> 0.7) minitest (~> 5.1) tzinfo (~> 1.1) - addressable (2.5.1) - public_suffix (~> 2.0, >= 2.0.2) + addressable (2.5.2) + public_suffix (>= 2.0.2, < 4.0) airbrussh (1.3.0) sshkit (>= 1.6.1, != 1.7.0) annotate (2.7.2) @@ -74,13 +74,13 @@ GEM debug_inspector (>= 0.0.1) bootsnap (1.1.2) msgpack (~> 1.0) - brakeman (3.6.2) + brakeman (3.7.2) browser (2.4.0) builder (3.2.3) bullet (5.5.1) activesupport (>= 3.0.0) uniform_notifier (~> 1.10.0) - bundler-audit (0.5.0) + bundler-audit (0.6.0) bundler (~> 1.2) thor (~> 0.18) capistrano (3.8.2) @@ -108,7 +108,7 @@ GEM xpath (~> 2.0) case_transform (0.2) activesupport - charlock_holmes (0.7.3) + charlock_holmes (0.7.5) chunky_png (1.3.8) cld3 (3.1.3) ffi (>= 1.1.0, < 1.10.0) @@ -154,12 +154,25 @@ GEM erubis (2.7.0) et-orbi (1.0.5) tzinfo + excon (0.58.0) execjs (2.7.0) fabrication (2.16.2) faker (1.7.3) i18n (~> 0.5) fast_blank (1.0.0) ffi (1.9.18) + fog-core (1.45.0) + builder + excon (~> 0.58) + formatador (~> 0.2) + fog-json (1.0.2) + fog-core (~> 1.0) + multi_json (~> 1.10) + fog-openstack (0.1.21) + fog-core (>= 1.40) + fog-json (>= 1.0) + ipaddress (>= 0.8) + formatador (0.2.5) fuubar (2.2.0) rspec-core (~> 3.0) ruby-progressbar (~> 1.4) @@ -179,6 +192,8 @@ GEM activesupport (>= 4.0.1) hamlit (>= 1.2.0) railties (>= 4.0.1) + hamster (3.0.0) + concurrent-ruby (~> 1.0) hashdiff (0.3.5) highline (1.7.8) hiredis (0.6.1) @@ -209,8 +224,16 @@ GEM rainbow (~> 2.2) terminal-table (>= 1.5.1) idn-ruby (0.1.0) + ipaddress (0.8.3) jmespath (1.3.1) json (2.1.0) + json-ld (2.1.5) + multi_json (~> 1.12) + rdf (~> 2.2) + json-ld-preloaded (2.2.1) + json-ld (~> 2.1, >= 2.1.5) + multi_json (~> 1.11) + rdf (~> 2.2) jsonapi-renderer (0.1.3) jwt (1.5.6) kaminari (1.0.1) @@ -298,7 +321,7 @@ GEM slop (~> 3.4) pry-rails (0.3.6) pry (>= 0.10.4) - public_suffix (2.0.5) + public_suffix (3.0.0) puma (3.9.1) pundit (1.1.0) activesupport (>= 3.0.0) @@ -348,6 +371,11 @@ GEM rainbow (2.2.2) rake rake (12.0.0) + rdf (2.2.8) + hamster (~> 3.0) + link_header (~> 0.0, >= 0.0.8) + rdf-normalize (0.3.2) + rdf (~> 2.0) redis (3.3.3) redis-actionpack (5.0.1) actionpack (>= 4.0, < 6) @@ -454,7 +482,7 @@ GEM temple (0.8.0) terminal-table (1.8.0) unicode-display_width (~> 1.1, >= 1.1.1) - thor (0.19.4) + thor (0.20.0) thread (0.2.2) thread_safe (0.3.6) tilt (2.0.8) @@ -511,7 +539,7 @@ DEPENDENCIES capistrano-rbenv (~> 2.1) capistrano-yarn (~> 2.0) capybara (~> 2.14) - charlock_holmes (~> 0.7.3) + charlock_holmes (~> 0.7.5) cld3 (~> 3.1) climate_control (~> 0.2) devise (~> 4.2) @@ -521,6 +549,7 @@ DEPENDENCIES fabrication (~> 2.16) faker (~> 1.7) fast_blank (~> 1.0) + fog-openstack (~> 0.1) fuubar (~> 2.2) goldfinger (~> 2.0) hamlit-rails (~> 0.2) @@ -531,6 +560,7 @@ DEPENDENCIES httplog (~> 0.99) i18n-tasks (~> 0.9) idn-ruby + json-ld-preloaded (~> 2.2.1) kaminari (~> 1.0) letter_opener (~> 1.4) letter_opener_web (~> 1.3) @@ -560,6 +590,7 @@ DEPENDENCIES rails-controller-testing (~> 1.0) rails-i18n (~> 5.0) rails-settings-cached (~> 0.6) + rdf-normalize (~> 0.3.1) redis (~> 3.3) redis-namespace (~> 1.5) redis-rails (~> 5.0) @@ -590,4 +621,4 @@ RUBY VERSION ruby 2.4.1p111 BUNDLED WITH - 1.15.3 + 1.15.4 diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index c270eb000..26ab6636b 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -7,24 +7,78 @@ class AccountsController < ApplicationController def show respond_to do |format| format.html do - @statuses = @account.statuses.permitted_for(@account, current_account).paginate_by_max_id(20, params[:max_id], params[:since_id]) - @statuses = cache_collection(@statuses, Status) + @pinned_statuses = [] + + if current_account && @account.blocking?(current_account) + @statuses = [] + return + end + + @pinned_statuses = cache_collection(@account.pinned_statuses, Status) if show_pinned_statuses? + @statuses = filtered_statuses.paginate_by_max_id(20, params[:max_id], params[:since_id]) + @statuses = cache_collection(@statuses, Status) + @next_url = next_url unless @statuses.empty? end format.atom do @entries = @account.stream_entries.where(hidden: false).with_includes.paginate_by_max_id(20, params[:max_id], params[:since_id]) - render xml: OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.feed(@account, @entries.to_a)) + render xml: OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.feed(@account, @entries.reject { |entry| entry.status.nil? })) end format.json do - render json: @account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter + render json: @account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' end end end private + def show_pinned_statuses? + [replies_requested?, media_requested?, params[:max_id].present?, params[:since_id].present?].none? + end + + def filtered_statuses + default_statuses.tap do |statuses| + statuses.merge!(only_media_scope) if media_requested? + statuses.merge!(no_replies_scope) unless replies_requested? + end + end + + def default_statuses + @account.statuses.where(visibility: [:public, :unlisted]) + end + + def only_media_scope + Status.where(id: account_media_status_ids) + end + + def account_media_status_ids + @account.media_attachments.attached.reorder(nil).select(:status_id).distinct + end + + def no_replies_scope + Status.without_replies + end + def set_account @account = Account.find_local!(params[:username]) end + + def next_url + if media_requested? + short_account_media_url(@account, max_id: @statuses.last.id) + elsif replies_requested? + short_account_with_replies_url(@account, max_id: @statuses.last.id) + else + short_account_url(@account, max_id: @statuses.last.id) + end + end + + def media_requested? + request.path.ends_with?('/media') + end + + def replies_requested? + request.path.ends_with?('/with_replies') + end end diff --git a/app/controllers/activitypub/inboxes_controller.rb b/app/controllers/activitypub/inboxes_controller.rb new file mode 100644 index 000000000..b37910b36 --- /dev/null +++ b/app/controllers/activitypub/inboxes_controller.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class ActivityPub::InboxesController < Api::BaseController + include SignatureVerification + + before_action :set_account + + def create + if signed_request_account + upgrade_account + process_payload + head 201 + else + head 202 + end + end + + private + + def set_account + @account = Account.find_local!(params[:account_username]) if params[:account_username] + end + + def body + @body ||= request.body.read + end + + def upgrade_account + if signed_request_account.ostatus? + signed_request_account.update(last_webfingered_at: nil) + ResolveRemoteAccountWorker.perform_async(signed_request_account.acct) + end + + Pubsubhubbub::UnsubscribeWorker.perform_async(signed_request_account.id) if signed_request_account.subscribed? + end + + def process_payload + ActivityPub::ProcessingWorker.perform_async(signed_request_account.id, body.force_encoding('UTF-8')) + end +end diff --git a/app/controllers/activitypub/outboxes_controller.rb b/app/controllers/activitypub/outboxes_controller.rb index 30b91f370..9f97ff622 100644 --- a/app/controllers/activitypub/outboxes_controller.rb +++ b/app/controllers/activitypub/outboxes_controller.rb @@ -7,7 +7,7 @@ class ActivityPub::OutboxesController < Api::BaseController @statuses = @account.statuses.permitted_for(@account, current_account).paginate_by_max_id(20, params[:max_id], params[:since_id]) @statuses = cache_collection(@statuses, Status) - render json: outbox_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter + render json: outbox_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' end private diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb index 7bceee2cd..54c659e1b 100644 --- a/app/controllers/admin/accounts_controller.rb +++ b/app/controllers/admin/accounts_controller.rb @@ -17,7 +17,7 @@ module Admin end def unsubscribe - UnsubscribeService.new.call(@account) + Pubsubhubbub::UnsubscribeWorker.perform_async(@account.id) redirect_to admin_account_path(@account.id) end diff --git a/app/controllers/admin/statuses_controller.rb b/app/controllers/admin/statuses_controller.rb index 50712f0dd..b05000b16 100644 --- a/app/controllers/admin/statuses_controller.rb +++ b/app/controllers/admin/statuses_controller.rb @@ -9,7 +9,7 @@ module Admin before_action :set_account before_action :set_status, only: [:update, :destroy] - PAR_PAGE = 20 + PER_PAGE = 20 def index @statuses = @account.statuses @@ -17,7 +17,7 @@ module Admin account_media_status_ids = @account.media_attachments.attached.reorder(nil).select(:status_id).distinct @statuses.merge!(Status.where(id: account_media_status_ids)) end - @statuses = @statuses.preload(:media_attachments, :mentions).page(params[:page]).per(PAR_PAGE) + @statuses = @statuses.preload(:media_attachments, :mentions).page(params[:page]).per(PER_PAGE) @form = Form::StatusBatch.new end diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index 105a2859d..7cfe8fe71 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -43,7 +43,7 @@ class Api::BaseController < ApplicationController links = [] links << [next_path, [%w(rel next)]] if next_path links << [prev_path, [%w(rel prev)]] if prev_path - response.headers['Link'] = LinkHeader.new(links) + response.headers['Link'] = LinkHeader.new(links) unless links.empty? end def limit_param(default_limit) @@ -62,10 +62,11 @@ class Api::BaseController < ApplicationController end def require_user! - current_resource_owner - set_user_activity - rescue ActiveRecord::RecordNotFound - render json: { error: 'This method requires an authenticated user' }, status: 422 + if current_user + set_user_activity + else + render json: { error: 'This method requires an authenticated user' }, status: 422 + end end def render_empty diff --git a/app/controllers/api/oembed_controller.rb b/app/controllers/api/oembed_controller.rb index f8c87dd16..37a163cd3 100644 --- a/app/controllers/api/oembed_controller.rb +++ b/app/controllers/api/oembed_controller.rb @@ -4,14 +4,14 @@ class Api::OEmbedController < Api::BaseController respond_to :json def show - @stream_entry = find_stream_entry.stream_entry - render json: @stream_entry, serializer: OEmbedSerializer, width: maxwidth_or_default, height: maxheight_or_default + @status = status_finder.status + render json: @status, serializer: OEmbedSerializer, width: maxwidth_or_default, height: maxheight_or_default end private - def find_stream_entry - StreamEntryFinder.new(params[:url]) + def status_finder + StatusFinder.new(params[:url]) end def maxwidth_or_default diff --git a/app/controllers/api/v1/accounts/credentials_controller.rb b/app/controllers/api/v1/accounts/credentials_controller.rb index 073808532..da534d960 100644 --- a/app/controllers/api/v1/accounts/credentials_controller.rb +++ b/app/controllers/api/v1/accounts/credentials_controller.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class Api::V1::Accounts::CredentialsController < Api::BaseController + before_action -> { doorkeeper_authorize! :read }, except: [:update] before_action -> { doorkeeper_authorize! :write }, only: [:update] before_action :require_user! @@ -10,8 +11,9 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController end def update - current_account.update!(account_params) @account = current_account + UpdateAccountService.new.call(@account, account_params, raise_error: true) + ActivityPub::UpdateDistributionWorker.perform_async(@account.id) render json: @account, serializer: REST::CredentialAccountSerializer end diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb index d9ae5c089..095f6937b 100644 --- a/app/controllers/api/v1/accounts/statuses_controller.rb +++ b/app/controllers/api/v1/accounts/statuses_controller.rb @@ -29,6 +29,7 @@ class Api::V1::Accounts::StatusesController < Api::BaseController def account_statuses default_statuses.tap do |statuses| statuses.merge!(only_media_scope) if params[:only_media] + statuses.merge!(pinned_scope) if params[:pinned] statuses.merge!(no_replies_scope) if params[:exclude_replies] end end @@ -53,6 +54,10 @@ class Api::V1::Accounts::StatusesController < Api::BaseController @account.media_attachments.attached.reorder(nil).select(:status_id).distinct end + def pinned_scope + @account.pinned_statuses + end + def no_replies_scope Status.without_replies end diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index f621aa245..656cacd8a 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -14,6 +14,16 @@ class Api::V1::AccountsController < Api::BaseController def follow FollowService.new.call(current_user.account, @account.acct) + + unless @account.locked? + relationships = AccountRelationshipsPresenter.new( + [@account.id], + current_user.account_id, + following_map: { @account.id => true }, + requested_map: { @account.id => false } + ) + end + render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships end diff --git a/app/controllers/api/v1/statuses/pins_controller.rb b/app/controllers/api/v1/statuses/pins_controller.rb new file mode 100644 index 000000000..3de1009b8 --- /dev/null +++ b/app/controllers/api/v1/statuses/pins_controller.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class Api::V1::Statuses::PinsController < Api::BaseController + include Authorization + + before_action -> { doorkeeper_authorize! :write } + before_action :require_user! + before_action :set_status + + respond_to :json + + def create + StatusPin.create!(account: current_account, status: @status) + render json: @status, serializer: REST::StatusSerializer + end + + def destroy + pin = StatusPin.find_by(account: current_account, status: @status) + pin&.destroy! + render json: @status, serializer: REST::StatusSerializer + end + + private + + def set_status + @status = Status.find(params[:status_id]) + end +end diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 9c7124d0f..544a4ce21 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -29,7 +29,7 @@ class Api::V1::StatusesController < Api::BaseController end def card - @card = PreviewCard.find_by(status: @status) + @card = @status.preview_cards.first if @card.nil? render_empty diff --git a/app/controllers/api/web/embeds_controller.rb b/app/controllers/api/web/embeds_controller.rb new file mode 100644 index 000000000..2ed516161 --- /dev/null +++ b/app/controllers/api/web/embeds_controller.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class Api::Web::EmbedsController < Api::BaseController + respond_to :json + + before_action :require_user! + + def create + status = StatusFinder.new(params[:url]).status + render json: status, serializer: OEmbedSerializer, width: 400 + rescue ActiveRecord::RecordNotFound + oembed = OEmbed::Providers.get(params[:url]) + render json: Oj.dump(oembed.fields) + rescue OEmbed::NotFound + render json: {}, status: :not_found + end +end diff --git a/app/controllers/concerns/account_controller_concern.rb b/app/controllers/concerns/account_controller_concern.rb index d36fc8c93..5b9981aa2 100644 --- a/app/controllers/concerns/account_controller_concern.rb +++ b/app/controllers/concerns/account_controller_concern.rb @@ -23,6 +23,7 @@ module AccountControllerConcern [ webfinger_account_link, atom_account_url_link, + actor_url_link, ] ) end @@ -41,6 +42,13 @@ module AccountControllerConcern ] end + def actor_url_link + [ + ActivityPub::TagManager.instance.uri_for(@account), + [%w(rel alternate), %w(type application/activity+json)], + ] + end + def webfinger_account_url webfinger_url(resource: @account.to_webfinger_s) end diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb index abe845d93..4211283ed 100644 --- a/app/controllers/concerns/signature_verification.rb +++ b/app/controllers/concerns/signature_verification.rb @@ -31,7 +31,7 @@ module SignatureVerification return end - account = ResolveRemoteAccountService.new.call(signature_params['keyId'].gsub(/\Aacct:/, '')) + account = account_from_key_id(signature_params['keyId']) if account.nil? @signed_request_account = nil @@ -49,6 +49,10 @@ module SignatureVerification end end + def request_body + @request_body ||= request.raw_post + end + private def build_signed_string(signed_headers) @@ -57,6 +61,8 @@ module SignatureVerification signed_headers.split(' ').map do |signed_header| if signed_header == Request::REQUEST_TARGET "#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}" + elsif signed_header == 'digest' + "digest: #{body_digest}" else "#{signed_header}: #{request.headers[to_header_name(signed_header)]}" end @@ -73,6 +79,10 @@ module SignatureVerification (Time.now.utc - time_sent).abs <= 30 end + def body_digest + "SHA-256=#{Digest::SHA256.base64digest(request_body)}" + end + def to_header_name(name) name.split(/-/).map(&:capitalize).join('-') end @@ -81,7 +91,16 @@ module SignatureVerification signature_params['keyId'].blank? || signature_params['signature'].blank? || signature_params['algorithm'].blank? || - signature_params['algorithm'] != 'rsa-sha256' || - !signature_params['keyId'].start_with?('acct:') + signature_params['algorithm'] != 'rsa-sha256' + end + + def account_from_key_id(key_id) + if key_id.start_with?('acct:') + ResolveRemoteAccountService.new.call(key_id.gsub(/\Aacct:/, '')) + elsif !ActivityPub::TagManager.instance.local_uri?(key_id) + account = ActivityPub::TagManager.instance.uri_to_resource(key_id, Account) + account ||= ActivityPub::FetchRemoteKeyService.new.call(key_id) + account + end end end diff --git a/app/controllers/follower_accounts_controller.rb b/app/controllers/follower_accounts_controller.rb index 5edb4d67c..0e1949897 100644 --- a/app/controllers/follower_accounts_controller.rb +++ b/app/controllers/follower_accounts_controller.rb @@ -10,7 +10,7 @@ class FollowerAccountsController < ApplicationController format.html format.json do - render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter + render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' end end end diff --git a/app/controllers/following_accounts_controller.rb b/app/controllers/following_accounts_controller.rb index 7cafe5fda..d4593093f 100644 --- a/app/controllers/following_accounts_controller.rb +++ b/app/controllers/following_accounts_controller.rb @@ -10,7 +10,7 @@ class FollowingAccountsController < ApplicationController format.html format.json do - render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter + render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' end end end diff --git a/app/controllers/intents_controller.rb b/app/controllers/intents_controller.rb new file mode 100644 index 000000000..504befd1f --- /dev/null +++ b/app/controllers/intents_controller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class IntentsController < ApplicationController + def show + uri = Addressable::URI.parse(params[:uri]) + + if uri.scheme == 'web+mastodon' + case uri.host + when 'follow' + return redirect_to authorize_follow_path(acct: uri.query_values['uri'].gsub(/\Aacct:/, '')) + when 'share' + return redirect_to share_path(text: uri.query_values['text']) + end + end + + not_found + end +end diff --git a/app/controllers/settings/applications_controller.rb b/app/controllers/settings/applications_controller.rb new file mode 100644 index 000000000..8fc9a0fa9 --- /dev/null +++ b/app/controllers/settings/applications_controller.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +class Settings::ApplicationsController < ApplicationController + layout 'admin' + + before_action :authenticate_user! + before_action :set_application, only: [:show, :update, :destroy, :regenerate] + before_action :prepare_scopes, only: [:create, :update] + + def index + @applications = current_user.applications.page(params[:page]) + end + + def new + @application = Doorkeeper::Application.new( + redirect_uri: Doorkeeper.configuration.native_redirect_uri, + scopes: 'read write follow' + ) + end + + def show; end + + def create + @application = current_user.applications.build(application_params) + + if @application.save + redirect_to settings_applications_path, notice: I18n.t('applications.created') + else + render :new + end + end + + def update + if @application.update(application_params) + redirect_to settings_applications_path, notice: I18n.t('generic.changes_saved_msg') + else + render :show + end + end + + def destroy + @application.destroy + redirect_to settings_applications_path, notice: I18n.t('applications.destroyed') + end + + def regenerate + @access_token = current_user.token_for_app(@application) + @access_token.destroy + + redirect_to settings_application_path(@application), notice: I18n.t('applications.token_regenerated') + end + + private + + def set_application + @application = current_user.applications.find(params[:id]) + end + + def application_params + params.require(:doorkeeper_application).permit( + :name, + :redirect_uri, + :scopes, + :website + ) + end + + def prepare_scopes + scopes = params.fetch(:doorkeeper_application, {}).fetch(:scopes, nil) + params[:doorkeeper_application][:scopes] = scopes.join(' ') if scopes.is_a? Array + end +end diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb index 0367e3593..28f78a4fb 100644 --- a/app/controllers/settings/profiles_controller.rb +++ b/app/controllers/settings/profiles_controller.rb @@ -14,7 +14,8 @@ class Settings::ProfilesController < ApplicationController def show; end def update - if @account.update(account_params) + if UpdateAccountService.new.call(@account, account_params) + ActivityPub::UpdateDistributionWorker.perform_async(@account.id) redirect_to settings_profile_path, notice: I18n.t('generic.changes_saved_msg') else render :show diff --git a/app/controllers/shares_controller.rb b/app/controllers/shares_controller.rb new file mode 100644 index 000000000..994742c3d --- /dev/null +++ b/app/controllers/shares_controller.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class SharesController < ApplicationController + layout 'modal' + + before_action :authenticate_user! + before_action :set_body_classes + + def show + serializable_resource = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(initial_state_params), serializer: InitialStateSerializer) + @initial_state_json = serializable_resource.to_json + end + + private + + def initial_state_params + { + settings: Web::Setting.find_by(user: current_user)&.data || {}, + push_subscription: current_account.user.web_push_subscription(current_session), + current_account: current_account, + token: current_session.token, + admin: Account.find_local(Setting.site_contact_username), + text: params[:text], + } + end + + def set_body_classes + @body_classes = 'compose-standalone' + end +end diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index 8e0ce0ec3..65206ea96 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -9,6 +9,7 @@ class StatusesController < ApplicationController before_action :set_status before_action :set_link_headers before_action :check_account_suspension + before_action :redirect_to_original, only: [:show] def show respond_to do |format| @@ -20,13 +21,18 @@ class StatusesController < ApplicationController end format.json do - render json: @status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter + render json: @status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' end end end def activity - render json: @status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter + render json: @status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' + end + + def embed + response.headers['X-Frame-Options'] = 'ALLOWALL' + render 'stream_entries/embed', layout: 'embedded' end private @@ -36,7 +42,12 @@ class StatusesController < ApplicationController end def set_link_headers - response.headers['Link'] = LinkHeader.new([[account_stream_entry_url(@account, @status.stream_entry, format: 'atom'), [%w(rel alternate), %w(type application/atom+xml)]]]) + response.headers['Link'] = LinkHeader.new( + [ + [account_stream_entry_url(@account, @status.stream_entry, format: 'atom'), [%w(rel alternate), %w(type application/atom+xml)]], + [ActivityPub::TagManager.instance.uri_for(@status), [%w(rel alternate), %w(type application/activity+json)]], + ] + ) end def set_status @@ -53,4 +64,8 @@ class StatusesController < ApplicationController def check_account_suspension gone if @account.suspended? end + + def redirect_to_original + redirect_to ::TagManager.instance.url_for(@status.reblog) if @status.reblog? + end end diff --git a/app/controllers/stream_entries_controller.rb b/app/controllers/stream_entries_controller.rb index 3eb91d830..cc579dbc8 100644 --- a/app/controllers/stream_entries_controller.rb +++ b/app/controllers/stream_entries_controller.rb @@ -25,10 +25,7 @@ class StreamEntriesController < ApplicationController end def embed - response.headers['X-Frame-Options'] = 'ALLOWALL' - return gone if @stream_entry.activity.nil? - - render layout: 'embedded' + redirect_to embed_short_account_status_url(@account, @stream_entry.activity), status: 301 end private @@ -38,7 +35,12 @@ class StreamEntriesController < ApplicationController end def set_link_headers - response.headers['Link'] = LinkHeader.new([[account_stream_entry_url(@account, @stream_entry, format: 'atom'), [%w(rel alternate), %w(type application/atom+xml)]]]) + response.headers['Link'] = LinkHeader.new( + [ + [account_stream_entry_url(@account, @stream_entry, format: 'atom'), [%w(rel alternate), %w(type application/atom+xml)]], + [ActivityPub::TagManager.instance.uri_for(@stream_entry.activity), [%w(rel alternate), %w(type application/activity+json)]], + ] + ) end def set_stream_entry diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 2cd85e185..3001b2ee3 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -12,7 +12,7 @@ class TagsController < ApplicationController format.html format.json do - render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter + render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' end end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 9f50d8bdb..61d4442c1 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -5,6 +5,10 @@ module ApplicationHelper current_page?(path) ? 'active' : '' end + def active_link_to(label, path, options = {}) + link_to label, path, options.merge(class: active_nav_class(path)) + end + def show_landing_strip? !user_signed_in? && !single_user_mode? end diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb new file mode 100644 index 000000000..d82a07332 --- /dev/null +++ b/app/helpers/jsonld_helper.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module JsonLdHelper + def equals_or_includes?(haystack, needle) + haystack.is_a?(Array) ? haystack.include?(needle) : haystack == needle + end + + def first_of_value(value) + value.is_a?(Array) ? value.first : value + end + + def value_or_id(value) + value.is_a?(String) || value.nil? ? value : value['id'] + end + + def supported_context?(json) + !json.nil? && equals_or_includes?(json['@context'], ActivityPub::TagManager::CONTEXT) + end + + def canonicalize(json) + graph = RDF::Graph.new << JSON::LD::API.toRdf(json) + graph.dump(:normalize) + end + + def fetch_resource(uri) + response = build_request(uri).perform + return if response.code != 200 + body_to_json(response.to_s) + end + + def body_to_json(body) + body.is_a?(String) ? Oj.load(body, mode: :strict) : body + rescue Oj::ParseError + nil + end + + def merge_context(context, new_context) + if context.is_a?(Array) + context << new_context + else + [context, new_context] + end + end + + private + + def build_request(uri) + request = Request.new(:get, uri) + request.add_headers('Accept' => 'application/activity+json, application/ld+json') + request + end +end diff --git a/app/helpers/routing_helper.rb b/app/helpers/routing_helper.rb index 8126176ba..f4693358c 100644 --- a/app/helpers/routing_helper.rb +++ b/app/helpers/routing_helper.rb @@ -12,6 +12,14 @@ module RoutingHelper end def full_asset_url(source, options = {}) - Rails.configuration.x.use_s3 ? source : URI.join(root_url, ActionController::Base.helpers.asset_url(source, options)).to_s + source = ActionController::Base.helpers.asset_url(source, options) unless use_storage? + + URI.join(root_url, source).to_s + end + + private + + def use_storage? + Rails.configuration.x.use_s3 || Rails.configuration.x.use_swift end end diff --git a/app/helpers/stream_entries_helper.rb b/app/helpers/stream_entries_helper.rb index 4ef7cffb0..445114985 100644 --- a/app/helpers/stream_entries_helper.rb +++ b/app/helpers/stream_entries_helper.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module StreamEntriesHelper - EMBEDDED_CONTROLLER = 'stream_entries' + EMBEDDED_CONTROLLER = 'statuses' EMBEDDED_ACTION = 'embed' def display_name(account) diff --git a/app/javascript/mastodon/actions/interactions.js b/app/javascript/mastodon/actions/interactions.js index 36eec4934..7b5f4bd9c 100644 --- a/app/javascript/mastodon/actions/interactions.js +++ b/app/javascript/mastodon/actions/interactions.js @@ -24,6 +24,14 @@ export const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST'; export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS'; export const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL'; +export const PIN_REQUEST = 'PIN_REQUEST'; +export const PIN_SUCCESS = 'PIN_SUCCESS'; +export const PIN_FAIL = 'PIN_FAIL'; + +export const UNPIN_REQUEST = 'UNPIN_REQUEST'; +export const UNPIN_SUCCESS = 'UNPIN_SUCCESS'; +export const UNPIN_FAIL = 'UNPIN_FAIL'; + export function reblog(status) { return function (dispatch, getState) { dispatch(reblogRequest(status)); @@ -233,3 +241,73 @@ export function fetchFavouritesFail(id, error) { error, }; }; + +export function pin(status) { + return (dispatch, getState) => { + dispatch(pinRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/pin`).then(response => { + dispatch(pinSuccess(status, response.data)); + }).catch(error => { + dispatch(pinFail(status, error)); + }); + }; +}; + +export function pinRequest(status) { + return { + type: PIN_REQUEST, + status, + }; +}; + +export function pinSuccess(status, response) { + return { + type: PIN_SUCCESS, + status, + response, + }; +}; + +export function pinFail(status, error) { + return { + type: PIN_FAIL, + status, + error, + }; +}; + +export function unpin (status) { + return (dispatch, getState) => { + dispatch(unpinRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/unpin`).then(response => { + dispatch(unpinSuccess(status, response.data)); + }).catch(error => { + dispatch(unpinFail(status, error)); + }); + }; +}; + +export function unpinRequest(status) { + return { + type: UNPIN_REQUEST, + status, + }; +}; + +export function unpinSuccess(status, response) { + return { + type: UNPIN_SUCCESS, + status, + response, + }; +}; + +export function unpinFail(status, error) { + return { + type: UNPIN_FAIL, + status, + error, + }; +}; diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js new file mode 100644 index 000000000..7802694a3 --- /dev/null +++ b/app/javascript/mastodon/actions/streaming.js @@ -0,0 +1,94 @@ +import createStream from '../stream'; +import { + updateTimeline, + deleteFromTimelines, + refreshHomeTimeline, + connectTimeline, + disconnectTimeline, +} from './timelines'; +import { updateNotifications, refreshNotifications } from './notifications'; +import { getLocale } from '../locales'; + +const { messages } = getLocale(); + +export function connectTimelineStream (timelineId, path, pollingRefresh = null) { + return (dispatch, getState) => { + const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']); + const accessToken = getState().getIn(['meta', 'access_token']); + const locale = getState().getIn(['meta', 'locale']); + let polling = null; + + const setupPolling = () => { + polling = setInterval(() => { + pollingRefresh(dispatch); + }, 20000); + }; + + const clearPolling = () => { + if (polling) { + clearInterval(polling); + polling = null; + } + }; + + const subscription = createStream(streamingAPIBaseURL, accessToken, path, { + + connected () { + if (pollingRefresh) { + clearPolling(); + } + dispatch(connectTimeline(timelineId)); + }, + + disconnected () { + if (pollingRefresh) { + setupPolling(); + } + dispatch(disconnectTimeline(timelineId)); + }, + + received (data) { + switch(data.event) { + case 'update': + dispatch(updateTimeline(timelineId, JSON.parse(data.payload))); + break; + case 'delete': + dispatch(deleteFromTimelines(data.payload)); + break; + case 'notification': + dispatch(updateNotifications(JSON.parse(data.payload), messages, locale)); + break; + } + }, + + reconnected () { + if (pollingRefresh) { + clearPolling(); + pollingRefresh(dispatch); + } + dispatch(connectTimeline(timelineId)); + }, + + }); + + const disconnect = () => { + if (subscription) { + subscription.close(); + } + clearPolling(); + }; + + return disconnect; + }; +} + +function refreshHomeTimelineAndNotification (dispatch) { + dispatch(refreshHomeTimeline()); + dispatch(refreshNotifications()); +} + +export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification); +export const connectCommunityStream = () => connectTimelineStream('community', 'public:local'); +export const connectMediaStream = () => connectTimelineStream('community', 'public:local'); +export const connectPublicStream = () => connectTimelineStream('public', 'public'); +export const connectHashtagStream = (tag) => connectTimelineStream(`hashtag:${tag}`, `hashtag&tag=${tag}`); diff --git a/app/javascript/mastodon/components/account.js b/app/javascript/mastodon/components/account.js index 69cc63d10..6456c12ba 100644 --- a/app/javascript/mastodon/components/account.js +++ b/app/javascript/mastodon/components/account.js @@ -26,6 +26,7 @@ export default class Account extends ImmutablePureComponent { onBlock: PropTypes.func.isRequired, onMute: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, + hidden: PropTypes.bool, }; handleFollow = () => { @@ -41,12 +42,21 @@ export default class Account extends ImmutablePureComponent { } render () { - const { account, me, intl } = this.props; + const { account, me, intl, hidden } = this.props; if (!account) { return <div />; } + if (hidden) { + return ( + <div> + {account.get('display_name')} + {account.get('username')} + </div> + ); + } + let buttons; if (account.get('id') !== me && account.get('relationship', null) !== null) { diff --git a/app/javascript/mastodon/components/column.js b/app/javascript/mastodon/components/column.js index 103fcd495..168f2da9c 100644 --- a/app/javascript/mastodon/components/column.js +++ b/app/javascript/mastodon/components/column.js @@ -33,7 +33,7 @@ export default class Column extends React.PureComponent { } componentDidMount () { - this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents ? { passive: true } : false); + this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false); } componentWillUnmount () { diff --git a/app/javascript/mastodon/components/intersection_observer_article.js b/app/javascript/mastodon/components/intersection_observer_article.js new file mode 100644 index 000000000..347767818 --- /dev/null +++ b/app/javascript/mastodon/components/intersection_observer_article.js @@ -0,0 +1,122 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import scheduleIdleTask from '../features/ui/util/schedule_idle_task'; +import getRectFromEntry from '../features/ui/util/get_rect_from_entry'; + +export default class IntersectionObserverArticle extends ImmutablePureComponent { + + static propTypes = { + intersectionObserverWrapper: PropTypes.object, + id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + index: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + listLength: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + children: PropTypes.node, + }; + + state = { + isHidden: false, // set to true in requestIdleCallback to trigger un-render + } + + shouldComponentUpdate (nextProps, nextState) { + if (!nextState.isIntersecting && nextState.isHidden) { + // It's only if we're not intersecting (i.e. offscreen) and isHidden is true + // that either "isIntersecting" or "isHidden" matter, and then they're + // the only things that matter (and updated ARIA attributes). + return this.state.isIntersecting || !this.state.isHidden || nextProps.listLength !== this.props.listLength; + } else if (nextState.isIntersecting && !this.state.isIntersecting) { + // If we're going from a non-intersecting state to an intersecting state, + // (i.e. offscreen to onscreen), then we definitely need to re-render + return true; + } + // Otherwise, diff based on "updateOnProps" and "updateOnStates" + return super.shouldComponentUpdate(nextProps, nextState); + } + + componentDidMount () { + if (!this.props.intersectionObserverWrapper) { + // TODO: enable IntersectionObserver optimization for notification statuses. + // These are managed in notifications/index.js rather than status_list.js + return; + } + this.props.intersectionObserverWrapper.observe( + this.props.id, + this.node, + this.handleIntersection + ); + + this.componentMounted = true; + } + + componentWillUnmount () { + if (this.props.intersectionObserverWrapper) { + this.props.intersectionObserverWrapper.unobserve(this.props.id, this.node); + } + + this.componentMounted = false; + } + + handleIntersection = (entry) => { + if (this.node && this.node.children.length !== 0) { + // save the height of the fully-rendered element + this.height = getRectFromEntry(entry).height; + + if (this.props.onHeightChange) { + this.props.onHeightChange(this.props.status, this.height); + } + } + + this.setState((prevState) => { + if (prevState.isIntersecting && !entry.isIntersecting) { + scheduleIdleTask(this.hideIfNotIntersecting); + } + return { + isIntersecting: entry.isIntersecting, + isHidden: false, + }; + }); + } + + hideIfNotIntersecting = () => { + if (!this.componentMounted) { + return; + } + + // When the browser gets a chance, test if we're still not intersecting, + // and if so, set our isHidden to true to trigger an unrender. The point of + // this is to save DOM nodes and avoid using up too much memory. + // See: https://github.com/tootsuite/mastodon/issues/2900 + this.setState((prevState) => ({ isHidden: !prevState.isIntersecting })); + } + + handleRef = (node) => { + this.node = node; + } + + render () { + const { children, id, index, listLength } = this.props; + const { isIntersecting, isHidden } = this.state; + + if (!isIntersecting && isHidden) { + return ( + <article + ref={this.handleRef} + aria-posinset={index} + aria-setsize={listLength} + style={{ height: `${this.height}px`, opacity: 0, overflow: 'hidden' }} + data-id={id} + tabIndex='0' + > + {children && React.cloneElement(children, { hidden: true })} + </article> + ); + } + + return ( + <article ref={this.handleRef} aria-posinset={index} aria-setsize={listLength} data-id={id} tabIndex='0'> + {children && React.cloneElement(children, { hidden: false })} + </article> + ); + } + +} diff --git a/app/javascript/mastodon/components/scrollable_list.js b/app/javascript/mastodon/components/scrollable_list.js new file mode 100644 index 000000000..e47b1e9aa --- /dev/null +++ b/app/javascript/mastodon/components/scrollable_list.js @@ -0,0 +1,185 @@ +import React, { PureComponent } from 'react'; +import { ScrollContainer } from 'react-router-scroll'; +import PropTypes from 'prop-types'; +import IntersectionObserverArticle from './intersection_observer_article'; +import LoadMore from './load_more'; +import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper'; +import { throttle } from 'lodash'; +import { List as ImmutableList } from 'immutable'; + +export default class ScrollableList extends PureComponent { + + static propTypes = { + scrollKey: PropTypes.string.isRequired, + onScrollToBottom: PropTypes.func, + onScrollToTop: PropTypes.func, + onScroll: PropTypes.func, + trackScroll: PropTypes.bool, + shouldUpdateScroll: PropTypes.func, + isLoading: PropTypes.bool, + hasMore: PropTypes.bool, + prepend: PropTypes.node, + emptyMessage: PropTypes.node, + children: PropTypes.node, + }; + + static defaultProps = { + trackScroll: true, + }; + + intersectionObserverWrapper = new IntersectionObserverWrapper(); + + handleScroll = throttle(() => { + if (this.node) { + const { scrollTop, scrollHeight, clientHeight } = this.node; + const offset = scrollHeight - scrollTop - clientHeight; + this._oldScrollPosition = scrollHeight - scrollTop; + + if (400 > offset && this.props.onScrollToBottom && !this.props.isLoading) { + this.props.onScrollToBottom(); + } else if (scrollTop < 100 && this.props.onScrollToTop) { + this.props.onScrollToTop(); + } else if (this.props.onScroll) { + this.props.onScroll(); + } + } + }, 150, { + trailing: true, + }); + + componentDidMount () { + this.attachScrollListener(); + this.attachIntersectionObserver(); + + // Handle initial scroll posiiton + this.handleScroll(); + } + + componentDidUpdate (prevProps) { + // Reset the scroll position when a new child comes in in order not to + // jerk the scrollbar around if you're already scrolled down the page. + if (React.Children.count(prevProps.children) < React.Children.count(this.props.children) && this._oldScrollPosition && this.node.scrollTop > 0) { + if (this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props)) { + const newScrollTop = this.node.scrollHeight - this._oldScrollPosition; + if (this.node.scrollTop !== newScrollTop) { + this.node.scrollTop = newScrollTop; + } + } else { + this._oldScrollPosition = this.node.scrollHeight - this.node.scrollTop; + } + } + } + + componentWillUnmount () { + this.detachScrollListener(); + this.detachIntersectionObserver(); + } + + attachIntersectionObserver () { + this.intersectionObserverWrapper.connect({ + root: this.node, + rootMargin: '300% 0px', + }); + } + + detachIntersectionObserver () { + this.intersectionObserverWrapper.disconnect(); + } + + attachScrollListener () { + this.node.addEventListener('scroll', this.handleScroll); + } + + detachScrollListener () { + this.node.removeEventListener('scroll', this.handleScroll); + } + + getFirstChildKey (props) { + const { children } = props; + let firstChild = children; + if (children instanceof ImmutableList) { + firstChild = children.get(0); + } else if (Array.isArray(children)) { + firstChild = children[0]; + } + return firstChild && firstChild.key; + } + + setRef = (c) => { + this.node = c; + } + + handleLoadMore = (e) => { + e.preventDefault(); + this.props.onScrollToBottom(); + } + + handleKeyDown = (e) => { + if (['PageDown', 'PageUp'].includes(e.key) || (e.ctrlKey && ['End', 'Home'].includes(e.key))) { + const article = (() => { + switch (e.key) { + case 'PageDown': + return e.target.nodeName === 'ARTICLE' && e.target.nextElementSibling; + case 'PageUp': + return e.target.nodeName === 'ARTICLE' && e.target.previousElementSibling; + case 'End': + return this.node.querySelector('[role="feed"] > article:last-of-type'); + case 'Home': + return this.node.querySelector('[role="feed"] > article:first-of-type'); + default: + return null; + } + })(); + + + if (article) { + e.preventDefault(); + article.focus(); + article.scrollIntoView(); + } + } + } + + render () { + const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props; + const childrenCount = React.Children.count(children); + + const loadMore = <LoadMore visible={!isLoading && childrenCount > 0 && hasMore} onClick={this.handleLoadMore} />; + let scrollableArea = null; + + if (isLoading || childrenCount > 0 || !emptyMessage) { + scrollableArea = ( + <div className='scrollable' ref={this.setRef}> + <div role='feed' className='item-list' onKeyDown={this.handleKeyDown}> + {prepend} + + {React.Children.map(this.props.children, (child, index) => ( + <IntersectionObserverArticle key={child.key} id={child.key} index={index} listLength={childrenCount} intersectionObserverWrapper={this.intersectionObserverWrapper}> + {child} + </IntersectionObserverArticle> + ))} + + {loadMore} + </div> + </div> + ); + } else { + scrollableArea = ( + <div className='empty-column-indicator' ref={this.setRef}> + {emptyMessage} + </div> + ); + } + + if (trackScroll) { + return ( + <ScrollContainer scrollKey={scrollKey} shouldUpdateScroll={shouldUpdateScroll}> + {scrollableArea} + </ScrollContainer> + ); + } else { + return scrollableArea; + } + } + +} diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index 7468957d3..b8617018d 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -12,13 +12,11 @@ import StatusContent from './status_content'; import StatusActionBar from './status_action_bar'; import { FormattedMessage } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import scheduleIdleTask from '../features/ui/util/schedule_idle_task'; import { MediaGallery, VideoPlayer } from '../features/ui/util/async-components'; // We use the component (and not the container) since we do not want // to use the progress bar to show download progress import Bundle from '../features/ui/components/bundle'; -import getRectFromEntry from '../features/ui/util/get_rect_from_entry'; export default class Status extends ImmutablePureComponent { @@ -29,27 +27,25 @@ export default class Status extends ImmutablePureComponent { static propTypes = { status: ImmutablePropTypes.map, account: ImmutablePropTypes.map, - wrapped: PropTypes.bool, onReply: PropTypes.func, onFavourite: PropTypes.func, onReblog: PropTypes.func, onDelete: PropTypes.func, + onPin: PropTypes.func, onOpenMedia: PropTypes.func, onOpenVideo: PropTypes.func, onBlock: PropTypes.func, + onEmbed: PropTypes.func, onHeightChange: PropTypes.func, me: PropTypes.number, boostModal: PropTypes.bool, autoPlayGif: PropTypes.bool, muted: PropTypes.bool, - intersectionObserverWrapper: PropTypes.object, - index: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - listLength: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + hidden: PropTypes.bool, }; state = { isExpanded: false, - isHidden: false, // set to true in requestIdleCallback to trigger un-render } // Avoid checking props that are functions (and whose equality will always @@ -57,91 +53,15 @@ export default class Status extends ImmutablePureComponent { updateOnProps = [ 'status', 'account', - 'wrapped', 'me', 'boostModal', 'autoPlayGif', 'muted', - 'listLength', + 'hidden', ] updateOnStates = ['isExpanded'] - shouldComponentUpdate (nextProps, nextState) { - if (!nextState.isIntersecting && nextState.isHidden) { - // It's only if we're not intersecting (i.e. offscreen) and isHidden is true - // that either "isIntersecting" or "isHidden" matter, and then they're - // the only things that matter (and updated ARIA attributes). - return this.state.isIntersecting || !this.state.isHidden || nextProps.listLength !== this.props.listLength; - } else if (nextState.isIntersecting && !this.state.isIntersecting) { - // If we're going from a non-intersecting state to an intersecting state, - // (i.e. offscreen to onscreen), then we definitely need to re-render - return true; - } - // Otherwise, diff based on "updateOnProps" and "updateOnStates" - return super.shouldComponentUpdate(nextProps, nextState); - } - - componentDidMount () { - if (!this.props.intersectionObserverWrapper) { - // TODO: enable IntersectionObserver optimization for notification statuses. - // These are managed in notifications/index.js rather than status_list.js - return; - } - this.props.intersectionObserverWrapper.observe( - this.props.id, - this.node, - this.handleIntersection - ); - - this.componentMounted = true; - } - - componentWillUnmount () { - if (this.props.intersectionObserverWrapper) { - this.props.intersectionObserverWrapper.unobserve(this.props.id, this.node); - } - - this.componentMounted = false; - } - - handleIntersection = (entry) => { - if (this.node && this.node.children.length !== 0) { - // save the height of the fully-rendered element - this.height = getRectFromEntry(entry).height; - - if (this.props.onHeightChange) { - this.props.onHeightChange(this.props.status, this.height); - } - } - - this.setState((prevState) => { - if (prevState.isIntersecting && !entry.isIntersecting) { - scheduleIdleTask(this.hideIfNotIntersecting); - } - return { - isIntersecting: entry.isIntersecting, - isHidden: false, - }; - }); - } - - hideIfNotIntersecting = () => { - if (!this.componentMounted) { - return; - } - - // When the browser gets a chance, test if we're still not intersecting, - // and if so, set our isHidden to true to trigger an unrender. The point of - // this is to save DOM nodes and avoid using up too much memory. - // See: https://github.com/tootsuite/mastodon/issues/2900 - this.setState((prevState) => ({ isHidden: !prevState.isIntersecting })); - } - - handleRef = (node) => { - this.node = node; - } - handleClick = () => { if (!this.context.router) { return; @@ -175,25 +95,19 @@ export default class Status extends ImmutablePureComponent { let media = null; let statusAvatar; - // Exclude intersectionObserverWrapper from `other` variable - // because intersection is managed in here. - const { status, account, intersectionObserverWrapper, index, listLength, wrapped, ...other } = this.props; - const { isExpanded, isIntersecting, isHidden } = this.state; + const { status, account, hidden, ...other } = this.props; + const { isExpanded } = this.state; if (status === null) { return null; } - const hasIntersectionObserverWrapper = !!this.props.intersectionObserverWrapper; - const isHiddenForSure = isIntersecting === false && isHidden; - const visibilityUnknownButHeightIsCached = isIntersecting === undefined && status.has('height'); - - if (hasIntersectionObserverWrapper && (isHiddenForSure || visibilityUnknownButHeightIsCached)) { + if (hidden) { return ( - <article ref={this.handleRef} data-id={status.get('id')} aria-posinset={index} aria-setsize={listLength} tabIndex='0' style={{ height: `${this.height || status.get('height')}px`, opacity: 0, overflow: 'hidden' }}> + <div> {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} {status.get('content')} - </article> + </div> ); } @@ -201,14 +115,14 @@ export default class Status extends ImmutablePureComponent { const display_name_html = { __html: status.getIn(['account', 'display_name_html']) }; return ( - <article className='status__wrapper' ref={this.handleRef} data-id={status.get('id')} aria-posinset={index} aria-setsize={listLength} tabIndex='0'> + <div className='status__wrapper' data-id={status.get('id')} > <div className='status__prepend'> <div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div> <FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={display_name_html} /></a> }} /> </div> - <Status {...other} wrapped status={status.get('reblog')} account={status.get('account')} /> - </article> + <Status {...other} status={status.get('reblog')} account={status.get('account')} /> + </div> ); } @@ -237,7 +151,7 @@ export default class Status extends ImmutablePureComponent { } return ( - <article aria-posinset={index} aria-setsize={listLength} className={`status ${this.props.muted ? 'muted' : ''} status-${status.get('visibility')}`} data-id={status.get('id')} tabIndex={wrapped ? null : '0'} ref={this.handleRef}> + <div className={`status ${this.props.muted ? 'muted' : ''} status-${status.get('visibility')}`} data-id={status.get('id')}> <div className='status__info'> <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a> @@ -255,7 +169,7 @@ export default class Status extends ImmutablePureComponent { {media} <StatusActionBar {...this.props} /> - </article> + </div> ); } diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js index 81c2a4e23..de99f8850 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -24,6 +24,9 @@ const messages = defineMessages({ report: { id: 'status.report', defaultMessage: 'Report @{name}' }, muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' }, + pin: { id: 'status.pin', defaultMessage: 'Pin on profile' }, + unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' }, + embed: { id: 'status.embed', defaultMessage: 'Embed' }, }); @injectIntl @@ -43,7 +46,9 @@ export default class StatusActionBar extends ImmutablePureComponent { onMute: PropTypes.func, onBlock: PropTypes.func, onReport: PropTypes.func, + onEmbed: PropTypes.func, onMuteConversation: PropTypes.func, + onPin: PropTypes.func, me: PropTypes.number, withDismiss: PropTypes.bool, intl: PropTypes.object.isRequired, @@ -80,6 +85,10 @@ export default class StatusActionBar extends ImmutablePureComponent { this.props.onDelete(this.props.status); } + handlePinClick = () => { + this.props.onPin(this.props.status); + } + handleMentionClick = () => { this.props.onMention(this.props.status.get('account'), this.context.router.history); } @@ -96,6 +105,10 @@ export default class StatusActionBar extends ImmutablePureComponent { this.context.router.history.push(`/statuses/${this.props.status.get('id')}`); } + handleEmbed = () => { + this.props.onEmbed(this.props.status); + } + handleReport = () => { this.props.onReport(this.props.status); } @@ -106,9 +119,10 @@ export default class StatusActionBar extends ImmutablePureComponent { render () { const { status, me, intl, withDismiss } = this.props; - const reblogDisabled = status.get('visibility') === 'private' || status.get('visibility') === 'direct'; + const mutingConversation = status.get('muted'); - const anonymousAccess = !me; + const anonymousAccess = !me; + const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); let menu = []; let reblogIcon = 'retweet'; @@ -116,6 +130,11 @@ export default class StatusActionBar extends ImmutablePureComponent { let replyTitle; menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen }); + + if (publicStatus) { + menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed }); + } + menu.push(null); if (withDismiss) { @@ -124,6 +143,10 @@ export default class StatusActionBar extends ImmutablePureComponent { } if (status.getIn(['account', 'id']) === me) { + if (publicStatus) { + menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick }); + } + menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); } else { menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); @@ -154,7 +177,7 @@ export default class StatusActionBar extends ImmutablePureComponent { return ( <div className='status__action-bar'> <IconButton className='status__action-bar-button' disabled={anonymousAccess} title={replyTitle} icon={replyIcon} onClick={this.handleReplyClick} /> - <IconButton className='status__action-bar-button' disabled={anonymousAccess || reblogDisabled} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogDisabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /> + <IconButton className='status__action-bar-button' disabled={anonymousAccess || !publicStatus} active={status.get('reblogged')} pressed={status.get('reblogged')} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /> <IconButton className='status__action-bar-button star-icon' disabled={anonymousAccess} animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /> {shareButton} diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js index 271cf33b7..6bd357754 100644 --- a/app/javascript/mastodon/components/status_list.js +++ b/app/javascript/mastodon/components/status_list.js @@ -1,12 +1,10 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import { ScrollContainer } from 'react-router-scroll'; import PropTypes from 'prop-types'; import StatusContainer from '../../glitch/components/status/container'; import LoadMore from './load_more'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper'; -import { throttle } from 'lodash'; +import ScrollableList from './scrollable_list'; export default class StatusList extends ImmutablePureComponent { @@ -28,145 +26,21 @@ export default class StatusList extends ImmutablePureComponent { trackScroll: true, }; - intersectionObserverWrapper = new IntersectionObserverWrapper(); - - handleScroll = throttle(() => { - if (this.node) { - const { scrollTop, scrollHeight, clientHeight } = this.node; - const offset = scrollHeight - scrollTop - clientHeight; - this._oldScrollPosition = scrollHeight - scrollTop; - - if (400 > offset && this.props.onScrollToBottom && !this.props.isLoading) { - this.props.onScrollToBottom(); - } else if (scrollTop < 100 && this.props.onScrollToTop) { - this.props.onScrollToTop(); - } else if (this.props.onScroll) { - this.props.onScroll(); - } - } - }, 150, { - trailing: true, - }); - - componentDidMount () { - this.attachScrollListener(); - this.attachIntersectionObserver(); - - // Handle initial scroll posiiton - this.handleScroll(); - } - - componentDidUpdate (prevProps) { - // Reset the scroll position when a new toot comes in in order not to - // jerk the scrollbar around if you're already scrolled down the page. - if (prevProps.statusIds.size < this.props.statusIds.size && this._oldScrollPosition && this.node.scrollTop > 0) { - if (prevProps.statusIds.first() !== this.props.statusIds.first()) { - let newScrollTop = this.node.scrollHeight - this._oldScrollPosition; - if (this.node.scrollTop !== newScrollTop) { - this.node.scrollTop = newScrollTop; - } - } else { - this._oldScrollPosition = this.node.scrollHeight - this.node.scrollTop; - } - } - } - - componentWillUnmount () { - this.detachScrollListener(); - this.detachIntersectionObserver(); - } - - attachIntersectionObserver () { - this.intersectionObserverWrapper.connect({ - root: this.node, - rootMargin: '300% 0px', - }); - } - - detachIntersectionObserver () { - this.intersectionObserverWrapper.disconnect(); - } - - attachScrollListener () { - this.node.addEventListener('scroll', this.handleScroll); - } - - detachScrollListener () { - this.node.removeEventListener('scroll', this.handleScroll); - } - - setRef = (c) => { - this.node = c; - } - - handleLoadMore = (e) => { - e.preventDefault(); - this.props.onScrollToBottom(); - } - - handleKeyDown = (e) => { - if (['PageDown', 'PageUp'].includes(e.key) || (e.ctrlKey && ['End', 'Home'].includes(e.key))) { - const article = (() => { - switch (e.key) { - case 'PageDown': - return e.target.nodeName === 'ARTICLE' && e.target.nextElementSibling; - case 'PageUp': - return e.target.nodeName === 'ARTICLE' && e.target.previousElementSibling; - case 'End': - return this.node.querySelector('[role="feed"] > article:last-of-type'); - case 'Home': - return this.node.querySelector('[role="feed"] > article:first-of-type'); - default: - return null; - } - })(); - - - if (article) { - e.preventDefault(); - article.focus(); - article.scrollIntoView(); - } - } - } - render () { - const { statusIds, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props; - - const loadMore = <LoadMore visible={!isLoading && statusIds.size > 0 && hasMore} onClick={this.handleLoadMore} />; - let scrollableArea = null; - - if (isLoading || statusIds.size > 0 || !emptyMessage) { - scrollableArea = ( - <div className='scrollable' ref={this.setRef}> - <div role='feed' className='status-list' onKeyDown={this.handleKeyDown}> - {prepend} - - {statusIds.map((statusId, index) => { - return <StatusContainer key={statusId} id={statusId} index={index} listLength={statusIds.size} intersectionObserverWrapper={this.intersectionObserverWrapper} />; - })} - - {loadMore} - </div> - </div> - ); - } else { - scrollableArea = ( - <div className='empty-column-indicator' ref={this.setRef}> - {emptyMessage} - </div> - ); - } - - if (trackScroll) { - return ( - <ScrollContainer scrollKey={scrollKey} shouldUpdateScroll={shouldUpdateScroll}> - {scrollableArea} - </ScrollContainer> - ); - } else { - return scrollableArea; - } + const { statusIds, ...other } = this.props; + const { isLoading } = other; + + const scrollableContent = (isLoading || statusIds.size > 0) ? ( + statusIds.map((statusId) => ( + <StatusContainer key={statusId} id={statusId} /> + )) + ) : null; + + return ( + <ScrollableList {...other}> + {scrollableContent} + </ScrollableList> + ); } } diff --git a/app/javascript/mastodon/components/video_player.js b/app/javascript/mastodon/components/video_player.js index 5f2447c6d..f499e3e01 100644 --- a/app/javascript/mastodon/components/video_player.js +++ b/app/javascript/mastodon/components/video_player.js @@ -149,7 +149,7 @@ export default class VideoPlayer extends React.PureComponent { if (!this.state.visible) { if (sensitive) { return ( - <div role='button' tabIndex='0' style={{ width: `${width}px`, height: `${height}px`, marginTop: '8px' }} className='media-spoiler' onClick={this.handleVisibility}> + <div role='button' tabIndex='0' style={{ width: `${width}px`, height: `${height}px`, marginTop: '8px' }} className='media-spoiler__video' onClick={this.handleVisibility}> {spoilerButton} <span className='media-spoiler__warning'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span> <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> @@ -157,7 +157,7 @@ export default class VideoPlayer extends React.PureComponent { ); } else { return ( - <div role='button' tabIndex='0' style={{ width: `${width}px`, height: `${height}px`, marginTop: '8px' }} className='media-spoiler' onClick={this.handleVisibility}> + <div role='button' tabIndex='0' style={{ width: `${width}px`, height: `${height}px`, marginTop: '8px' }} className='media-spoiler__video' onClick={this.handleVisibility}> {spoilerButton} <span className='media-spoiler__warning'><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span> <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> diff --git a/app/javascript/mastodon/containers/account_container.js b/app/javascript/mastodon/containers/account_container.js index ca1efd0e5..7c77cb764 100644 --- a/app/javascript/mastodon/containers/account_container.js +++ b/app/javascript/mastodon/containers/account_container.js @@ -32,7 +32,7 @@ const makeMapStateToProps = () => { const mapDispatchToProps = (dispatch, { intl }) => ({ onFollow (account) { - if (account.getIn(['relationship', 'following'])) { + if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) { if (this.unfollowModal) { dispatch(openModal('CONFIRM', { message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, diff --git a/app/javascript/mastodon/containers/compose_container.js b/app/javascript/mastodon/containers/compose_container.js new file mode 100644 index 000000000..db452d03a --- /dev/null +++ b/app/javascript/mastodon/containers/compose_container.js @@ -0,0 +1,39 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import PropTypes from 'prop-types'; +import configureStore from '../store/configureStore'; +import { hydrateStore } from '../actions/store'; +import { IntlProvider, addLocaleData } from 'react-intl'; +import { getLocale } from '../locales'; +import Compose from '../features/standalone/compose'; + +const { localeData, messages } = getLocale(); +addLocaleData(localeData); + +const store = configureStore(); +const initialStateContainer = document.getElementById('initial-state'); + +if (initialStateContainer !== null) { + const initialState = JSON.parse(initialStateContainer.textContent); + store.dispatch(hydrateStore(initialState)); +} + +export default class TimelineContainer extends React.PureComponent { + + static propTypes = { + locale: PropTypes.string.isRequired, + }; + + render () { + const { locale } = this.props; + + return ( + <IntlProvider locale={locale} messages={messages}> + <Provider store={store}> + <Compose /> + </Provider> + </IntlProvider> + ); + } + +} diff --git a/app/javascript/mastodon/containers/mastodon.js b/app/javascript/mastodon/containers/mastodon.js index 8287375c4..db2a5f269 100644 --- a/app/javascript/mastodon/containers/mastodon.js +++ b/app/javascript/mastodon/containers/mastodon.js @@ -2,21 +2,13 @@ import React from 'react'; import { Provider } from 'react-redux'; import PropTypes from 'prop-types'; import configureStore from '../store/configureStore'; -import { - updateTimeline, - deleteFromTimelines, - refreshHomeTimeline, - connectTimeline, - disconnectTimeline, -} from '../actions/timelines'; import { showOnboardingOnce } from '../actions/onboarding'; -import { updateNotifications, refreshNotifications } from '../actions/notifications'; import BrowserRouter from 'react-router-dom/BrowserRouter'; import Route from 'react-router-dom/Route'; import ScrollContext from 'react-router-scroll/lib/ScrollBehaviorContext'; import UI from '../features/ui'; import { hydrateStore } from '../actions/store'; -import createStream from '../stream'; +import { connectUserStream } from '../actions/streaming'; import { IntlProvider, addLocaleData } from 'react-intl'; import { getLocale } from '../locales'; const { localeData, messages } = getLocale(); @@ -39,74 +31,28 @@ export default class Mastodon extends React.PureComponent { }; componentDidMount() { - const { locale } = this.props; - const streamingAPIBaseURL = store.getState().getIn(['meta', 'streaming_api_base_url']); - const accessToken = store.getState().getIn(['meta', 'access_token']); - - const setupPolling = () => { - this.polling = setInterval(() => { - store.dispatch(refreshHomeTimeline()); - store.dispatch(refreshNotifications()); - }, 20000); - }; - - const clearPolling = () => { - clearInterval(this.polling); - this.polling = undefined; - }; - - this.subscription = createStream(streamingAPIBaseURL, accessToken, 'user', { - - connected () { - clearPolling(); - store.dispatch(connectTimeline('home')); - }, - - disconnected () { - setupPolling(); - store.dispatch(disconnectTimeline('home')); - }, - - received (data) { - switch(data.event) { - case 'update': - store.dispatch(updateTimeline('home', JSON.parse(data.payload))); - break; - case 'delete': - store.dispatch(deleteFromTimelines(data.payload)); - break; - case 'notification': - store.dispatch(updateNotifications(JSON.parse(data.payload), messages, locale)); - break; - } - }, - - reconnected () { - clearPolling(); - store.dispatch(connectTimeline('home')); - store.dispatch(refreshHomeTimeline()); - store.dispatch(refreshNotifications()); - }, - - }); + this.disconnect = store.dispatch(connectUserStream()); // Desktop notifications + // Ask after 1 minute if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') { - Notification.requestPermission(); + window.setTimeout(() => Notification.requestPermission(), 60 * 1000); + } + + // Protocol handler + // Ask after 5 minutes + if (typeof navigator.registerProtocolHandler !== 'undefined') { + const handlerUrl = window.location.protocol + '//' + window.location.host + '/intent?uri=%s'; + window.setTimeout(() => navigator.registerProtocolHandler('web+mastodon', handlerUrl, 'Mastodon'), 5 * 60 * 1000); } store.dispatch(showOnboardingOnce()); } componentWillUnmount () { - if (typeof this.subscription !== 'undefined') { - this.subscription.close(); - this.subscription = null; - } - - if (typeof this.polling !== 'undefined') { - clearInterval(this.polling); - this.polling = null; + if (this.disconnect) { + this.disconnect(); + this.disconnect = null; } } diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js index d71584267..9dff79b72 100644 --- a/app/javascript/mastodon/containers/status_container.js +++ b/app/javascript/mastodon/containers/status_container.js @@ -14,6 +14,8 @@ import { favourite, unreblog, unfavourite, + pin, + unpin, } from '../actions/interactions'; import { blockAccount, @@ -75,6 +77,18 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ } }, + onPin (status) { + if (status.get('pinned')) { + dispatch(unpin(status)); + } else { + dispatch(pin(status)); + } + }, + + onEmbed (status) { + dispatch(openModal('EMBED', { url: status.get('url') })); + }, + onDelete (status) { if (!this.deleteModal) { dispatch(deleteStatus(status.get('id'))); diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js index 320e669a2..7ab492225 100644 --- a/app/javascript/mastodon/features/account/components/header.js +++ b/app/javascript/mastodon/features/account/components/header.js @@ -14,7 +14,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; const messages = defineMessages({ unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, follow: { id: 'account.follow', defaultMessage: 'Follow' }, - requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }, + requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' }, }); const makeMapStateToProps = () => { @@ -105,7 +105,7 @@ export default class Header extends ImmutablePureComponent { if (account.getIn(['relationship', 'requested'])) { actionBtn = ( <div className='account--action-button'> - <IconButton size={26} disabled icon='hourglass' title={intl.formatMessage(messages.requested)} /> + <IconButton size={26} active icon='hourglass' title={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} /> </div> ); } else if (!account.getIn(['relationship', 'blocking'])) { diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.js b/app/javascript/mastodon/features/account_timeline/containers/header_container.js index baa81bbc2..dcee78b3e 100644 --- a/app/javascript/mastodon/features/account_timeline/containers/header_container.js +++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.js @@ -38,7 +38,7 @@ const makeMapStateToProps = () => { const mapDispatchToProps = (dispatch, { intl }) => ({ onFollow (account) { - if (account.getIn(['relationship', 'following'])) { + if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) { if (this.unfollowModal) { dispatch(openModal('CONFIRM', { message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, diff --git a/app/javascript/mastodon/features/community_timeline/index.js b/app/javascript/mastodon/features/community_timeline/index.js index 0e2300f8c..596a89412 100644 --- a/app/javascript/mastodon/features/community_timeline/index.js +++ b/app/javascript/mastodon/features/community_timeline/index.js @@ -7,15 +7,11 @@ import ColumnHeader from '../../components/column_header'; import { refreshCommunityTimeline, expandCommunityTimeline, - updateTimeline, - deleteFromTimelines, - connectTimeline, - disconnectTimeline, } from '../../actions/timelines'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ColumnSettingsContainer from './containers/column_settings_container'; -import createStream from '../../stream'; +import { connectCommunityStream } from '../../actions/streaming'; const messages = defineMessages({ title: { id: 'column.community', defaultMessage: 'Local timeline' }, @@ -23,8 +19,6 @@ const messages = defineMessages({ const mapStateToProps = state => ({ hasUnread: state.getIn(['timelines', 'community', 'unread']) > 0, - streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']), - accessToken: state.getIn(['meta', 'access_token']), }); @connect(mapStateToProps) @@ -35,8 +29,6 @@ export default class CommunityTimeline extends React.PureComponent { dispatch: PropTypes.func.isRequired, columnId: PropTypes.string, intl: PropTypes.object.isRequired, - streamingAPIBaseURL: PropTypes.string.isRequired, - accessToken: PropTypes.string.isRequired, hasUnread: PropTypes.bool, multiColumn: PropTypes.bool, }; @@ -61,46 +53,16 @@ export default class CommunityTimeline extends React.PureComponent { } componentDidMount () { - const { dispatch, streamingAPIBaseURL, accessToken } = this.props; + const { dispatch } = this.props; dispatch(refreshCommunityTimeline()); - - if (typeof this._subscription !== 'undefined') { - return; - } - - this._subscription = createStream(streamingAPIBaseURL, accessToken, 'public:local', { - - connected () { - dispatch(connectTimeline('community')); - }, - - reconnected () { - dispatch(connectTimeline('community')); - }, - - disconnected () { - dispatch(disconnectTimeline('community')); - }, - - received (data) { - switch(data.event) { - case 'update': - dispatch(updateTimeline('community', JSON.parse(data.payload))); - break; - case 'delete': - dispatch(deleteFromTimelines(data.payload)); - break; - } - }, - - }); + this.disconnect = dispatch(connectCommunityStream()); } componentWillUnmount () { - if (typeof this._subscription !== 'undefined') { - this._subscription.close(); - this._subscription = null; + if (this.disconnect) { + this.disconnect(); + this.disconnect = null; } } diff --git a/app/javascript/mastodon/features/favourited_statuses/index.js b/app/javascript/mastodon/features/favourited_statuses/index.js index d9ad9bc1f..1e1f5873c 100644 --- a/app/javascript/mastodon/features/favourited_statuses/index.js +++ b/app/javascript/mastodon/features/favourited_statuses/index.js @@ -16,6 +16,7 @@ const messages = defineMessages({ const mapStateToProps = state => ({ statusIds: state.getIn(['status_lists', 'favourites', 'items']), + hasMore: !!state.getIn(['status_lists', 'favourites', 'next']), }); @connect(mapStateToProps) @@ -28,6 +29,7 @@ export default class Favourites extends ImmutablePureComponent { intl: PropTypes.object.isRequired, columnId: PropTypes.string, multiColumn: PropTypes.bool, + hasMore: PropTypes.bool, }; componentWillMount () { @@ -62,7 +64,7 @@ export default class Favourites extends ImmutablePureComponent { } render () { - const { intl, statusIds, columnId, multiColumn } = this.props; + const { intl, statusIds, columnId, multiColumn, hasMore } = this.props; const pinned = !!columnId; return ( @@ -75,12 +77,14 @@ export default class Favourites extends ImmutablePureComponent { onClick={this.handleHeaderClick} pinned={pinned} multiColumn={multiColumn} + showBackButton /> <StatusList trackScroll={!pinned} statusIds={statusIds} scrollKey={`favourited_statuses-${columnId}`} + hasMore={hasMore} onScrollToBottom={this.handleScrollToBottom} /> </Column> diff --git a/app/javascript/mastodon/features/hashtag_timeline/index.js b/app/javascript/mastodon/features/hashtag_timeline/index.js index b17e8e1a5..5fe21ce90 100644 --- a/app/javascript/mastodon/features/hashtag_timeline/index.js +++ b/app/javascript/mastodon/features/hashtag_timeline/index.js @@ -7,17 +7,13 @@ import ColumnHeader from '../../components/column_header'; import { refreshHashtagTimeline, expandHashtagTimeline, - updateTimeline, - deleteFromTimelines, } from '../../actions/timelines'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { FormattedMessage } from 'react-intl'; -import createStream from '../../stream'; +import { connectHashtagStream } from '../../actions/streaming'; -const mapStateToProps = state => ({ - hasUnread: state.getIn(['timelines', 'tag', 'unread']) > 0, - streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']), - accessToken: state.getIn(['meta', 'access_token']), +const mapStateToProps = (state, props) => ({ + hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}`, 'unread']) > 0, }); @connect(mapStateToProps) @@ -27,8 +23,6 @@ export default class HashtagTimeline extends React.PureComponent { params: PropTypes.object.isRequired, columnId: PropTypes.string, dispatch: PropTypes.func.isRequired, - streamingAPIBaseURL: PropTypes.string.isRequired, - accessToken: PropTypes.string.isRequired, hasUnread: PropTypes.bool, multiColumn: PropTypes.bool, }; @@ -53,28 +47,13 @@ export default class HashtagTimeline extends React.PureComponent { } _subscribe (dispatch, id) { - const { streamingAPIBaseURL, accessToken } = this.props; - - this.subscription = createStream(streamingAPIBaseURL, accessToken, `hashtag&tag=${id}`, { - - received (data) { - switch(data.event) { - case 'update': - dispatch(updateTimeline(`hashtag:${id}`, JSON.parse(data.payload))); - break; - case 'delete': - dispatch(deleteFromTimelines(data.payload)); - break; - } - }, - - }); + this.disconnect = dispatch(connectHashtagStream(id)); } _unsubscribe () { - if (typeof this.subscription !== 'undefined') { - this.subscription.close(); - this.subscription = null; + if (this.disconnect) { + this.disconnect(); + this.disconnect = null; } } diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js index 7d521e4b6..b52c3c934 100644 --- a/app/javascript/mastodon/features/notifications/components/notification.js +++ b/app/javascript/mastodon/features/notifications/components/notification.js @@ -2,6 +2,7 @@ // SEE INSTEAD : glitch/components/notification import React from 'react'; +import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import StatusContainer from '../../../containers/status_container'; import AccountContainer from '../../../containers/account_container'; @@ -13,6 +14,7 @@ export default class Notification extends ImmutablePureComponent { static propTypes = { notification: ImmutablePropTypes.map.isRequired, + hidden: PropTypes.bool, }; renderFollow (account, link) { @@ -26,13 +28,13 @@ export default class Notification extends ImmutablePureComponent { <FormattedMessage id='notification.follow' defaultMessage='{name} followed you' values={{ name: link }} /> </div> - <AccountContainer id={account.get('id')} withNote={false} /> + <AccountContainer id={account.get('id')} withNote={false} hidden={this.props.hidden} /> </div> ); } renderMention (notification) { - return <StatusContainer id={notification.get('status')} withDismiss />; + return <StatusContainer id={notification.get('status')} withDismiss hidden={this.props.hidden} />; } renderFavourite (notification, link) { @@ -45,7 +47,7 @@ export default class Notification extends ImmutablePureComponent { <FormattedMessage id='notification.favourite' defaultMessage='{name} favourited your status' values={{ name: link }} /> </div> - <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss /> + <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={!!this.props.hidden} /> </div> ); } @@ -60,7 +62,7 @@ export default class Notification extends ImmutablePureComponent { <FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} /> </div> - <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss /> + <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={this.props.hidden} /> </div> ); } diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js index 0d86d41ce..54e58dfd0 100644 --- a/app/javascript/mastodon/features/notifications/index.js +++ b/app/javascript/mastodon/features/notifications/index.js @@ -16,8 +16,8 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ColumnSettingsContainer from './containers/column_settings_container'; import { createSelector } from 'reselect'; import { List as ImmutableList } from 'immutable'; -import LoadMore from '../../components/load_more'; import { debounce } from 'lodash'; +import ScrollableList from '../../components/scrollable_list'; const messages = defineMessages({ title: { id: 'column.notifications', defaultMessage: 'Notifications' }, @@ -68,40 +68,18 @@ export default class Notifications extends React.PureComponent { trackScroll: true, }; - dispatchExpandNotifications = debounce(() => { + handleScrollToBottom = debounce(() => { + this.props.dispatch(scrollTopNotifications(false)); this.props.dispatch(expandNotifications()); }, 300, { leading: true }); - dispatchScrollToTop = debounce((top) => { - this.props.dispatch(scrollTopNotifications(top)); + handleScrollToTop = debounce(() => { + this.props.dispatch(scrollTopNotifications(true)); }, 100); - handleScroll = (e) => { - const { scrollTop, scrollHeight, clientHeight } = e.target; - const offset = scrollHeight - scrollTop - clientHeight; - this._oldScrollPosition = scrollHeight - scrollTop; - - if (250 > offset && this.props.hasMore && !this.props.isLoading) { - this.dispatchExpandNotifications(); - } - - if (scrollTop < 100) { - this.dispatchScrollToTop(true); - } else { - this.dispatchScrollToTop(false); - } - } - - componentDidUpdate (prevProps) { - if (this.node.scrollTop > 0 && (prevProps.notifications.size < this.props.notifications.size && prevProps.notifications.first() !== this.props.notifications.first() && !!this._oldScrollPosition)) { - this.node.scrollTop = this.node.scrollHeight - this._oldScrollPosition; - } - } - - handleLoadMore = (e) => { - e.preventDefault(); - this.dispatchExpandNotifications(); - } + handleScroll = debounce(() => { + this.props.dispatch(scrollTopNotifications(false)); + }, 100); handlePin = () => { const { columnId, dispatch } = this.props; @@ -122,10 +100,6 @@ export default class Notifications extends React.PureComponent { this.column.scrollTop(); } - setRef = (c) => { - this.node = c; - } - setColumnRef = c => { this.column = c; } @@ -133,52 +107,35 @@ export default class Notifications extends React.PureComponent { render () { const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore } = this.props; const pinned = !!columnId; + const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />; - let loadMore = ''; - let scrollableArea = ''; - let unread = ''; - let scrollContainer = ''; - - if (!isLoading && hasMore) { - loadMore = <LoadMore onClick={this.handleLoadMore} />; - } - - if (isUnread) { - unread = <div className='notifications__unread-indicator' />; - } + let scrollableContent = null; - if (isLoading && this.scrollableArea) { - scrollableArea = this.scrollableArea; + if (isLoading && this.scrollableContent) { + scrollableContent = this.scrollableContent; } else if (notifications.size > 0 || hasMore) { - scrollableArea = ( - <div className='scrollable' onScroll={this.handleScroll} ref={this.setRef}> - {unread} - - <div> - {notifications.map(item => <NotificationContainer key={item.get('id')} notification={item} accountId={item.get('account')} />)} - {loadMore} - </div> - </div> - ); + scrollableContent = notifications.map((item) => <NotificationContainer key={item.get('id')} notification={item} accountId={item.get('account')} />); } else { - scrollableArea = ( - <div className='empty-column-indicator' ref={this.setRef}> - <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." /> - </div> - ); + scrollableContent = null; } - if (pinned) { - scrollContainer = scrollableArea; - } else { - scrollContainer = ( - <ScrollContainer scrollKey={`notifications-${columnId}`} shouldUpdateScroll={shouldUpdateScroll}> - {scrollableArea} - </ScrollContainer> - ); - } - - this.scrollableArea = scrollableArea; + this.scrollableContent = scrollableContent; + + const scrollContainer = ( + <ScrollableList + scrollKey={`notifications-${columnId}`} + trackScroll={!pinned} + isLoading={isLoading} + hasMore={hasMore} + emptyMessage={emptyMessage} + onScrollToBottom={this.handleScrollToBottom} + onScrollToTop={this.handleScrollToTop} + onScroll={this.handleScroll} + shouldUpdateScroll={shouldUpdateScroll} + > + {scrollableContent} + </ScrollableList> + ); return ( <Column diff --git a/app/javascript/mastodon/features/public_timeline/index.js b/app/javascript/mastodon/features/public_timeline/index.js index c6cad02d6..193489c63 100644 --- a/app/javascript/mastodon/features/public_timeline/index.js +++ b/app/javascript/mastodon/features/public_timeline/index.js @@ -7,15 +7,11 @@ import ColumnHeader from '../../components/column_header'; import { refreshPublicTimeline, expandPublicTimeline, - updateTimeline, - deleteFromTimelines, - connectTimeline, - disconnectTimeline, } from '../../actions/timelines'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ColumnSettingsContainer from './containers/column_settings_container'; -import createStream from '../../stream'; +import { connectPublicStream } from '../../actions/streaming'; const messages = defineMessages({ title: { id: 'column.public', defaultMessage: 'Federated timeline' }, @@ -23,8 +19,6 @@ const messages = defineMessages({ const mapStateToProps = state => ({ hasUnread: state.getIn(['timelines', 'public', 'unread']) > 0, - streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']), - accessToken: state.getIn(['meta', 'access_token']), }); @connect(mapStateToProps) @@ -36,8 +30,6 @@ export default class PublicTimeline extends React.PureComponent { intl: PropTypes.object.isRequired, columnId: PropTypes.string, multiColumn: PropTypes.bool, - streamingAPIBaseURL: PropTypes.string.isRequired, - accessToken: PropTypes.string.isRequired, hasUnread: PropTypes.bool, }; @@ -61,46 +53,16 @@ export default class PublicTimeline extends React.PureComponent { } componentDidMount () { - const { dispatch, streamingAPIBaseURL, accessToken } = this.props; + const { dispatch } = this.props; dispatch(refreshPublicTimeline()); - - if (typeof this._subscription !== 'undefined') { - return; - } - - this._subscription = createStream(streamingAPIBaseURL, accessToken, 'public', { - - connected () { - dispatch(connectTimeline('public')); - }, - - reconnected () { - dispatch(connectTimeline('public')); - }, - - disconnected () { - dispatch(disconnectTimeline('public')); - }, - - received (data) { - switch(data.event) { - case 'update': - dispatch(updateTimeline('public', JSON.parse(data.payload))); - break; - case 'delete': - dispatch(deleteFromTimelines(data.payload)); - break; - } - }, - - }); + this.disconnect = dispatch(connectPublicStream()); } componentWillUnmount () { - if (typeof this._subscription !== 'undefined') { - this._subscription.close(); - this._subscription = null; + if (this.disconnect) { + this.disconnect(); + this.disconnect = null; } } diff --git a/app/javascript/mastodon/features/standalone/compose/index.js b/app/javascript/mastodon/features/standalone/compose/index.js new file mode 100644 index 000000000..96d07fefb --- /dev/null +++ b/app/javascript/mastodon/features/standalone/compose/index.js @@ -0,0 +1,18 @@ +import React from 'react'; +import ComposeFormContainer from '../../compose/containers/compose_form_container'; +import NotificationsContainer from '../../ui/containers/notifications_container'; +import LoadingBarContainer from '../../ui/containers/loading_bar_container'; + +export default class Compose extends React.PureComponent { + + render () { + return ( + <div> + <ComposeFormContainer /> + <NotificationsContainer /> + <LoadingBarContainer className='loading-bar' /> + </div> + ); + } + +} diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js index a2885adda..4be013be7 100644 --- a/app/javascript/mastodon/features/status/components/action_bar.js +++ b/app/javascript/mastodon/features/status/components/action_bar.js @@ -14,6 +14,9 @@ const messages = defineMessages({ favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, report: { id: 'status.report', defaultMessage: 'Report @{name}' }, share: { id: 'status.share', defaultMessage: 'Share' }, + pin: { id: 'status.pin', defaultMessage: 'Pin on profile' }, + unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' }, + embed: { id: 'status.embed', defaultMessage: 'Embed' }, }); @injectIntl @@ -31,6 +34,8 @@ export default class ActionBar extends React.PureComponent { onDelete: PropTypes.func.isRequired, onMention: PropTypes.func.isRequired, onReport: PropTypes.func, + onPin: PropTypes.func, + onEmbed: PropTypes.func, me: PropTypes.number.isRequired, intl: PropTypes.object.isRequired, }; @@ -59,6 +64,10 @@ export default class ActionBar extends React.PureComponent { this.props.onReport(this.props.status); } + handlePinClick = () => { + this.props.onPin(this.props.status); + } + handleShare = () => { navigator.share({ text: this.props.status.get('search_index'), @@ -66,12 +75,26 @@ export default class ActionBar extends React.PureComponent { }); } + handleEmbed = () => { + this.props.onEmbed(this.props.status); + } + render () { const { status, me, intl } = this.props; + const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); + let menu = []; + if (publicStatus) { + menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed }); + } + if (me === status.getIn(['account', 'id'])) { + if (publicStatus) { + menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick }); + } + menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); } else { menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); diff --git a/app/javascript/mastodon/features/status/components/card.js b/app/javascript/mastodon/features/status/components/card.js index bfb40468b..6b13e15cc 100644 --- a/app/javascript/mastodon/features/status/components/card.js +++ b/app/javascript/mastodon/features/status/components/card.js @@ -1,6 +1,7 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import punycode from 'punycode'; +import classnames from 'classnames'; const IDNA_PREFIX = 'xn--'; @@ -32,7 +33,7 @@ export default class Card extends React.PureComponent { if (card.get('image')) { image = ( <div className='status-card__image'> - <img src={card.get('image')} alt={card.get('title')} className='status-card__image-image' /> + <img src={card.get('image')} alt={card.get('title')} className='status-card__image-image' width={card.get('width')} height={card.get('height')} /> </div> ); } @@ -41,8 +42,12 @@ export default class Card extends React.PureComponent { provider = decodeIDNA(getHostname(card.get('url'))); } + const className = classnames('status-card', { + 'horizontal': card.get('width') > card.get('height'), + }); + return ( - <a href={card.get('url')} className='status-card' target='_blank' rel='noopener'> + <a href={card.get('url')} className={className} target='_blank' rel='noopener'> {image} <div className='status-card__content'> diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js index d774dfdfe..03010cf0a 100644 --- a/app/javascript/mastodon/features/status/index.js +++ b/app/javascript/mastodon/features/status/index.js @@ -12,6 +12,8 @@ import { unfavourite, reblog, unreblog, + pin, + unpin, } from '../../actions/interactions'; import { replyCompose, @@ -89,6 +91,14 @@ export default class Status extends ImmutablePureComponent { } } + handlePin = (status) => { + if (status.get('pinned')) { + this.props.dispatch(unpin(status)); + } else { + this.props.dispatch(pin(status)); + } + } + handleReplyClick = (status) => { this.props.dispatch(replyCompose(status, this.context.router.history)); } @@ -139,6 +149,10 @@ export default class Status extends ImmutablePureComponent { this.props.dispatch(initReport(status.get('account'), status)); } + handleEmbed = (status) => { + this.props.dispatch(openModal('EMBED', { url: status.get('url') })); + } + renderChildren (list) { return list.map(id => <StatusContainer key={id} id={id} />); } @@ -190,6 +204,8 @@ export default class Status extends ImmutablePureComponent { onDelete={this.handleDeleteClick} onMention={this.handleMentionClick} onReport={this.handleReport} + onPin={this.handlePin} + onEmbed={this.handleEmbed} /> {descendants} diff --git a/app/javascript/mastodon/features/ui/components/column.js b/app/javascript/mastodon/features/ui/components/column.js index 9031c16fc..15538ea38 100644 --- a/app/javascript/mastodon/features/ui/components/column.js +++ b/app/javascript/mastodon/features/ui/components/column.js @@ -25,6 +25,17 @@ export default class Column extends React.PureComponent { this._interruptScrollAnimation = scrollTop(scrollable); } + scrollTop () { + const scrollable = this.node.querySelector('.scrollable'); + + if (!scrollable) { + return; + } + + this._interruptScrollAnimation = scrollTop(scrollable); + } + + handleScroll = debounce(() => { if (typeof this._interruptScrollAnimation !== 'undefined') { this._interruptScrollAnimation(); diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js index 47d5a2e20..7d84bece7 100644 --- a/app/javascript/mastodon/features/ui/components/columns_area.js +++ b/app/javascript/mastodon/features/ui/components/columns_area.js @@ -12,6 +12,7 @@ import ColumnLoading from './column_loading'; import BundleColumnError from './bundle_column_error'; import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, FavouritedStatuses } from '../../ui/util/async-components'; +import detectPassiveEvents from 'detect-passive-events'; import { scrollRight } from '../../../scroll'; const componentMap = { @@ -24,7 +25,7 @@ const componentMap = { 'FAVOURITES': FavouritedStatuses, }; -@injectIntl +@component => injectIntl(component, { withRef: true }) export default class ColumnsArea extends ImmutablePureComponent { static contextTypes = { @@ -47,16 +48,36 @@ export default class ColumnsArea extends ImmutablePureComponent { } componentDidMount() { + if (!this.props.singleColumn) { + this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false); + } this.lastIndex = getIndex(this.context.router.history.location.pathname); this.setState({ shouldAnimate: true }); } + componentWillUpdate(nextProps) { + if (this.props.singleColumn !== nextProps.singleColumn && nextProps.singleColumn) { + this.node.removeEventListener('wheel', this.handleWheel); + } + } + componentDidUpdate(prevProps) { + if (this.props.singleColumn !== prevProps.singleColumn && !this.props.singleColumn) { + this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false); + } this.lastIndex = getIndex(this.context.router.history.location.pathname); this.setState({ shouldAnimate: true }); + } - if (this.props.children !== prevProps.children && !this.props.singleColumn) { - scrollRight(this.node); + componentWillUnmount () { + if (!this.props.singleColumn) { + this.node.removeEventListener('wheel', this.handleWheel); + } + } + + handleChildrenContentChange() { + if (!this.props.singleColumn) { + scrollRight(this.node, this.node.scrollWidth - window.innerWidth); } } @@ -80,6 +101,14 @@ export default class ColumnsArea extends ImmutablePureComponent { } } + handleWheel = () => { + if (typeof this._interruptScrollAnimation !== 'function') { + return; + } + + this._interruptScrollAnimation(); + } + setRef = (node) => { this.node = node; } diff --git a/app/javascript/mastodon/features/ui/components/embed_modal.js b/app/javascript/mastodon/features/ui/components/embed_modal.js new file mode 100644 index 000000000..992aed8a3 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/embed_modal.js @@ -0,0 +1,84 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { FormattedMessage, injectIntl } from 'react-intl'; +import axios from 'axios'; + +@injectIntl +export default class EmbedModal extends ImmutablePureComponent { + + static propTypes = { + url: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + } + + state = { + loading: false, + oembed: null, + }; + + componentDidMount () { + const { url } = this.props; + + this.setState({ loading: true }); + + axios.post('/api/web/embed', { url }).then(res => { + this.setState({ loading: false, oembed: res.data }); + + const iframeDocument = this.iframe.contentWindow.document; + + iframeDocument.open(); + iframeDocument.write(res.data.html); + iframeDocument.close(); + + iframeDocument.body.style.margin = 0; + this.iframe.height = iframeDocument.body.scrollHeight + 'px'; + }); + } + + setIframeRef = c => { + this.iframe = c; + } + + handleTextareaClick = (e) => { + e.target.select(); + } + + render () { + const { oembed } = this.state; + + return ( + <div className='modal-root__modal embed-modal'> + <h4><FormattedMessage id='status.embed' defaultMessage='Embed' /></h4> + + <div className='embed-modal__container'> + <p className='hint'> + <FormattedMessage id='embed.instructions' defaultMessage='Embed this status on your website by copying the code below.' /> + </p> + + <input + type='text' + className='embed-modal__html' + readOnly + value={oembed && oembed.html || ''} + onClick={this.handleTextareaClick} + /> + + <p className='hint'> + <FormattedMessage id='embed.preview' defaultMessage='Here is what it will look like:' /> + </p> + + <iframe + className='embed-modal__iframe' + scrolling='no' + frameBorder='0' + ref={this.setIframeRef} + title='preview' + /> + </div> + </div> + ); + } + +} diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js index d316ff433..cd605d7b2 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.js +++ b/app/javascript/mastodon/features/ui/components/modal_root.js @@ -14,6 +14,7 @@ import { ConfirmationModal, ReportModal, SettingsModal, + EmbedModal, } from '../../../features/ui/util/async-components'; const MODAL_COMPONENTS = { @@ -25,6 +26,7 @@ const MODAL_COMPONENTS = { 'REPORT': ReportModal, 'SETTINGS': SettingsModal, 'ACTIONS': () => Promise.resolve({ default: ActionsModal }), + 'EMBED': EmbedModal, }; export default class ModalRoot extends React.PureComponent { diff --git a/app/javascript/mastodon/features/ui/components/onboarding_modal.js b/app/javascript/mastodon/features/ui/components/onboarding_modal.js index 0c872f40d..2facf9c44 100644 --- a/app/javascript/mastodon/features/ui/components/onboarding_modal.js +++ b/app/javascript/mastodon/features/ui/components/onboarding_modal.js @@ -30,7 +30,7 @@ const PageOne = ({ acct, domain }) => ( <div> <h1><FormattedMessage id='onboarding.page_one.welcome' defaultMessage='Welcome to {domain}!' values={{ domain }} /></h1> <p><FormattedMessage id='onboarding.page_one.federation' defaultMessage='{domain} is an "instance" of Mastodon. Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.' values={{ domain }} /></p> - <p><FormattedMessage id='onboarding.page_one.handle' defaultMessage='You are on {domain}, so your full handle is {handle}' values={{ domain, handle: <strong>{acct}@{domain}</strong> }} /></p> + <p><FormattedMessage id='onboarding.page_one.handle' defaultMessage='You are on {domain}, so your full handle is {handle}' values={{ domain, handle: <strong>@{acct}@{domain}</strong> }} /></p> </div> </div> ); diff --git a/app/javascript/mastodon/features/ui/containers/columns_area_container.js b/app/javascript/mastodon/features/ui/containers/columns_area_container.js index 6420f0784..95f95618b 100644 --- a/app/javascript/mastodon/features/ui/containers/columns_area_container.js +++ b/app/javascript/mastodon/features/ui/containers/columns_area_container.js @@ -5,4 +5,4 @@ const mapStateToProps = state => ({ columns: state.getIn(['settings', 'columns']), }); -export default connect(mapStateToProps)(ColumnsArea); +export default connect(mapStateToProps, null, null, { withRef: true })(ColumnsArea); diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index 6d53f474d..a14547ed8 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -1,12 +1,11 @@ import React from 'react'; -import classNames from 'classnames'; -import Redirect from 'react-router-dom/Redirect'; import NotificationsContainer from './containers/notifications_container'; import PropTypes from 'prop-types'; import LoadingBarContainer from './containers/loading_bar_container'; import TabsBar from './components/tabs_bar'; import ModalContainer from './containers/modal_container'; import { connect } from 'react-redux'; +import { Redirect, withRouter } from 'react-router-dom'; import { isMobile } from '../../is_mobile'; import { debounce } from 'lodash'; import { uploadCompose } from '../../actions/compose'; @@ -16,6 +15,7 @@ import { clearStatusesHeight } from '../../actions/statuses'; import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers'; import UploadArea from './components/upload_area'; import ColumnsAreaContainer from './containers/columns_area_container'; +import classNames from 'classnames'; import { Compose, Status, @@ -51,6 +51,7 @@ const mapStateToProps = state => ({ }); @connect(mapStateToProps) +@withRouter export default class UI extends React.PureComponent { static contextTypes = { @@ -65,6 +66,7 @@ export default class UI extends React.PureComponent { systemFontUi: PropTypes.bool, navbarUnder: PropTypes.bool, isComposing: PropTypes.bool, + location: PropTypes.object, }; state = { @@ -141,7 +143,7 @@ export default class UI extends React.PureComponent { if (data.type === 'navigate') { this.context.router.history.push(data.path); } else { - console.warn('Unknown message type:', data.type); // eslint-disable-line no-console + console.warn('Unknown message type:', data.type); } } @@ -175,6 +177,12 @@ export default class UI extends React.PureComponent { return true; } + componentDidUpdate (prevProps) { + if (![this.props.location.pathname, '/'].includes(prevProps.location.pathname)) { + this.columnsAreaNode.handleChildrenContentChange(); + } + } + componentWillUnmount () { window.removeEventListener('resize', this.handleResize); document.removeEventListener('dragenter', this.handleDragEnter); @@ -188,6 +196,10 @@ export default class UI extends React.PureComponent { this.node = c; } + setColumnsAreaRef = (c) => { + this.columnsAreaNode = c.getWrappedInstance().getWrappedInstance(); + } + render () { const { width, draggingOver } = this.state; const { children, layout, isWide, navbarUnder } = this.props; @@ -212,7 +224,7 @@ export default class UI extends React.PureComponent { return ( <div className={className} ref={this.setRef}> {navbarUnder ? null : (<TabsBar />)} - <ColumnsAreaContainer singleColumn={isMobile(width, layout)}> + <ColumnsAreaContainer ref={this.setColumnsAreaRef} singleColumn={isMobile(width, layout)}> <WrappedSwitch> <Redirect from='/' to='/getting-started' exact /> <WrappedRoute path='/getting-started' component={GettingStarted} content={children} /> diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index 9267519dd..108ffc142 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -116,3 +116,7 @@ export function MediaGallery () { export function VideoPlayer () { return import(/* webpackChunkName: "status/video_player" */'../../../components/video_player'); } + +export function EmbedModal () { + return import(/* webpackChunkName: "modals/embed_modal" */'../components/embed_modal'); +} diff --git a/app/javascript/mastodon/locales/ar.json b/app/javascript/mastodon/locales/ar.json index f5cf77f92..2ceb6eb9a 100644 --- a/app/javascript/mastodon/locales/ar.json +++ b/app/javascript/mastodon/locales/ar.json @@ -47,7 +47,7 @@ "compose_form.lock_disclaimer.lock": "مقفل", "compose_form.placeholder": "فيمَ تفكّر؟", "compose_form.privacy_disclaimer": "Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}}? Post privacy only works on Mastodon instances. If {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, there will be no indication that your post is private, and it may be boosted or otherwise made visible to unintended recipients.", - "compose_form.publish": "بوّق !", + "compose_form.publish": "بوّق", "compose_form.publish_loud": "{publish}!", "compose_form.sensitive": "ضع علامة على الوسيط باعتباره حسّاس", "compose_form.spoiler": "أخفِ النص واعرض تحذيرا", @@ -63,6 +63,8 @@ "confirmations.mute.message": "هل أنت متأكد أنك تريد كتم {name} ؟", "confirmations.unfollow.confirm": "Unfollow", "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?", + "embed.instructions": "Embed this status on your website by copying the code below.", + "embed.preview": "Here is what it will look like:", "emoji_button.activity": "الأنشطة", "emoji_button.flags": "الأعلام", "emoji_button.food": "الطعام والشراب", @@ -162,12 +164,14 @@ "standalone.public_title": "A look inside...", "status.cannot_reblog": "تعذرت ترقية هذا المنشور", "status.delete": "إحذف", + "status.embed": "Embed", "status.favourite": "أضف إلى المفضلة", "status.load_more": "حمّل المزيد", "status.media_hidden": "الصورة مستترة", "status.mention": "أذكُر @{name}", "status.mute_conversation": "Mute conversation", "status.open": "وسع هذه المشاركة", + "status.pin": "Pin on profile", "status.reblog": "رَقِّي", "status.reblogged_by": "{name} رقى", "status.reply": "ردّ", @@ -179,6 +183,7 @@ "status.show_less": "إعرض أقلّ", "status.show_more": "أظهر المزيد", "status.unmute_conversation": "Unmute conversation", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "تحرير", "tabs_bar.federated_timeline": "الموحَّد", "tabs_bar.home": "الرئيسية", diff --git a/app/javascript/mastodon/locales/bg.json b/app/javascript/mastodon/locales/bg.json index e6788f9eb..183ba2673 100644 --- a/app/javascript/mastodon/locales/bg.json +++ b/app/javascript/mastodon/locales/bg.json @@ -63,6 +63,8 @@ "confirmations.mute.message": "Are you sure you want to mute {name}?", "confirmations.unfollow.confirm": "Unfollow", "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?", + "embed.instructions": "Embed this status on your website by copying the code below.", + "embed.preview": "Here is what it will look like:", "emoji_button.activity": "Activity", "emoji_button.flags": "Flags", "emoji_button.food": "Food & Drink", @@ -162,12 +164,14 @@ "standalone.public_title": "A look inside...", "status.cannot_reblog": "This post cannot be boosted", "status.delete": "Изтриване", + "status.embed": "Embed", "status.favourite": "Предпочитани", "status.load_more": "Load more", "status.media_hidden": "Media hidden", "status.mention": "Споменаване", "status.mute_conversation": "Mute conversation", "status.open": "Expand this status", + "status.pin": "Pin on profile", "status.reblog": "Споделяне", "status.reblogged_by": "{name} сподели", "status.reply": "Отговор", @@ -179,6 +183,7 @@ "status.show_less": "Show less", "status.show_more": "Show more", "status.unmute_conversation": "Unmute conversation", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "Съставяне", "tabs_bar.federated_timeline": "Federated", "tabs_bar.home": "Начало", diff --git a/app/javascript/mastodon/locales/ca.json b/app/javascript/mastodon/locales/ca.json index 95b3c60bf..0e3d2bc18 100644 --- a/app/javascript/mastodon/locales/ca.json +++ b/app/javascript/mastodon/locales/ca.json @@ -63,6 +63,8 @@ "confirmations.mute.message": "Estàs segur que vols silenciar {name}?", "confirmations.unfollow.confirm": "Unfollow", "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?", + "embed.instructions": "Embed this status on your website by copying the code below.", + "embed.preview": "Here is what it will look like:", "emoji_button.activity": "Activitat", "emoji_button.flags": "Flags", "emoji_button.food": "Menjar i Beure", @@ -162,12 +164,14 @@ "standalone.public_title": "A look inside...", "status.cannot_reblog": "Aquesta publicació no pot ser retootejada", "status.delete": "Esborrar", + "status.embed": "Embed", "status.favourite": "Favorit", "status.load_more": "Carrega més", "status.media_hidden": "Multimèdia amagat", "status.mention": "Esmentar @{name}", "status.mute_conversation": "Silenciar conversació", "status.open": "Ampliar aquest estat", + "status.pin": "Pin on profile", "status.reblog": "Boost", "status.reblogged_by": "{name} ha retootejat", "status.reply": "Respondre", @@ -179,6 +183,7 @@ "status.show_less": "Mostra menys", "status.show_more": "Mostra més", "status.unmute_conversation": "Activar conversació", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "Compondre", "tabs_bar.federated_timeline": "Federada", "tabs_bar.home": "Inici", diff --git a/app/javascript/mastodon/locales/de.json b/app/javascript/mastodon/locales/de.json index 67a99b765..3133238cd 100644 --- a/app/javascript/mastodon/locales/de.json +++ b/app/javascript/mastodon/locales/de.json @@ -1,92 +1,94 @@ { "account.block": "@{name} blocken", - "account.block_domain": "Hide everything from {domain}", - "account.disclaimer_full": "Information below may reflect the user's profile incompletely.", + "account.block_domain": "Alles von {domain} verstecken", + "account.disclaimer_full": "Hier aufgeführten Informationen können unvollständig sein.", "account.edit_profile": "Profil bearbeiten", "account.follow": "Folgen", "account.followers": "Folgende", "account.follows": "Folgt", "account.follows_you": "Folgt dir", - "account.media": "Media", + "account.media": "Medien", "account.mention": "@{name} erwähnen", "account.mute": "@{name} stummschalten", "account.posts": "Beiträge", "account.report": "@{name} melden", - "account.requested": "Warte auf Erlaubnis", - "account.share": "Share @{name}'s profile", + "account.requested": "Warte auf Erlaubnis. Klicke zum Abbrechen", + "account.share": "Profil von @{name} teilen", "account.unblock": "@{name} entblocken", - "account.unblock_domain": "Unhide {domain}", + "account.unblock_domain": "{domain} wieder anzeigen", "account.unfollow": "Entfolgen", "account.unmute": "@{name} nicht mehr stummschalten", - "account.view_full_profile": "View full profile", + "account.view_full_profile": "Komplettes Profil anzeigen", "boost_modal.combo": "Du kannst {combo} drücken, um dies beim nächsten Mal zu überspringen", - "bundle_column_error.body": "Something went wrong while loading this component.", - "bundle_column_error.retry": "Try again", - "bundle_column_error.title": "Network error", - "bundle_modal_error.close": "Close", - "bundle_modal_error.message": "Something went wrong while loading this component.", - "bundle_modal_error.retry": "Try again", - "column.blocks": "Blockierte Benutzer", + "bundle_column_error.body": "Etwas ist beim Laden schiefgelaufen.", + "bundle_column_error.retry": "Erneut versuchen", + "bundle_column_error.title": "Netzwerkfehlher", + "bundle_modal_error.close": "Schließen", + "bundle_modal_error.message": "Etwas ist beim Laden schiefgelaufen.", + "bundle_modal_error.retry": "Erneut versuchen", + "column.blocks": "Blockierte Profile", "column.community": "Lokale Zeitleiste", "column.favourites": "Favoriten", "column.follow_requests": "Folgeanfragen", "column.home": "Startseite", - "column.mutes": "Stummgeschaltete Benutzer", + "column.mutes": "Stummgeschaltete Profile", "column.notifications": "Mitteilungen", "column.public": "Gesamtes bekanntes Netz", "column_back_button.label": "Zurück", - "column_header.hide_settings": "Hide settings", - "column_header.moveLeft_settings": "Move column to the left", - "column_header.moveRight_settings": "Move column to the right", - "column_header.pin": "Pin", - "column_header.show_settings": "Show settings", - "column_header.unpin": "Unpin", + "column_header.hide_settings": "Einstellungen verbergen", + "column_header.moveLeft_settings": "Spalte links verschieben", + "column_header.moveRight_settings": "Spalte rechts verschieben", + "column_header.pin": "Anheften", + "column_header.show_settings": "Einstellungen anzeigen", + "column_header.unpin": "Lösen", "column_subheading.navigation": "Navigation", - "column_subheading.settings": "Settings", - "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", + "column_subheading.settings": "Einstellungen", + "compose_form.lock_disclaimer": "Dein Profil ist nicht {locked}. Jeder kann dir jederzeit folgen, um deine privaten Beiträge einzusehen.", + "compose_form.lock_disclaimer.lock": "gesperrt", "compose_form.placeholder": "Worüber möchtest du schreiben?", - "compose_form.privacy_disclaimer": "Dein privater Status wird an die genannten Benutzer auf den Domains {domains} zugestellt. Vertraust du {domainsCount, plural, one {diesem Server} other {diesen Servern}}? Private Beiträge funktionieren nur auf Mastodon-Instanzen. Wenn {domains} {domainsCount, plural, one {keine Mastodon-Instanz ist} other {keine Mastodon-Instanzen sind}}, wird es dort kein Anzeichen geben, dass dein Beitrag privat ist und er könnte geteilt oder anderweitig für unerwünschte Empfänger sichtbar gemacht werden.", + "compose_form.privacy_disclaimer": "Dein privater Status wird an die genannten Profile auf den Domains {domains} zugestellt. Vertraust du {domainsCount, plural, one {diesem Server} other {diesen Servern}}? Private Beiträge funktionieren nur auf Mastodon-Instanzen. Wenn {domains} {domainsCount, plural, one {keine Mastodon-Instanz ist} other {keine Mastodon-Instanzen sind}}, wird es dort kein Anzeichen geben, dass dein Beitrag privat ist und er könnte geteilt oder anderweitig für unerwünschte Empfänger sichtbar gemacht werden.", "compose_form.publish": "Tröt", "compose_form.publish_loud": "{publish}!", "compose_form.sensitive": "Medien als heikel markieren", "compose_form.spoiler": "Text hinter Warnung verbergen", "compose_form.spoiler_placeholder": "Inhaltswarnung", - "confirmation_modal.cancel": "Cancel", - "confirmations.block.confirm": "Block", - "confirmations.block.message": "Are you sure you want to block {name}?", - "confirmations.delete.confirm": "Delete", - "confirmations.delete.message": "Are you sure you want to delete this status?", - "confirmations.domain_block.confirm": "Hide entire domain", - "confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.", - "confirmations.mute.confirm": "Mute", - "confirmations.mute.message": "Are you sure you want to mute {name}?", - "confirmations.unfollow.confirm": "Unfollow", - "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?", - "emoji_button.activity": "Activity", - "emoji_button.flags": "Flags", - "emoji_button.food": "Food & Drink", + "confirmation_modal.cancel": "Abbrechen", + "confirmations.block.confirm": "Blockieren", + "confirmations.block.message": "Bist du dir sicher, dass du {name} blockieren möchtest?", + "confirmations.delete.confirm": "Löschen", + "confirmations.delete.message": "Bist du dir sicher, dass du diesen Beitrag löschen möchstest?", + "confirmations.domain_block.confirm": "Die ganze Domain verbergen", + "confirmations.domain_block.message": "Bist du dir wirklich sicher, dass du die ganze Domain {domain} verbergen willst? In den meisten Fällen sind ein paar gezielte Blocks genug.", + "confirmations.mute.confirm": "Stummschalten", + "confirmations.mute.message": "Bist du dir sicher, dass du {name} stummschalten möchstest?", + "confirmations.unfollow.confirm": "Entfolgen", + "confirmations.unfollow.message": "Bist du dir sicher, dass du {name} entfolgen möchstest?", + "embed.instructions": "Du kannst diesen Beitrag auf deiner Webseite einbetten, in dem du den folgenden Code einfügst.", + "embed.preview": "So wird es aussehen:", + "emoji_button.activity": "Aktivitäten", + "emoji_button.flags": "Flaggen", + "emoji_button.food": "Essen und Trinken", "emoji_button.label": "Emoji einfügen", - "emoji_button.nature": "Nature", - "emoji_button.objects": "Objects", - "emoji_button.people": "People", - "emoji_button.search": "Search...", - "emoji_button.symbols": "Symbols", - "emoji_button.travel": "Travel & Places", + "emoji_button.nature": "Natur", + "emoji_button.objects": "Dinge", + "emoji_button.people": "Leute", + "emoji_button.search": "Suche…", + "emoji_button.symbols": "Symbole", + "emoji_button.travel": "Reise und Orte", "empty_column.community": "Die lokale Zeitleiste ist leer. Schreibe etwas öffentlich, um den Ball ins Rollen zu bringen!", "empty_column.hashtag": "Es gibt noch nichts unter diesem Hashtag.", - "empty_column.home": "Du folgst noch niemandem. Besuche {public} oder benutze die Suche, um zu starten oder andere Benutzer anzutreffen.", - "empty_column.home.inactivity": "Your home feed is empty. If you have been inactive for a while, it will be regenerated for you soon.", + "empty_column.home": "Du folgst noch niemandem. Besuche {public} oder benutze die Suche, um zu starten oder andere Profile zu finden.", + "empty_column.home.inactivity": "Deine Zeitleiste ist leer. Falls du eine längere Zeit inaktiv gewesen bist, wird sie für dich so schnell wie möglich wiedererstellt.", "empty_column.home.public_timeline": "die öffentliche Zeitleiste", "empty_column.notifications": "Du hast noch keine Mitteilungen. Interagiere mit anderen, um die Konversation zu starten.", - "empty_column.public": "Hier ist nichts zu sehen! Schreibe etwas öffentlich oder folge Benutzern von anderen Instanzen, um es aufzufüllen.", + "empty_column.public": "Hier ist nichts zu sehen! Schreibe etwas öffentlich oder folge Profilen von anderen Instanzen, um es aufzufüllen.", "follow_request.authorize": "Erlauben", "follow_request.reject": "Ablehnen", - "getting_started.appsshort": "Apps", - "getting_started.faq": "FAQ", + "getting_started.appsshort": "Anwendungen", + "getting_started.faq": "Häufig gestellte Fragen", "getting_started.heading": "Erste Schritte", "getting_started.open_source_notice": "Mastodon ist quelloffene Software. Du kannst auf {github} dazu beitragen oder Probleme melden.", - "getting_started.userguide": "User Guide", + "getting_started.userguide": "Bedienungsanleitung", "home.column_settings.advanced": "Fortgeschritten", "home.column_settings.basic": "Einfach", "home.column_settings.filter_regex": "Filter durch reguläre Ausdrücke", @@ -94,27 +96,27 @@ "home.column_settings.show_replies": "Antworten anzeigen", "home.settings": "Spalteneinstellungen", "lightbox.close": "Schließen", - "lightbox.next": "Next", - "lightbox.previous": "Previous", + "lightbox.next": "Weiter", + "lightbox.previous": "Zurück", "loading_indicator.label": "Lade…", "media_gallery.toggle_visible": "Sichtbarkeit einstellen", "missing_indicator.label": "Nicht gefunden", - "navigation_bar.blocks": "Blockierte Benutzer", + "navigation_bar.blocks": "Blockierte Profile", "navigation_bar.community_timeline": "Lokale Zeitleiste", "navigation_bar.edit_profile": "Profil bearbeiten", "navigation_bar.favourites": "Favoriten", "navigation_bar.follow_requests": "Folgeanfragen", "navigation_bar.info": "Erweiterte Informationen", "navigation_bar.logout": "Abmelden", - "navigation_bar.mutes": "Stummgeschaltete Benutzer", + "navigation_bar.mutes": "Stummgeschaltete Profile", "navigation_bar.preferences": "Einstellungen", "navigation_bar.public_timeline": "Föderierte Zeitleiste", "notification.favourite": "{name} favorisierte deinen Status", "notification.follow": "{name} folgt dir", "notification.mention": "{name} erwähnte dich", "notification.reblog": "{name} teilte deinen Status", - "notifications.clear": "Mitteilungen beseitigen", - "notifications.clear_confirmation": "Bist du sicher, dass du alle Mitteilungen beseitigen willst?", + "notifications.clear": "Mitteilungen löschen", + "notifications.clear_confirmation": "Bist du dir sicher, dass du alle Mitteilungen löschen möchstest?", "notifications.column_settings.alert": "Desktop-Benachrichtigungen", "notifications.column_settings.favourite": "Favorisierungen:", "notifications.column_settings.follow": "Neue Folgende:", @@ -124,28 +126,28 @@ "notifications.column_settings.reblog": "Geteilte Beiträge:", "notifications.column_settings.show": "In der Spalte anzeigen", "notifications.column_settings.sound": "Ton abspielen", - "onboarding.done": "Done", - "onboarding.next": "Next", - "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.", - "onboarding.page_four.home": "The home timeline shows posts from people you follow.", - "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.", - "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.", - "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}", - "onboarding.page_one.welcome": "Welcome to Mastodon!", - "onboarding.page_six.admin": "Your instance's admin is {admin}.", - "onboarding.page_six.almost_done": "Almost done...", - "onboarding.page_six.appetoot": "Bon Appetoot!", - "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.", - "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.", - "onboarding.page_six.guidelines": "community guidelines", - "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!", - "onboarding.page_six.various_app": "mobile apps", - "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.", - "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.", - "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.", - "onboarding.skip": "Skip", + "onboarding.done": "Fertig", + "onboarding.next": "Weiter", + "onboarding.page_five.public_timelines": "Die lokale Zeitleiste zeigt alle Beiträge von Leuten, die auch auf deiner Instanz {domain} sind. Das gesamte bekannte Netz zeigt Beiträge von allen, die kollektiv aus deiner Instanz heraus gefolgt werden. Zusammen werden die beiden Leisten auch öffentliche Zeitleisten genannt, durch sie kannst du viel neues entdecken.", + "onboarding.page_four.home": "Die Startseite zeigt dir Beiträge von Leuten, denen du folgst.", + "onboarding.page_four.notifications": "Wenn jemand mir dir interagiert, bekommst du eine Mitteilung.", + "onboarding.page_one.federation": "Mastodon ist ein soziales Netzwerk, das aus unabhängigen Servern besteht. Diese Server nennen wir auch Instanzen.", + "onboarding.page_one.handle": "Du bist auf der Instanz {domain}, also ist dein vollständiger Profilname im Netzwerk {handle}", + "onboarding.page_one.welcome": "Willkommen bei Mastodon!", + "onboarding.page_six.admin": "Für deine Instanz ist {admin} zuständig.", + "onboarding.page_six.almost_done": "Fast fertig…", + "onboarding.page_six.appetoot": "Guten Appetröt!", + "onboarding.page_six.apps_available": "Es gibt verschiedene {apps} für iOS, Android und andere Plattformen.", + "onboarding.page_six.github": "Mastodon ist freie, quelloffene Software. Du kannst auf {github} dazu beitragen oder Probleme melden.", + "onboarding.page_six.guidelines": "Richtlinien", + "onboarding.page_six.read_guidelines": "Bitte mach dich mit den {guidelines} von {domain} vertraut!", + "onboarding.page_six.various_app": "mobile Anwendungen", + "onboarding.page_three.profile": "Bearbeite dein Profil, um dein Bild, deinen Namen oder deine Beschreibung anzupassen. Dort findest du auch andere Einstellungen.", + "onboarding.page_three.search": "Benutze die Suchfunktion, um Leute oder Themen zu finden. Zum Beispiel, die Hashtags {illustration} oder {introductions}. Um eine Person zu finden, die auf einer anderen Instanz ist, benutze den vollständigen Profilnamen.", + "onboarding.page_two.compose": "Schreibe Beiträge aus der Schreiben-Spalte. Du kannst Bilder und kurze Videos hochladen, Sichtbarkeitseinstellungen ändern und Inhaltswarnungen hinzufügen.", + "onboarding.skip": "Überspringen", "privacy.change": "Privatsphäre des Status anpassen", - "privacy.direct.long": "Beitrag nur an erwähnte Benutzer", + "privacy.direct.long": "Beitrag nur an erwähnte Profile", "privacy.direct.short": "Direkt", "privacy.private.long": "Beitrag nur an Folgende", "privacy.private.short": "Privat", @@ -159,15 +161,17 @@ "report.target": "Melden", "search.placeholder": "Suche", "search_results.total": "{count, number} {count, plural, one {Ergebnis} other {Ergebnisse}}", - "standalone.public_title": "A look inside...", - "status.cannot_reblog": "This post cannot be boosted", + "standalone.public_title": "Vorschau…", + "status.cannot_reblog": "Dieser Beitrag kann nicht geteilt werden", "status.delete": "Löschen", + "status.embed": "Einbetten", "status.favourite": "Favorisieren", "status.load_more": "Weitere laden", "status.media_hidden": "Medien versteckt", "status.mention": "Erwähnen", - "status.mute_conversation": "Mute conversation", + "status.mute_conversation": "Thread stummschalten", "status.open": "Öffnen", + "status.pin": "Auf dem Profil anheften", "status.reblog": "Teilen", "status.reblogged_by": "{name} teilte", "status.reply": "Antworten", @@ -175,13 +179,14 @@ "status.report": "@{name} melden", "status.sensitive_toggle": "Klicke, um sie zu sehen", "status.sensitive_warning": "Heikle Inhalte", - "status.share": "Share", + "status.share": "Teilen", "status.show_less": "Weniger anzeigen", "status.show_more": "Mehr anzeigen", - "status.unmute_conversation": "Unmute conversation", + "status.unmute_conversation": "Stummschaltung von Thread aufheben", + "status.unpin": "Vom Profil lösen", "tabs_bar.compose": "Schreiben", "tabs_bar.federated_timeline": "Föderation", - "tabs_bar.home": "Home", + "tabs_bar.home": "Startseite", "tabs_bar.local_timeline": "Lokal", "tabs_bar.notifications": "Mitteilungen", "upload_area.title": "Hereinziehen zum Hochladen", diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json index e5d541cd6..89f74a56b 100644 --- a/app/javascript/mastodon/locales/defaultMessages.json +++ b/app/javascript/mastodon/locales/defaultMessages.json @@ -189,6 +189,18 @@ { "defaultMessage": "Unmute conversation", "id": "status.unmute_conversation" + }, + { + "defaultMessage": "Pin on profile", + "id": "status.pin" + }, + { + "defaultMessage": "Unpin from profile", + "id": "status.unpin" + }, + { + "defaultMessage": "Embed", + "id": "status.embed" } ], "path": "app/javascript/mastodon/components/status_action_bar.json" @@ -424,7 +436,7 @@ "id": "account.follow" }, { - "defaultMessage": "Awaiting approval", + "defaultMessage": "Awaiting approval. Click to cancel follow request", "id": "account.requested" }, { @@ -1035,6 +1047,18 @@ { "defaultMessage": "Share", "id": "status.share" + }, + { + "defaultMessage": "Pin on profile", + "id": "status.pin" + }, + { + "defaultMessage": "Unpin from profile", + "id": "status.unpin" + }, + { + "defaultMessage": "Embed", + "id": "status.embed" } ], "path": "app/javascript/mastodon/features/status/components/action_bar.json" @@ -1111,6 +1135,23 @@ { "descriptors": [ { + "defaultMessage": "Embed", + "id": "status.embed" + }, + { + "defaultMessage": "Embed this status on your website by copying the code below.", + "id": "embed.instructions" + }, + { + "defaultMessage": "Here is what it will look like:", + "id": "embed.preview" + } + ], + "path": "app/javascript/mastodon/features/ui/components/embed_modal.json" + }, + { + "descriptors": [ + { "defaultMessage": "Close", "id": "lightbox.close" }, diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 2ea2062d3..6d9b9c208 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -12,7 +12,7 @@ "account.mute": "Mute @{name}", "account.posts": "Posts", "account.report": "Report @{name}", - "account.requested": "Awaiting approval", + "account.requested": "Awaiting approval. Click to cancel follow request", "account.share": "Share @{name}'s profile", "account.unblock": "Unblock @{name}", "account.unblock_domain": "Unhide {domain}", @@ -63,6 +63,8 @@ "confirmations.mute.message": "Are you sure you want to mute {name}?", "confirmations.unfollow.confirm": "Unfollow", "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?", + "embed.instructions": "Embed this status on your website by copying the code below.", + "embed.preview": "Here is what it will look like:", "emoji_button.activity": "Activity", "emoji_button.flags": "Flags", "emoji_button.food": "Food & Drink", @@ -162,12 +164,14 @@ "standalone.public_title": "A look inside...", "status.cannot_reblog": "This post cannot be boosted", "status.delete": "Delete", + "status.embed": "Embed", "status.favourite": "Favourite", "status.load_more": "Load more", "status.media_hidden": "Media hidden", "status.mention": "Mention @{name}", "status.mute_conversation": "Mute conversation", "status.open": "Expand this status", + "status.pin": "Pin on profile", "status.reblog": "Boost", "status.reblogged_by": "{name} boosted", "status.reply": "Reply", @@ -179,6 +183,7 @@ "status.show_less": "Show less", "status.show_more": "Show more", "status.unmute_conversation": "Unmute conversation", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "Compose", "tabs_bar.federated_timeline": "Federated", "tabs_bar.home": "Home", diff --git a/app/javascript/mastodon/locales/eo.json b/app/javascript/mastodon/locales/eo.json index 960d747ec..d828d0858 100644 --- a/app/javascript/mastodon/locales/eo.json +++ b/app/javascript/mastodon/locales/eo.json @@ -63,6 +63,8 @@ "confirmations.mute.message": "Are you sure you want to mute {name}?", "confirmations.unfollow.confirm": "Unfollow", "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?", + "embed.instructions": "Embed this status on your website by copying the code below.", + "embed.preview": "Here is what it will look like:", "emoji_button.activity": "Activity", "emoji_button.flags": "Flags", "emoji_button.food": "Food & Drink", @@ -162,12 +164,14 @@ "standalone.public_title": "A look inside...", "status.cannot_reblog": "This post cannot be boosted", "status.delete": "Forigi", + "status.embed": "Embed", "status.favourite": "Favori", "status.load_more": "Load more", "status.media_hidden": "Media hidden", "status.mention": "Mencii @{name}", "status.mute_conversation": "Mute conversation", "status.open": "Expand this status", + "status.pin": "Pin on profile", "status.reblog": "Diskonigi", "status.reblogged_by": "{name} diskonigita", "status.reply": "Respondi", @@ -179,6 +183,7 @@ "status.show_less": "Show less", "status.show_more": "Show more", "status.unmute_conversation": "Unmute conversation", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "Ekskribi", "tabs_bar.federated_timeline": "Federated", "tabs_bar.home": "Hejmo", diff --git a/app/javascript/mastodon/locales/es.json b/app/javascript/mastodon/locales/es.json index 212d16639..d35eb84e7 100644 --- a/app/javascript/mastodon/locales/es.json +++ b/app/javascript/mastodon/locales/es.json @@ -63,6 +63,8 @@ "confirmations.mute.message": "Are you sure you want to mute {name}?", "confirmations.unfollow.confirm": "Unfollow", "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?", + "embed.instructions": "Embed this status on your website by copying the code below.", + "embed.preview": "Here is what it will look like:", "emoji_button.activity": "Activity", "emoji_button.flags": "Flags", "emoji_button.food": "Food & Drink", @@ -162,12 +164,14 @@ "standalone.public_title": "A look inside...", "status.cannot_reblog": "This post cannot be boosted", "status.delete": "Borrar", + "status.embed": "Embed", "status.favourite": "Favorito", "status.load_more": "Load more", "status.media_hidden": "Media hidden", "status.mention": "Mencionar", "status.mute_conversation": "Mute conversation", "status.open": "Expandir estado", + "status.pin": "Pin on profile", "status.reblog": "Retoot", "status.reblogged_by": "Retooteado por {name}", "status.reply": "Responder", @@ -179,6 +183,7 @@ "status.show_less": "Mostrar menos", "status.show_more": "Mostrar más", "status.unmute_conversation": "Unmute conversation", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "Redactar", "tabs_bar.federated_timeline": "Federated", "tabs_bar.home": "Inicio", diff --git a/app/javascript/mastodon/locales/fa.json b/app/javascript/mastodon/locales/fa.json index 5ada62f93..d05b26eb9 100644 --- a/app/javascript/mastodon/locales/fa.json +++ b/app/javascript/mastodon/locales/fa.json @@ -63,6 +63,8 @@ "confirmations.mute.message": "آیا واقعاً میخواهید {name} را بیصدا کنید؟", "confirmations.unfollow.confirm": "لغو پیگیری", "confirmations.unfollow.message": "آیا واقعاً میخواهید به پیگیری از {name} پایان دهید؟", + "embed.instructions": "برای جاگذاری این نوشته در سایت خودتان، کد زیر را کپی کنید.", + "embed.preview": "نوشتهٔ جاگذاریشده این گونه به نظر خواهد رسید:", "emoji_button.activity": "فعالیت", "emoji_button.flags": "پرچمها", "emoji_button.food": "غذا و نوشیدنی", @@ -162,12 +164,14 @@ "standalone.public_title": "نگاهی به کاربران این سرور...", "status.cannot_reblog": "این نوشته را نمیشود بازبوقید", "status.delete": "پاککردن", + "status.embed": "جاگذاری", "status.favourite": "پسندیدن", "status.load_more": "بیشتر نشان بده", "status.media_hidden": "تصویر پنهان شده", "status.mention": "نامبردن از @{name}", "status.mute_conversation": "بیصداکردن گفتگو", "status.open": "این نوشته را باز کن", + "status.pin": "نوشتهٔ ثابت نمایه", "status.reblog": "بازبوقیدن", "status.reblogged_by": "{name} بازبوقید", "status.reply": "پاسخ", @@ -179,6 +183,7 @@ "status.show_less": "نهفتن", "status.show_more": "نمایش", "status.unmute_conversation": "باصداکردن گفتگو", + "status.unpin": "برداشتن نوشتهٔ ثابت نمایه", "tabs_bar.compose": "بنویسید", "tabs_bar.federated_timeline": "همگانی", "tabs_bar.home": "خانه", diff --git a/app/javascript/mastodon/locales/fi.json b/app/javascript/mastodon/locales/fi.json index cb9e9c2a6..926a57ff1 100644 --- a/app/javascript/mastodon/locales/fi.json +++ b/app/javascript/mastodon/locales/fi.json @@ -63,6 +63,8 @@ "confirmations.mute.message": "Are you sure you want to mute {name}?", "confirmations.unfollow.confirm": "Unfollow", "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?", + "embed.instructions": "Embed this status on your website by copying the code below.", + "embed.preview": "Here is what it will look like:", "emoji_button.activity": "Activity", "emoji_button.flags": "Flags", "emoji_button.food": "Food & Drink", @@ -162,12 +164,14 @@ "standalone.public_title": "A look inside...", "status.cannot_reblog": "This post cannot be boosted", "status.delete": "Poista", + "status.embed": "Embed", "status.favourite": "Tykkää", "status.load_more": "Load more", "status.media_hidden": "Media hidden", "status.mention": "Mainitse @{name}", "status.mute_conversation": "Mute conversation", "status.open": "Expand this status", + "status.pin": "Pin on profile", "status.reblog": "Buustaa", "status.reblogged_by": "{name} buustasi", "status.reply": "Vastaa", @@ -179,6 +183,7 @@ "status.show_less": "Show less", "status.show_more": "Show more", "status.unmute_conversation": "Unmute conversation", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "Luo", "tabs_bar.federated_timeline": "Federated", "tabs_bar.home": "Koti", diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json index f3f0d0463..fa8ea6c73 100644 --- a/app/javascript/mastodon/locales/fr.json +++ b/app/javascript/mastodon/locales/fr.json @@ -20,11 +20,11 @@ "account.unmute": "Ne plus masquer", "account.view_full_profile": "Afficher le profil complet", "boost_modal.combo": "Vous pouvez appuyer sur {combo} pour pouvoir passer ceci, la prochaine fois", - "bundle_column_error.body": "Une erreur s'est produite lors du chargement de ce composant.", + "bundle_column_error.body": "Une erreur s’est produite lors du chargement de ce composant.", "bundle_column_error.retry": "Réessayer", "bundle_column_error.title": "Erreur réseau", "bundle_modal_error.close": "Fermer", - "bundle_modal_error.message": "Une erreur s'est produite lors du chargement de ce composant.", + "bundle_modal_error.message": "Une erreur s’est produite lors du chargement de ce composant.", "bundle_modal_error.retry": "Réessayer", "column.blocks": "Comptes bloqués", "column.community": "Fil public local", @@ -43,12 +43,12 @@ "column_header.unpin": "Retirer", "column_subheading.navigation": "Navigation", "column_subheading.settings": "Paramètres", - "compose_form.lock_disclaimer": "Votre compte n’est pas {locked}. Tout le monde peut vous suivre et voir vos pouets restreints.", + "compose_form.lock_disclaimer": "Votre compte n’est pas {locked}. Tout le monde peut vous suivre et voir vos pouets privés.", "compose_form.lock_disclaimer.lock": "verrouillé", "compose_form.placeholder": "Qu’avez-vous en tête ?", "compose_form.privacy_disclaimer": "Votre statut privé va être transmis aux personnes mentionnées sur {domains}. Avez-vous confiance en {domainsCount, plural, one {ce serveur} other {ces serveurs}} pour ne pas divulguer votre statut ? Les statuts privés ne fonctionnent que sur les instances de Mastodon. Si {domains} {domainsCount, plural, one {n’est pas une instance de Mastodon} other {ne sont pas des instances de Mastodon}}, il n’y aura aucune indication que votre statut est privé, et il pourrait être partagé ou rendu visible d’une autre manière à d’autres personnes imprévues.", "compose_form.publish": "Pouet ", - "compose_form.publish_loud": "{publish}!", + "compose_form.publish_loud": "{publish} !", "compose_form.sensitive": "Marquer le média comme sensible", "compose_form.spoiler": "Masquer le texte derrière un avertissement", "compose_form.spoiler_placeholder": "Écrivez ici votre avertissement", @@ -62,7 +62,9 @@ "confirmations.mute.confirm": "Masquer", "confirmations.mute.message": "Confirmez vous le masquage de {name} ?", "confirmations.unfollow.confirm": "Ne plus suivre", - "confirmations.unfollow.message": "Vous voulez-vous arrêter de suivre {name} ?", + "confirmations.unfollow.message": "Vous voulez-vous arrêter de suivre {name} ?", + "embed.instructions": "Embed this status on your website by copying the code below.", + "embed.preview": "Here is what it will look like:", "emoji_button.activity": "Activités", "emoji_button.flags": "Drapeaux", "emoji_button.food": "Boire et manger", @@ -134,8 +136,8 @@ "onboarding.page_one.welcome": "Bienvenue sur Mastodon !", "onboarding.page_six.admin": "L’administrateur⋅trice de votre instance est {admin}", "onboarding.page_six.almost_done": "Nous y sommes presque…", - "onboarding.page_six.appetoot": "Bon Appétoot!", - "onboarding.page_six.apps_available": "De nombreuses {apps} sont disponibles pour iOS, Android et autres. Et maintenant… Bon Appétoot!", + "onboarding.page_six.appetoot": "Bon appouétit !", + "onboarding.page_six.apps_available": "De nombreuses {apps} sont disponibles pour iOS, Android et autres. Et maintenant… Bon appouétit !", "onboarding.page_six.github": "Mastodon est un logiciel libre, gratuit et open-source. Vous pouvez rapporter des bogues, suggérer des fonctionnalités, ou contribuer à son développement sur {github}.", "onboarding.page_six.guidelines": "règles de la communauté", "onboarding.page_six.read_guidelines": "S’il vous plaît, n’oubliez pas de lire les {guidelines} !", @@ -159,15 +161,17 @@ "report.target": "Signalement", "search.placeholder": "Rechercher", "search_results.total": "{count, number} {count, plural, one {résultat} other {résultats}}", - "standalone.public_title": "Coup d'œil", + "standalone.public_title": "Jeter un coup d’œil…", "status.cannot_reblog": "Cette publication ne peut être boostée", "status.delete": "Effacer", + "status.embed": "Embed", "status.favourite": "Ajouter aux favoris", "status.load_more": "Charger plus", "status.media_hidden": "Média caché", "status.mention": "Mentionner", "status.mute_conversation": "Masquer la conversation", "status.open": "Déplier ce statut", + "status.pin": "Épingler sur le profil", "status.reblog": "Partager", "status.reblogged_by": "{name} a partagé :", "status.reply": "Répondre", @@ -179,6 +183,7 @@ "status.show_less": "Replier", "status.show_more": "Déplier", "status.unmute_conversation": "Ne plus masquer la conversation", + "status.unpin": "Retirer du profil", "tabs_bar.compose": "Composer", "tabs_bar.federated_timeline": "Fil public global", "tabs_bar.home": "Accueil", diff --git a/app/javascript/mastodon/locales/he.json b/app/javascript/mastodon/locales/he.json index 34266d8e1..9ef933108 100644 --- a/app/javascript/mastodon/locales/he.json +++ b/app/javascript/mastodon/locales/he.json @@ -63,6 +63,8 @@ "confirmations.mute.message": "להשתיק את {name}?", "confirmations.unfollow.confirm": "Unfollow", "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?", + "embed.instructions": "Embed this status on your website by copying the code below.", + "embed.preview": "Here is what it will look like:", "emoji_button.activity": "פעילות", "emoji_button.flags": "דגלים", "emoji_button.food": "אוכל ושתיה", @@ -162,12 +164,14 @@ "standalone.public_title": "A look inside...", "status.cannot_reblog": "לא ניתן להדהד הודעה זו", "status.delete": "מחיקה", + "status.embed": "Embed", "status.favourite": "חיבוב", "status.load_more": "עוד", "status.media_hidden": "מדיה מוסתרת", "status.mention": "פניה אל @{name}", "status.mute_conversation": "השתקת שיחה", "status.open": "הרחבת הודעה", + "status.pin": "Pin on profile", "status.reblog": "הדהוד", "status.reblogged_by": "הודהד על ידי {name}", "status.reply": "תגובה", @@ -179,6 +183,7 @@ "status.show_less": "הראה פחות", "status.show_more": "הראה יותר", "status.unmute_conversation": "הסרת השתקת שיחה", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "חיבור", "tabs_bar.federated_timeline": "ציר זמן בין-קהילתי", "tabs_bar.home": "בבית", diff --git a/app/javascript/mastodon/locales/hr.json b/app/javascript/mastodon/locales/hr.json index f69b096d4..f301723cf 100644 --- a/app/javascript/mastodon/locales/hr.json +++ b/app/javascript/mastodon/locales/hr.json @@ -1,7 +1,7 @@ { "account.block": "Blokiraj @{name}", "account.block_domain": "Sakrij sve sa {domain}", - "account.disclaimer_full": "Information below may reflect the user's profile incompletely.", + "account.disclaimer_full": "Ovaj korisnik je sa druge instance. Ovaj broj bi mogao biti veći.", "account.edit_profile": "Uredi profil", "account.follow": "Slijedi", "account.followers": "Sljedbenici", @@ -15,7 +15,7 @@ "account.requested": "Čeka pristanak", "account.share": "Share @{name}'s profile", "account.unblock": "Deblokiraj @{name}", - "account.unblock_domain": "Otkrij {domain}", + "account.unblock_domain": "Poništi sakrivanje {domain}", "account.unfollow": "Prestani slijediti", "account.unmute": "Poništi utišavanje @{name}", "account.view_full_profile": "View full profile", @@ -43,7 +43,7 @@ "column_header.unpin": "Unpin", "column_subheading.navigation": "Navigacija", "column_subheading.settings": "Postavke", - "compose_form.lock_disclaimer": "Tvoj račun nije {locked}. Svatko te može slijediti i vidjeti tvoje postove namijenjene samo sljedbenicima.", + "compose_form.lock_disclaimer": "Tvoj račun nije {locked}. Svatko te može slijediti kako bi vidio postove namijenjene samo tvojim sljedbenicima.", "compose_form.lock_disclaimer.lock": "zaključan", "compose_form.placeholder": "Što ti je na umu?", "compose_form.privacy_disclaimer": "Tvoj privatni status će biti dostavljen spomenutim korisnicima na {domains}. Vjeruješ li {domainsCount, plural, one {that server} drugim {those servers}}? Privatnost postova radi samo na Mastodon instancama. Ako {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, neće biti indikacije da je tvoj post privatan, i mogao bi biti podignut ili biti učinjen vidljivim na drugi način neželjenim primateljima.", @@ -54,29 +54,32 @@ "compose_form.spoiler_placeholder": "Upozorenje o sadržaju", "confirmation_modal.cancel": "Otkaži", "confirmations.block.confirm": "Blokiraj", - "confirmations.block.message": "Jesi li siguran da želiš blokirati {name}?", + "confirmations.block.message": "Želiš li sigurno blokirati {name}?", "confirmations.delete.confirm": "Obriši", - "confirmations.delete.message": "Jesi li siguran da želiš obrisati ovaj status?", + "confirmations.delete.message": "Želiš li stvarno obrisati ovaj status?", "confirmations.domain_block.confirm": "Sakrij cijelu domenu", - "confirmations.domain_block.message": "Jesi li zaista, zaista siguran da želiš blokirati sve sa {domain}? U većini slučajeva nekoliko ciljanih blokiranja ili utišavanja je dostatno i poželjnije.", + "confirmations.domain_block.message": "Jesi li zaista, zaista siguran da želiš potpuno blokirati {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.", "confirmations.mute.confirm": "Utišaj", "confirmations.mute.message": "Jesi li siguran da želiš utišati {name}?", + "confirmations.mute.message": "Jesi li siguran da želiš utišati {name}?", "confirmations.unfollow.confirm": "Unfollow", "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?", + "embed.instructions": "Embed this status on your website by copying the code below.", + "embed.preview": "Here is what it will look like:", "emoji_button.activity": "Aktivnost", "emoji_button.flags": "Zastave", "emoji_button.food": "Hrana & Piće", "emoji_button.label": "Umetni smajlije", - "emoji_button.nature": "Nature", + "emoji_button.nature": "Priroda", "emoji_button.objects": "Objekti", "emoji_button.people": "Ljudi", "emoji_button.search": "Traži...", "emoji_button.symbols": "Simboli", - "emoji_button.travel": "Putovanja i Mjesta", + "emoji_button.travel": "Putovanja & Mjesta", "empty_column.community": "Lokalni timeline je prazan. Napiši nešto javno kako bi pokrenuo stvari!", "empty_column.hashtag": "Još ne postoji ništa s ovim hashtagom.", "empty_column.home": "Još ne slijediš nikoga. Posjeti {public} ili koristi tražilicu kako bi počeo i upoznao druge korisnike.", - "empty_column.home.inactivity": "Tvoj home feed je prazan. Ako si neko vrijeme bio neaktivan, regenerirat će se uskoro.", + "empty_column.home.inactivity": "Tvoj home feed je prazan. Ako si neko vrijeme bio neaktivan, uskoro ćese regenerirati.", "empty_column.home.public_timeline": "javni timeline", "empty_column.notifications": "Još nemaš notifikacija. Komuniciraj sa drugima kako bi započeo razgovor.", "empty_column.public": "Ovdje nema ništa! Napiši nešto javno, ili ručno slijedi korisnike sa drugih instanci kako bi popunio", @@ -86,11 +89,11 @@ "getting_started.faq": "FAQ", "getting_started.heading": "Počnimo", "getting_started.open_source_notice": "Mastodon je softver otvorenog koda. Možeš pridonijeti ili prijaviti probleme na GitHubu {github}.", - "getting_started.userguide": "Vodič za korisnike", + "getting_started.userguide": "Upute za korištenje", "home.column_settings.advanced": "Napredno", "home.column_settings.basic": "Osnovno", "home.column_settings.filter_regex": "Filtriraj s regularnim izrazima", - "home.column_settings.show_reblogs": "Pokaži boosts", + "home.column_settings.show_reblogs": "Pokaži boostove", "home.column_settings.show_replies": "Pokaži odgovore", "home.settings": "Postavke Stupca", "lightbox.close": "Zatvori", @@ -111,7 +114,7 @@ "navigation_bar.public_timeline": "Federalni timeline", "notification.favourite": "{name} je lajkao tvoj status", "notification.follow": "{name} te sada slijedi", - "notification.mention": "{name} mentioned you", + "notification.mention": "{name} te je spomenuo", "notification.reblog": "{name} je podigao tvoj status", "notifications.clear": "Očisti notifikacije", "notifications.clear_confirmation": "Želiš li zaista obrisati sve svoje notifikacije?", @@ -121,28 +124,28 @@ "notifications.column_settings.mention": "Spominjanja:", "notifications.column_settings.push": "Push notifications", "notifications.column_settings.push_meta": "This device", - "notifications.column_settings.reblog": "Boosts:", + "notifications.column_settings.reblog": "Boostovi:", "notifications.column_settings.show": "Prikaži u stupcu", "notifications.column_settings.sound": "Sviraj zvuk", "onboarding.done": "Učinjeno", - "onboarding.next": "Sljedeća", - "onboarding.page_five.public_timelines": "The local timeline prikazuje javne postove svih na {domain}. Federalni timeline pokazuje javne postove svih sa {domain} domena koje slijediš. To je sjajan način da otkriješ nove ljude.", - "onboarding.page_four.home": "The home timeline prikazuje samo postove ljudi koje slijediš.", - "onboarding.page_four.notifications": "Stupac notifikacija pokazuje kada je netko u interakciji s tobom.", - "onboarding.page_one.federation": "Mastodon je mreža nezavisnih servera udruženih kako bi stvorili veću socijalnu mrežu. Te servere zovemo instance.", - "onboarding.page_one.handle": "Ti si na {domain}, tako da je tvoj potpuni opis {handle}", - "onboarding.page_one.welcome": "Dobro došli u Mastodon!", + "onboarding.next": "Sljedeće", + "onboarding.page_five.public_timelines": "Lokalni timeline prikazuje javne postove sviju od svakog na {domain}. Federalni timeline prikazuje javne postove svakog koga ljudi na {domain} slijede. To su Javni Timelineovi, sjajan način za otkriti nove ljude.", + "onboarding.page_four.home": "The home timeline prikazuje postove ljudi koje slijediš.", + "onboarding.page_four.notifications": "Stupac za notifikacije pokazuje poruke drugih upućene tebi.", + "onboarding.page_one.federation": "Mastodon čini mreža neovisnih servera udruženih u jednu veću socialnu mrežu. Te servere nazivamo instancama.", + "onboarding.page_one.handle": "Ti si na {domain}, i tvoja puna handle je {handle}", + "onboarding.page_one.welcome": "Dobro došli na Mastodon!", "onboarding.page_six.admin": "Administrator tvoje instance je {admin}.", "onboarding.page_six.almost_done": "Još malo pa gotovo...", "onboarding.page_six.appetoot": "Živjeli!", "onboarding.page_six.apps_available": "Postoje {apps} dostupne za iOS, Android i druge platforme.", - "onboarding.page_six.github": "Mastodon je besplatan softver otvorenog koda. Možeš prijaviti greške, zahtijevati mogućnosti, ili pridonijeti kodu na {github}.", + "onboarding.page_six.github": "Mastodon je besplatan softver otvorenog koda. You can report bugs, request features, or contribute to the code on {github}.", "onboarding.page_six.guidelines": "smjernice zajednice", - "onboarding.page_six.read_guidelines": "Molimo, pročitaj {domain}'s {guidelines}!", + "onboarding.page_six.read_guidelines": "Molimo pročitaj {domain}'s {guidelines}!", "onboarding.page_six.various_app": "mobilne aplikacije", - "onboarding.page_three.profile": "Uredi svoj profil mijenjanjem avatara, biografije i imena koje će biti prikazano. Naći ćeš i druge korisne postavke.", - "onboarding.page_three.search": "Koristi tražilicu kako bi pronašao ljude i sadržaj sa određenim hashtagovima, kao što su {illustration} i {introductions}. Da bi našao osobu koja nije na ovoj instanci, upotrijebi njihov puni opis.", - "onboarding.page_two.compose": "Piši postove u stupcu za njihovo sastavljanje. Možeš uploadati slike, promijeniti postavke privatnosti, i dodati upozorenja o sadržaju s ikonama ispod.", + "onboarding.page_three.profile": "Uredi svoj profil promjenom svog avatara, biografije, i imena. Ovdje ćeš isto tako pronaći i druge postavke.", + "onboarding.page_three.search": "Koristi tražilicu kako bi pronašao ljude i tražio hashtags, kao što su {illustration} i {introductions}. Kako bi pronašao osobu koja nije na ovoj instanci, upotrijebi njen pun handle.", + "onboarding.page_two.compose": "Piši postove u stupcu za sastavljanje. Možeš uploadati slike, promijeniti postavke privatnosti, i dodati upozorenja o sadržaju s ikonama ispod.", "onboarding.skip": "Preskoči", "privacy.change": "Podesi status privatnosti", "privacy.direct.long": "Prikaži samo spomenutim korisnicima", @@ -160,14 +163,16 @@ "search.placeholder": "Traži", "search_results.total": "{count, number} {count, plural, one {result} other {results}}", "standalone.public_title": "A look inside...", - "status.cannot_reblog": "Ovaj post ne može biti podignut", + "status.cannot_reblog": "Ovaj post ne može biti boostan", "status.delete": "Obriši", + "status.embed": "Embed", "status.favourite": "Označi omiljenim", "status.load_more": "Učitaj više", "status.media_hidden": "Sakriven media sadržaj", "status.mention": "Spomeni @{name}", "status.mute_conversation": "Utišaj razgovor", "status.open": "Proširi ovaj status", + "status.pin": "Pin on profile", "status.reblog": "Podigni", "status.reblogged_by": "{name} je podigao", "status.reply": "Odgovori", @@ -179,6 +184,7 @@ "status.show_less": "Pokaži manje", "status.show_more": "Pokaži više", "status.unmute_conversation": "Poništi utišavanje razgovora", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "Sastavi", "tabs_bar.federated_timeline": "Federalni", "tabs_bar.home": "Dom", @@ -191,5 +197,5 @@ "video_player.expand": "Proširi video", "video_player.toggle_sound": "Toggle zvuk", "video_player.toggle_visible": "Preklopi vidljivost", - "video_player.video_error": "Video nije mogao biti prikazan" + "video_player.video_error": "Video ne može biti reproduciran" } diff --git a/app/javascript/mastodon/locales/hu.json b/app/javascript/mastodon/locales/hu.json index 4d2a50963..a708ec638 100644 --- a/app/javascript/mastodon/locales/hu.json +++ b/app/javascript/mastodon/locales/hu.json @@ -63,6 +63,8 @@ "confirmations.mute.message": "Are you sure you want to mute {name}?", "confirmations.unfollow.confirm": "Unfollow", "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?", + "embed.instructions": "Embed this status on your website by copying the code below.", + "embed.preview": "Here is what it will look like:", "emoji_button.activity": "Activity", "emoji_button.flags": "Flags", "emoji_button.food": "Food & Drink", @@ -162,12 +164,14 @@ "standalone.public_title": "A look inside...", "status.cannot_reblog": "This post cannot be boosted", "status.delete": "Törlés", + "status.embed": "Embed", "status.favourite": "Kedvenc", "status.load_more": "Load more", "status.media_hidden": "Media hidden", "status.mention": "Említés", "status.mute_conversation": "Mute conversation", "status.open": "Expand this status", + "status.pin": "Pin on profile", "status.reblog": "Reblog", "status.reblogged_by": "{name} reblogolta", "status.reply": "Válasz", @@ -179,6 +183,7 @@ "status.show_less": "Show less", "status.show_more": "Show more", "status.unmute_conversation": "Unmute conversation", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "Összeállítás", "tabs_bar.federated_timeline": "Federated", "tabs_bar.home": "Kezdőlap", diff --git a/app/javascript/mastodon/locales/id.json b/app/javascript/mastodon/locales/id.json index 532739e3c..d71e293e8 100644 --- a/app/javascript/mastodon/locales/id.json +++ b/app/javascript/mastodon/locales/id.json @@ -63,6 +63,8 @@ "confirmations.mute.message": "Apa anda yakin ingin membisukan {name}?", "confirmations.unfollow.confirm": "Unfollow", "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?", + "embed.instructions": "Embed this status on your website by copying the code below.", + "embed.preview": "Here is what it will look like:", "emoji_button.activity": "Aktivitas", "emoji_button.flags": "Bendera", "emoji_button.food": "Makanan & Minuman", @@ -162,12 +164,14 @@ "standalone.public_title": "A look inside...", "status.cannot_reblog": "This post cannot be boosted", "status.delete": "Hapus", + "status.embed": "Embed", "status.favourite": "Difavoritkan", "status.load_more": "Tampilkan semua", "status.media_hidden": "Media disembunyikan", "status.mention": "Balasan @{name}", "status.mute_conversation": "Mute conversation", "status.open": "Tampilkan status ini", + "status.pin": "Pin on profile", "status.reblog": "Boost", "status.reblogged_by": "di-boost {name}", "status.reply": "Balas", @@ -179,6 +183,7 @@ "status.show_less": "Tampilkan lebih sedikit", "status.show_more": "Tampilkan semua", "status.unmute_conversation": "Unmute conversation", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "Tulis", "tabs_bar.federated_timeline": "Gabungan", "tabs_bar.home": "Beranda", diff --git a/app/javascript/mastodon/locales/io.json b/app/javascript/mastodon/locales/io.json index a5e363e40..5df5c59a1 100644 --- a/app/javascript/mastodon/locales/io.json +++ b/app/javascript/mastodon/locales/io.json @@ -63,6 +63,8 @@ "confirmations.mute.message": "Are you sure you want to mute {name}?", "confirmations.unfollow.confirm": "Unfollow", "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?", + "embed.instructions": "Embed this status on your website by copying the code below.", + "embed.preview": "Here is what it will look like:", "emoji_button.activity": "Activity", "emoji_button.flags": "Flags", "emoji_button.food": "Food & Drink", @@ -162,12 +164,14 @@ "standalone.public_title": "A look inside...", "status.cannot_reblog": "This post cannot be boosted", "status.delete": "Efacar", + "status.embed": "Embed", "status.favourite": "Favorizar", "status.load_more": "Kargar pluse", "status.media_hidden": "Kontenajo celita", "status.mention": "Mencionar @{name}", "status.mute_conversation": "Mute conversation", "status.open": "Detaligar ca mesajo", + "status.pin": "Pin on profile", "status.reblog": "Repetar", "status.reblogged_by": "{name} repetita", "status.reply": "Respondar", @@ -179,6 +183,7 @@ "status.show_less": "Montrar mine", "status.show_more": "Montrar plue", "status.unmute_conversation": "Unmute conversation", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "Kompozar", "tabs_bar.federated_timeline": "Federata", "tabs_bar.home": "Hemo", diff --git a/app/javascript/mastodon/locales/it.json b/app/javascript/mastodon/locales/it.json index 329eb82ca..eec35a70c 100644 --- a/app/javascript/mastodon/locales/it.json +++ b/app/javascript/mastodon/locales/it.json @@ -63,6 +63,8 @@ "confirmations.mute.message": "Are you sure you want to mute {name}?", "confirmations.unfollow.confirm": "Unfollow", "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?", + "embed.instructions": "Embed this status on your website by copying the code below.", + "embed.preview": "Here is what it will look like:", "emoji_button.activity": "Activity", "emoji_button.flags": "Flags", "emoji_button.food": "Food & Drink", @@ -162,12 +164,14 @@ "standalone.public_title": "A look inside...", "status.cannot_reblog": "This post cannot be boosted", "status.delete": "Elimina", + "status.embed": "Embed", "status.favourite": "Apprezzato", "status.load_more": "Mostra di più", "status.media_hidden": "Allegato nascosto", "status.mention": "Nomina @{name}", "status.mute_conversation": "Mute conversation", "status.open": "Espandi questo post", + "status.pin": "Pin on profile", "status.reblog": "Condividi", "status.reblogged_by": "{name} ha condiviso", "status.reply": "Rispondi", @@ -179,6 +183,7 @@ "status.show_less": "Mostra meno", "status.show_more": "Mostra di più", "status.unmute_conversation": "Unmute conversation", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "Scrivi", "tabs_bar.federated_timeline": "Federazione", "tabs_bar.home": "Home", diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json index 4c98086bb..560d2b668 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -63,6 +63,8 @@ "confirmations.mute.message": "本当に{name}をミュートしますか?", "confirmations.unfollow.confirm": "フォロー解除", "confirmations.unfollow.message": "本当に{name}をフォロー解除しますか?", + "embed.instructions": "下記のコードをコピーしてウェブサイトに埋め込みます。", + "embed.preview": "表示例:", "emoji_button.activity": "活動", "emoji_button.flags": "国旗", "emoji_button.food": "食べ物", @@ -159,15 +161,17 @@ "report.target": "{target} を通報する", "search.placeholder": "検索", "search_results.total": "{count, number}件の結果", - "standalone.public_title": "連合タイムライン", + "standalone.public_title": "今こんな話をしています", "status.cannot_reblog": "この投稿はブーストできません", "status.delete": "削除", + "status.embed": "埋め込み", "status.favourite": "お気に入り", "status.load_more": "もっと見る", "status.media_hidden": "非表示のメディア", "status.mention": "返信", "status.mute_conversation": "会話をミュート", "status.open": "詳細を表示", + "status.pin": "プロフィールに固定表示", "status.reblog": "ブースト", "status.reblogged_by": "{name}さんにブーストされました", "status.reply": "返信", @@ -179,6 +183,7 @@ "status.show_less": "隠す", "status.show_more": "もっと見る", "status.unmute_conversation": "会話のミュートを解除", + "status.unpin": "プロフィールの固定表示を解除", "tabs_bar.compose": "投稿", "tabs_bar.federated_timeline": "連合", "tabs_bar.home": "ホーム", diff --git a/app/javascript/mastodon/locales/ko.json b/app/javascript/mastodon/locales/ko.json index 47d0d4087..7d573506c 100644 --- a/app/javascript/mastodon/locales/ko.json +++ b/app/javascript/mastodon/locales/ko.json @@ -63,6 +63,8 @@ "confirmations.mute.message": "정말로 {name}를 뮤트하시겠습니까?", "confirmations.unfollow.confirm": "Unfollow", "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?", + "embed.instructions": "Embed this status on your website by copying the code below.", + "embed.preview": "Here is what it will look like:", "emoji_button.activity": "활동", "emoji_button.flags": "국기", "emoji_button.food": "음식", @@ -162,12 +164,14 @@ "standalone.public_title": "A look inside...", "status.cannot_reblog": "이 포스트는 부스트 할 수 없습니다", "status.delete": "삭제", + "status.embed": "Embed", "status.favourite": "즐겨찾기", "status.load_more": "더 보기", "status.media_hidden": "미디어 숨겨짐", "status.mention": "답장", "status.mute_conversation": "이 대화를 뮤트", "status.open": "상세 정보 표시", + "status.pin": "Pin on profile", "status.reblog": "부스트", "status.reblogged_by": "{name}님이 부스트 했습니다", "status.reply": "답장", @@ -179,6 +183,7 @@ "status.show_less": "숨기기", "status.show_more": "더 보기", "status.unmute_conversation": "이 대화의 뮤트 해제하기", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "포스트", "tabs_bar.federated_timeline": "연합", "tabs_bar.home": "홈", diff --git a/app/javascript/mastodon/locales/nl.json b/app/javascript/mastodon/locales/nl.json index 4d68c7992..d6775e1e4 100644 --- a/app/javascript/mastodon/locales/nl.json +++ b/app/javascript/mastodon/locales/nl.json @@ -63,6 +63,8 @@ "confirmations.mute.message": "Weet je het zeker dat je {name} wilt negeren?", "confirmations.unfollow.confirm": "Ontvolgen", "confirmations.unfollow.message": "Weet je het zeker dat je {name} wilt ontvolgen?", + "embed.instructions": "Embed this status on your website by copying the code below.", + "embed.preview": "Here is what it will look like:", "emoji_button.activity": "Activiteiten", "emoji_button.flags": "Vlaggen", "emoji_button.food": "Eten en drinken", @@ -162,12 +164,14 @@ "standalone.public_title": "Een kijkje binnenin...", "status.cannot_reblog": "Deze toot kan niet geboost worden", "status.delete": "Verwijderen", + "status.embed": "Embed", "status.favourite": "Favoriet", "status.load_more": "Meer laden", "status.media_hidden": "Media verborgen", "status.mention": "Vermeld @{name}", "status.mute_conversation": "Negeer conversatie", "status.open": "Toot volledig tonen", + "status.pin": "Pin on profile", "status.reblog": "Boost", "status.reblogged_by": "{name} boostte", "status.reply": "Reageren", @@ -179,6 +183,7 @@ "status.show_less": "Minder tonen", "status.show_more": "Meer tonen", "status.unmute_conversation": "Conversatie niet meer negeren", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "Schrijven", "tabs_bar.federated_timeline": "Globaal", "tabs_bar.home": "Start", diff --git a/app/javascript/mastodon/locales/no.json b/app/javascript/mastodon/locales/no.json index 9453e65ff..f3c24a807 100644 --- a/app/javascript/mastodon/locales/no.json +++ b/app/javascript/mastodon/locales/no.json @@ -63,6 +63,8 @@ "confirmations.mute.message": "Er du sikker på at du vil dempe {name}?", "confirmations.unfollow.confirm": "Unfollow", "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?", + "embed.instructions": "Embed this status on your website by copying the code below.", + "embed.preview": "Here is what it will look like:", "emoji_button.activity": "Aktivitet", "emoji_button.flags": "Flagg", "emoji_button.food": "Mat og drikke", @@ -162,12 +164,14 @@ "standalone.public_title": "A look inside...", "status.cannot_reblog": "Denne posten kan ikke fremheves", "status.delete": "Slett", + "status.embed": "Embed", "status.favourite": "Lik", "status.load_more": "Last mer", "status.media_hidden": "Media skjult", "status.mention": "Nevn @{name}", "status.mute_conversation": "Demp samtale", "status.open": "Utvid denne statusen", + "status.pin": "Pin on profile", "status.reblog": "Fremhev", "status.reblogged_by": "Fremhevd av {name}", "status.reply": "Svar", @@ -179,6 +183,7 @@ "status.show_less": "Vis mindre", "status.show_more": "Vis mer", "status.unmute_conversation": "Ikke demp samtale", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "Komponer", "tabs_bar.federated_timeline": "Felles", "tabs_bar.home": "Hjem", diff --git a/app/javascript/mastodon/locales/oc.json b/app/javascript/mastodon/locales/oc.json index e2a5d7c59..a86033e6f 100644 --- a/app/javascript/mastodon/locales/oc.json +++ b/app/javascript/mastodon/locales/oc.json @@ -12,7 +12,7 @@ "account.mute": "Rescondre @{name}", "account.posts": "Estatuts", "account.report": "Senhalar @{name}", - "account.requested": "Invitacion mandada", + "account.requested": "Invitacion mandada. Clicatz per anullar.", "account.share": "Partejar lo perfil a @{name}", "account.unblock": "Desblocar @{name}", "account.unblock_domain": "Desblocar {domain}", @@ -45,24 +45,26 @@ "column_subheading.settings": "Paramètres", "compose_form.lock_disclaimer": "Vòstre compte es pas {locked}. Tot lo mond pòt vos sègre e veire los estatuts reservats als seguidors.", "compose_form.lock_disclaimer.lock": "clavat", - "compose_form.placeholder": "A de qué pensatz ?", - "compose_form.privacy_disclaimer": "Vòstre estatut privat serà enviat a las personas mencionadas sus {domains}. Vos fisatz d’aqueste {domainsCount, plural, one { servidor} other {s servidors}} per divulgar pas vòstre estatut ? Los estatuts privats foncionan pas que sus las instàncias de Mastodon. Se {domains} {domainsCount, plural, one {es pas una instància a Mastodon} other {son pas d'instàncias a Mastodon}}, i aurà pas d’indicacion disent que vòstre estatut es privat e poirà èsser partejat o èsser visible a de mond pas prevists", + "compose_form.placeholder": "A de qué pensatz ?", + "compose_form.privacy_disclaimer": "Vòstre estatut privat serà enviat a las personas mencionadas sus {domains}. Vos fisatz d’aqueste {domainsCount, plural, one { servidor} other {s servidors}} per divulgar pas vòstre estatut ? Los estatuts privats foncionan pas que sus las instàncias de Mastodon. Se {domains} {domainsCount, plural, one {es pas una instància a Mastodon} other {son pas d'instàncias a Mastodon}}, i aurà pas d’indicacion disent que vòstre estatut es privat e poirà èsser partejat o èsser visible a de mond pas prevists", "compose_form.publish": "Tut", - "compose_form.publish_loud": "{publish} !", + "compose_form.publish_loud": "{publish} !", "compose_form.sensitive": "Marcar lo mèdia coma sensible", "compose_form.spoiler": "Rescondre lo tèxte darrièr un avertiment", "compose_form.spoiler_placeholder": "Escrivètz l’avertiment aquí", "confirmation_modal.cancel": "Anullar", "confirmations.block.confirm": "Blocar", - "confirmations.block.message": "Sètz segur de voler blocar {name} ?", + "confirmations.block.message": "Sètz segur de voler blocar {name} ?", "confirmations.delete.confirm": "Suprimir", - "confirmations.delete.message": "Sètz segur de voler suprimir l’estatut ?", + "confirmations.delete.message": "Sètz segur de voler suprimir l’estatut ?", "confirmations.domain_block.confirm": "Amagar tot lo domeni", - "confirmations.domain_block.message": "Sètz segur segur de voler blocar completament {domain} ? De còps cal pas que blocar o rescondre unas personas solament.", + "confirmations.domain_block.message": "Sètz segur segur de voler blocar completament {domain} ? De còps cal pas que blocar o rescondre unas personas solament.", "confirmations.mute.confirm": "Metre en silenci", - "confirmations.mute.message": "Sètz segur de voler metre en silenci {name} ?", + "confirmations.mute.message": "Sètz segur de voler metre en silenci {name} ?", "confirmations.unfollow.confirm": "Quitar de sègre", - "confirmations.unfollow.message": "Volètz vertadièrament quitar de sègre {name} ?", + "confirmations.unfollow.message": "Volètz vertadièrament quitar de sègre {name} ?", + "embed.instructions": "Embarcar aqueste estatut per o far veire sus un site Internet en copiar lo còdi çai-jos.", + "embed.preview": "Semblarà aquò : ", "emoji_button.activity": "Activitats", "emoji_button.flags": "Drapèus", "emoji_button.food": "Beure e manjar", @@ -73,13 +75,13 @@ "emoji_button.search": "Cercar…", "emoji_button.symbols": "Simbòls", "emoji_button.travel": "Viatges & lòcs", - "empty_column.community": "Lo flux public local es void. Escrivètz quicòm per lo garnir !", + "empty_column.community": "Lo flux public local es void. Escrivètz quicòm per lo garnir !", "empty_column.hashtag": "I a pas encara de contengut ligat a aqueste hashtag", "empty_column.home": "Pel moment seguètz pas degun. Visitatz {public} o utilizatz la recèrca per vos connectar a d’autras personas.", "empty_column.home.inactivity": "Vòstra pagina d’acuèlh es voida. Se sètz estat inactiu per un moment, serà tornada generar per vos dins una estona.", "empty_column.home.public_timeline": "lo flux public", "empty_column.notifications": "Avètz pas encara de notificacions. Respondètz a qualqu’un per començar una conversacion.", - "empty_column.public": "I a pas res aquí ! Escrivètz quicòm de public, o seguètz de personas d’autras instàncias per garnir lo flux public.", + "empty_column.public": "I a pas res aquí ! Escrivètz quicòm de public, o seguètz de personas d’autras instàncias per garnir lo flux public.", "follow_request.authorize": "Autorizar", "follow_request.reject": "Regetar", "getting_started.appsshort": "Apps", @@ -109,19 +111,19 @@ "navigation_bar.mutes": "Personas rescondudas", "navigation_bar.preferences": "Preferéncias", "navigation_bar.public_timeline": "Flux public global", - "notification.favourite": "{name} a ajustat a sos favorits :", + "notification.favourite": "{name} a ajustat a sos favorits :", "notification.follow": "{name} vos sèc", - "notification.mention": "{name} vos a mencionat :", - "notification.reblog": "{name} a partejat vòstre estatut :", + "notification.mention": "{name} vos a mencionat :", + "notification.reblog": "{name} a partejat vòstre estatut :", "notifications.clear": "Escafar", - "notifications.clear_confirmation": "Volètz vertadièrament escafar totas vòstras las notificacions ?", + "notifications.clear_confirmation": "Volètz vertadièrament escafar totas vòstras las notificacions ?", "notifications.column_settings.alert": "Notificacions localas", - "notifications.column_settings.favourite": "Favorits :", - "notifications.column_settings.follow": "Nòus seguidors :", - "notifications.column_settings.mention": "Mencions :", + "notifications.column_settings.favourite": "Favorits :", + "notifications.column_settings.follow": "Nòus seguidors :", + "notifications.column_settings.mention": "Mencions :", "notifications.column_settings.push": "Notificacions", "notifications.column_settings.push_meta": "Aqueste periferic", - "notifications.column_settings.reblog": "Partatges :", + "notifications.column_settings.reblog": "Partatges :", "notifications.column_settings.show": "Mostrar dins la colomna", "notifications.column_settings.sound": "Emetre un son", "onboarding.done": "Fach", @@ -131,14 +133,14 @@ "onboarding.page_four.notifications": "La colomna de notificacions vos fa veire quand qualqu’un interagís amb vos", "onboarding.page_one.federation": "Mastodon es un malhum de servidors independents que comunican per bastir un malhum ma larg. Òm los apèla instàncias.", "onboarding.page_one.handle": "Sètz sus {domain}, doncas vòstre identificant complet es {handle}", - "onboarding.page_one.welcome": "Benvengut a Mastodon !", + "onboarding.page_one.welcome": "Benvengut a Mastodon !", "onboarding.page_six.admin": "Vòstre administrator d’instància es {admin}.", "onboarding.page_six.almost_done": "Gaireben acabat…", "onboarding.page_six.appetoot": "Bon Appetut!", "onboarding.page_six.apps_available": "I a d’aplicacions per mobil per iOS, Android e mai.", "onboarding.page_six.github": "Mastodon es un logicial liure e open-source. Podètz senhalar de bugs, demandar de foncionalitats e contribuir al còdi sus {github}.", "onboarding.page_six.guidelines": "guida de la comunitat", - "onboarding.page_six.read_guidelines": "Mercés de legir la {guidelines} a {domain} !", + "onboarding.page_six.read_guidelines": "Mercés de legir la {guidelines} a {domain} !", "onboarding.page_six.various_app": "aplicacions per mobil", "onboarding.page_three.profile": "Modificatz vòstre perfil per cambiar vòstre avatar, bio e escais-nom. I a enlà totas las preferéncias.", "onboarding.page_three.search": "Emplegatz la barra de recèrca per trobar de mond e engachatz las etiquetas coma {illustration} e {introductions}. Per trobar una persona d’una autra instància, picatz son identificant complet.", @@ -162,14 +164,16 @@ "standalone.public_title": "Una ulhada dedins…", "status.cannot_reblog": "Aqueste estatut pòt pas èsser partejat", "status.delete": "Escafar", + "status.embed": "Embarcar", "status.favourite": "Apondre als favorits", "status.load_more": "Cargar mai", "status.media_hidden": "Mèdia rescondut", "status.mention": "Mencionar", "status.mute_conversation": "Rescondre la conversacion", "status.open": "Desplegar aqueste estatut", + "status.pin": "Penjar al perfil", "status.reblog": "Partejar", - "status.reblogged_by": "{name} a partejat :", + "status.reblogged_by": "{name} a partejat :", "status.reply": "Respondre", "status.replyAll": "Respondre a la conversacion", "status.report": "Senhalar @{name}", @@ -179,6 +183,7 @@ "status.show_less": "Tornar plegar", "status.show_more": "Desplegar", "status.unmute_conversation": "Conversacions amb silenci levat", + "status.unpin": "Despenjar del perfil", "tabs_bar.compose": "Compausar", "tabs_bar.federated_timeline": "Flux public global", "tabs_bar.home": "Acuèlh", diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json index 542230f11..e3e652970 100644 --- a/app/javascript/mastodon/locales/pl.json +++ b/app/javascript/mastodon/locales/pl.json @@ -10,7 +10,7 @@ "account.media": "Media", "account.mention": "Wspomnij o @{name}", "account.mute": "Wycisz @{name}", - "account.posts": "Posty", + "account.posts": "Wpisy", "account.report": "Zgłoś @{name}", "account.requested": "Oczekująca prośba", "account.share": "Udostępnij profil @{name}", @@ -43,10 +43,10 @@ "column_header.unpin": "Cofnij przypięcie", "column_subheading.navigation": "Nawigacja", "column_subheading.settings": "Ustawienia", - "compose_form.lock_disclaimer": "Twoje konto nie jest {locked}. Każdy, kto Cię śledzi, może wyświetlać Twoje posty przeznaczone tylko dla śledzących.", + "compose_form.lock_disclaimer": "Twoje konto nie jest {locked}. Każdy, kto Cię śledzi, może wyświetlać Twoje wpisy przeznaczone tylko dla śledzących.", "compose_form.lock_disclaimer.lock": "zablokowane", "compose_form.placeholder": "Co Ci chodzi po głowie?", - "compose_form.privacy_disclaimer": "Twój post zostanie dostarczony do użytkowników z {domains}. Czy ufasz {domainsCount, plural, one {temu serwerowi} other {tym serwerom}}? Prywatność postów obowiązuje tylko na instancjach Mastodona. Jeżeli {domains} {domainsCount, plural, one {nie jest instancją Mastodona} other {nie są instancjami Mastodona}}, post może być widoczny dla niewłaściwych osób.", + "compose_form.privacy_disclaimer": "Twój wpis zostanie dostarczony do użytkowników z {domains}. Czy ufasz {domainsCount, plural, one {temu serwerowi} other {tym serwerom}}? Prywatność wpisów obowiązuje tylko na instancjach Mastodona. Jeżeli {domains} {domainsCount, plural, one {nie jest instancją Mastodona} other {nie są instancjami Mastodona}}, wpis może być widoczny dla niewłaściwych osób.", "compose_form.publish": "Wyślij", "compose_form.publish_loud": "{publish}!", "compose_form.sensitive": "Oznacz treści jako wrażliwe", @@ -63,6 +63,8 @@ "confirmations.mute.message": "Czy na pewno chcesz wyciszyć {name}?", "confirmations.unfollow.confirm": "Przestań śledzić", "confirmations.unfollow.message": "Czy na pewno zamierzasz przestać śledzić {name}?", + "embed.instructions": "Osadź ten status na swojej stronie wklejając poniższy kod.", + "embed.preview": "Tak będzie to wyglądać:", "emoji_button.activity": "Aktywność", "emoji_button.flags": "Flagi", "emoji_button.food": "Żywność i napoje", @@ -70,11 +72,11 @@ "emoji_button.nature": "Natura", "emoji_button.objects": "Objekty", "emoji_button.people": "Ludzie", - "emoji_button.search": "Szukaj...", + "emoji_button.search": "Szukaj…", "emoji_button.symbols": "Symbole", "emoji_button.travel": "Podróże i miejsca", "empty_column.community": "Lokalna oś czasu jest pusta. Napisz coś publicznie, aby zagaić!", - "empty_column.hashtag": "Nie ma postów oznaczonych tym hashtagiem. Możesz napisać pierwszy!", + "empty_column.hashtag": "Nie ma wpisów oznaczonych tym hashtagiem. Możesz napisać pierwszy!", "empty_column.home": "Nie śledzisz nikogo. Odwiedź publiczną oś czasu lub użyj wyszukiwarki, aby znaleźć interesujące Cię profile.", "empty_column.home.inactivity": "Strumień jest pusty. Jeżeli nie było Cię tu ostatnio, zostanie on wypełniony wkrótce.", "empty_column.home.public_timeline": "publiczna oś czasu", @@ -85,7 +87,7 @@ "getting_started.appsshort": "Aplikacje", "getting_started.faq": "FAQ", "getting_started.heading": "Naucz się korzystać", - "getting_started.open_source_notice": "Mastodon jest oprogramowaniem o otwartym źródle. Możesz pomóc w rozwoju lub zgłaszać błędy na GitHubie tutaj {github}.", + "getting_started.open_source_notice": "Mastodon jest oprogramowaniem o otwartym źródle. Możesz pomóc w rozwoju lub zgłaszać błędy na GitHubie tutaj: {github}.", "getting_started.userguide": "Podręcznik użytkownika", "home.column_settings.advanced": "Zaawansowane", "home.column_settings.basic": "Podstawowe", @@ -96,7 +98,7 @@ "lightbox.close": "Zamknij", "lightbox.next": "Następne", "lightbox.previous": "Poprzednie", - "loading_indicator.label": "Ładowanie...", + "loading_indicator.label": "Ładowanie…", "media_gallery.toggle_visible": "Przełącz widoczność", "missing_indicator.label": "Nie znaleziono", "navigation_bar.blocks": "Zablokowani użytkownicy", @@ -116,12 +118,12 @@ "notifications.clear": "Wyczyść powiadomienia", "notifications.clear_confirmation": "Czy na pewno chcesz bezpowrotnie usunąć wszystkie powiadomienia?", "notifications.column_settings.alert": "Powiadomienia na pulpicie", - "notifications.column_settings.favourite": "Ulubione:", + "notifications.column_settings.favourite": "Dodanie do ulubionych:", "notifications.column_settings.follow": "Nowi śledzący:", - "notifications.column_settings.mention": "Wspomniali:", + "notifications.column_settings.mention": "Wspomnienia:", "notifications.column_settings.push": "Powiadomienia push", "notifications.column_settings.push_meta": "To urządzenie", - "notifications.column_settings.reblog": "Podbili:", + "notifications.column_settings.reblog": "Podbicia:", "notifications.column_settings.show": "Pokaż w kolumnie", "notifications.column_settings.sound": "Odtwarzaj dźwięk", "onboarding.done": "Gotowe", @@ -142,15 +144,15 @@ "onboarding.page_six.various_app": "aplikacje mobilne", "onboarding.page_three.profile": "Edytuj profil, aby zmienić obraz profilowy, biografię, wyświetlaną nazwę i inne ustawienia.", "onboarding.page_three.search": "Użyj paska wyszukiwania aby znaleźć ludzi i hashtagi, takie jak {illustration} i {introductions}. Aby znaleźć osobę spoza tej instancji, musisz użyć pełnego adresu.", - "onboarding.page_two.compose": "Napisz posty, aby wypełnić kolumnę. Możesz wysłać zdjęcia, zmienić ustawienia prywatności lub dodać ostrzeżenie o zawartości.", + "onboarding.page_two.compose": "Utwórz wpisy, aby wypełnić kolumnę. Możesz wysłać zdjęcia, zmienić ustawienia prywatności lub dodać ostrzeżenie o zawartości.", "onboarding.skip": "Pomiń", - "privacy.change": "Dostosuj widoczność postów", - "privacy.direct.long": "Widoczne tylko dla oznaczonych", + "privacy.change": "Dostosuj widoczność wpisów", + "privacy.direct.long": "Widoczny tylko dla wspomnianych", "privacy.direct.short": "Bezpośrednio", - "privacy.private.long": "Widoczne tylko dla śledzących", - "privacy.private.short": "Tylko śledzący", - "privacy.public.long": "Widoczne na publicznych osiach czasu", - "privacy.public.short": "Publiczne", + "privacy.private.long": "Widoczny tylko dla osób, które Cię śledzą", + "privacy.private.short": "Tylko dla śledzących", + "privacy.public.long": "Widoczny na publicznych osiach czasu", + "privacy.public.short": "Publiczny", "privacy.unlisted.long": "Niewidoczne na publicznych osiach czasu", "privacy.unlisted.short": "Niewidoczne", "reply_indicator.cancel": "Anuluj", @@ -160,14 +162,16 @@ "search.placeholder": "Szukaj", "search_results.total": "{count, number} {count, plural, one {wynik} more {wyniki}}", "standalone.public_title": "Spojrzenie w głąb…", - "status.cannot_reblog": "Ten post nie może zostać podbity", + "status.cannot_reblog": "Ten wpis nie może zostać podbity", "status.delete": "Usuń", + "status.embed": "Osadź", "status.favourite": "Ulubione", "status.load_more": "Załaduj więcej", "status.media_hidden": "Zawartość multimedialna ukryta", "status.mention": "Wspomnij o @{name}", "status.mute_conversation": "Wycisz konwersację", "status.open": "Rozszerz ten status", + "status.pin": "Przypnij do profilu", "status.reblog": "Podbij", "status.reblogged_by": "{name} podbił", "status.reply": "Odpowiedz", @@ -179,6 +183,7 @@ "status.show_less": "Pokaż mniej", "status.show_more": "Pokaż więcej", "status.unmute_conversation": "Cofnij wyciszenie konwersacji", + "status.unpin": "Odepnij z profilu", "tabs_bar.compose": "Napisz", "tabs_bar.federated_timeline": "Globalne", "tabs_bar.home": "Strona główna", diff --git a/app/javascript/mastodon/locales/pt-BR.json b/app/javascript/mastodon/locales/pt-BR.json index 55d2f05de..e861bf73f 100644 --- a/app/javascript/mastodon/locales/pt-BR.json +++ b/app/javascript/mastodon/locales/pt-BR.json @@ -1,68 +1,70 @@ { "account.block": "Bloquear @{name}", - "account.block_domain": "Hide everything from {domain}", - "account.disclaimer_full": "Information below may reflect the user's profile incompletely.", + "account.block_domain": "Esconder tudo de {domain}", + "account.disclaimer_full": "As informações abaixo podem refletir o perfil do usuário de maneira incompleta.", "account.edit_profile": "Editar perfil", "account.follow": "Seguir", "account.followers": "Seguidores", "account.follows": "Segue", - "account.follows_you": "É teu seguidor", - "account.media": "Media", + "account.follows_you": "É seu seguidor", + "account.media": "Mídia", "account.mention": "Mencionar @{name}", "account.mute": "Silenciar @{name}", "account.posts": "Posts", "account.report": "Denunciar @{name}", - "account.requested": "A aguardar aprovação", - "account.share": "Share @{name}'s profile", + "account.requested": "Aguardando aprovação", + "account.share": "Compartilhar perfil de @{name}", "account.unblock": "Não bloquear @{name}", - "account.unblock_domain": "Unhide {domain}", + "account.unblock_domain": "Desbloquear {domain}", "account.unfollow": "Deixar de seguir", "account.unmute": "Não silenciar @{name}", - "account.view_full_profile": "View full profile", + "account.view_full_profile": "Ver perfil completo", "boost_modal.combo": "Pode clicar {combo} para não voltar a ver", "bundle_column_error.body": "Something went wrong while loading this component.", - "bundle_column_error.retry": "Try again", + "bundle_column_error.retry": "Tente novamente", "bundle_column_error.title": "Network error", - "bundle_modal_error.close": "Close", + "bundle_modal_error.close": "Fechar", "bundle_modal_error.message": "Something went wrong while loading this component.", - "bundle_modal_error.retry": "Try again", - "column.blocks": "Utilizadores Bloqueados", + "bundle_modal_error.retry": "Tente novamente", + "column.blocks": "Usuários bloqueados", "column.community": "Local", "column.favourites": "Favoritos", - "column.follow_requests": "Seguidores Pendentes", - "column.home": "Home", - "column.mutes": "Utilizadores silenciados", + "column.follow_requests": "Seguidores pendentes", + "column.home": "Página inicial", + "column.mutes": "Usuários silenciados", "column.notifications": "Notificações", "column.public": "Global", "column_back_button.label": "Voltar", - "column_header.hide_settings": "Hide settings", - "column_header.moveLeft_settings": "Move column to the left", - "column_header.moveRight_settings": "Move column to the right", - "column_header.pin": "Pin", - "column_header.show_settings": "Show settings", - "column_header.unpin": "Unpin", - "column_subheading.navigation": "Navigation", - "column_subheading.settings": "Settings", - "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.", + "column_header.hide_settings": "Esconder configurações", + "column_header.moveLeft_settings": "Mover coluna para a esquerda", + "column_header.moveRight_settings": "Mover coluna para a direita", + "column_header.pin": "Fixar", + "column_header.show_settings": "Mostrar configurações", + "column_header.unpin": "Desafixar", + "column_subheading.navigation": "Navegação", + "column_subheading.settings": "Configurações", + "compose_form.lock_disclaimer": "A sua conta não está {locked}. Qualquer pessoa pode te seguir e visualizar as suas postagens só para seguidores.", "compose_form.lock_disclaimer.lock": "locked", - "compose_form.placeholder": "Em que estás a pensar?", - "compose_form.privacy_disclaimer": "O teu conteúdo privado vai ser partilhado com os utilizadores do {domains}. Confias {domainsCount, plural, one {neste servidor} other {nestes servidores}}? A privacidade só funciona em instâncias do Mastodon. Se {domains} {domainsCount, plural, one {não é uma instância} other {não são instâncias}}, não existem indicadores da privacidade da tua partilha, e podem ser partilhados com outros.", + "compose_form.placeholder": "No que você está pensando?", + "compose_form.privacy_disclaimer": "O seu conteúdo privado será compartilhado com os usuários do {domains}. Você confia {domainsCount, plural, one {neste servidor} other {nestes servidores}}? As configurações de privacidade só funcionam em instâncias do Mastodon. Se {domains} {domainsCount, plural, one {não é uma instância} other {não são instâncias}}, não há como garantir a privacidade de suas postagens, e elas podem ser compartilhadas com outros.", "compose_form.publish": "Publicar", "compose_form.publish_loud": "{publish}!", - "compose_form.sensitive": "Marcar media como conteúdo sensível", + "compose_form.sensitive": "Marcar mídia como conteúdo sensível", "compose_form.spoiler": "Esconder texto com aviso", "compose_form.spoiler_placeholder": "Aviso de conteúdo", - "confirmation_modal.cancel": "Cancel", - "confirmations.block.confirm": "Block", - "confirmations.block.message": "Are you sure you want to block {name}?", - "confirmations.delete.confirm": "Delete", - "confirmations.delete.message": "Are you sure you want to delete this status?", - "confirmations.domain_block.confirm": "Hide entire domain", - "confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.", - "confirmations.mute.confirm": "Mute", - "confirmations.mute.message": "Are you sure you want to mute {name}?", - "confirmations.unfollow.confirm": "Unfollow", - "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?", + "confirmation_modal.cancel": "Cancelar", + "confirmations.block.confirm": "Bloquear", + "confirmations.block.message": "Você tem certeza de que quer bloquear {name}?", + "confirmations.delete.confirm": "Excluir", + "confirmations.delete.message": "Você tem certeza de que quer excluir este status?", + "confirmations.domain_block.confirm": "Esconder o domínio inteiro", + "confirmations.domain_block.message": "Você quer mesmo bloquear {domain} inteiro? Na maioria dos casos, silenciar ou bloquear alguns usuários é o suficiente e o recomendado.", + "confirmations.mute.confirm": "Silenciar", + "confirmations.mute.message": "Você tem certeza de que quer silenciar {name}?", + "confirmations.unfollow.confirm": "Deixar de seguir", + "confirmations.unfollow.message": "Você tem certeza de que quer deixar de seguir {name}?", + "embed.instructions": "Embed this status on your website by copying the code below.", + "embed.preview": "Here is what it will look like:", "emoji_button.activity": "Activity", "emoji_button.flags": "Flags", "emoji_button.food": "Food & Drink", @@ -162,12 +164,14 @@ "standalone.public_title": "A look inside...", "status.cannot_reblog": "This post cannot be boosted", "status.delete": "Eliminar", + "status.embed": "Embed", "status.favourite": "Adicionar aos favoritos", "status.load_more": "Carregar mais", "status.media_hidden": "Media escondida", "status.mention": "Mencionar @{name}", "status.mute_conversation": "Mute conversation", "status.open": "Expandir", + "status.pin": "Pin on profile", "status.reblog": "Partilhar", "status.reblogged_by": "{name} partilhou", "status.reply": "Responder", @@ -179,6 +183,7 @@ "status.show_less": "Mostrar menos", "status.show_more": "Mostrar mais", "status.unmute_conversation": "Unmute conversation", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "Criar", "tabs_bar.federated_timeline": "Global", "tabs_bar.home": "Home", diff --git a/app/javascript/mastodon/locales/pt.json b/app/javascript/mastodon/locales/pt.json index 55d2f05de..f9e686411 100644 --- a/app/javascript/mastodon/locales/pt.json +++ b/app/javascript/mastodon/locales/pt.json @@ -63,6 +63,8 @@ "confirmations.mute.message": "Are you sure you want to mute {name}?", "confirmations.unfollow.confirm": "Unfollow", "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?", + "embed.instructions": "Embed this status on your website by copying the code below.", + "embed.preview": "Here is what it will look like:", "emoji_button.activity": "Activity", "emoji_button.flags": "Flags", "emoji_button.food": "Food & Drink", @@ -162,12 +164,14 @@ "standalone.public_title": "A look inside...", "status.cannot_reblog": "This post cannot be boosted", "status.delete": "Eliminar", + "status.embed": "Embed", "status.favourite": "Adicionar aos favoritos", "status.load_more": "Carregar mais", "status.media_hidden": "Media escondida", "status.mention": "Mencionar @{name}", "status.mute_conversation": "Mute conversation", "status.open": "Expandir", + "status.pin": "Pin on profile", "status.reblog": "Partilhar", "status.reblogged_by": "{name} partilhou", "status.reply": "Responder", @@ -179,6 +183,7 @@ "status.show_less": "Mostrar menos", "status.show_more": "Mostrar mais", "status.unmute_conversation": "Unmute conversation", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "Criar", "tabs_bar.federated_timeline": "Global", "tabs_bar.home": "Home", diff --git a/app/javascript/mastodon/locales/ru.json b/app/javascript/mastodon/locales/ru.json index 1abfb4370..0f78f4b17 100644 --- a/app/javascript/mastodon/locales/ru.json +++ b/app/javascript/mastodon/locales/ru.json @@ -1,7 +1,7 @@ { "account.block": "Блокировать", "account.block_domain": "Блокировать все с {domain}", - "account.disclaimer_full": "Information below may reflect the user's profile incompletely.", + "account.disclaimer_full": "Нижеуказанная информация может не полностью отражать профиль пользователя.", "account.edit_profile": "Изменить профиль", "account.follow": "Подписаться", "account.followers": "Подписаны", @@ -13,19 +13,19 @@ "account.posts": "Посты", "account.report": "Пожаловаться", "account.requested": "Ожидает подтверждения", - "account.share": "Share @{name}'s profile", + "account.share": "Поделиться профилем @{name}", "account.unblock": "Разблокировать", "account.unblock_domain": "Разблокировать {domain}", "account.unfollow": "Отписаться", "account.unmute": "Снять глушение", - "account.view_full_profile": "View full profile", + "account.view_full_profile": "Показать полный профиль", "boost_modal.combo": "Нажмите {combo}, чтобы пропустить это в следующий раз", - "bundle_column_error.body": "Something went wrong while loading this component.", - "bundle_column_error.retry": "Try again", - "bundle_column_error.title": "Network error", - "bundle_modal_error.close": "Close", - "bundle_modal_error.message": "Something went wrong while loading this component.", - "bundle_modal_error.retry": "Try again", + "bundle_column_error.body": "Что-то пошло не так при загрузке этого компонента.", + "bundle_column_error.retry": "Попробовать снова", + "bundle_column_error.title": "Ошибка сети", + "bundle_modal_error.close": "Закрыть", + "bundle_modal_error.message": "Что-то пошло не так при загрузке этого компонента.", + "bundle_modal_error.retry": "Попробовать снова", "column.blocks": "Список блокировки", "column.community": "Локальная лента", "column.favourites": "Понравившееся", @@ -35,11 +35,11 @@ "column.notifications": "Уведомления", "column.public": "Глобальная лента", "column_back_button.label": "Назад", - "column_header.hide_settings": "Hide settings", - "column_header.moveLeft_settings": "Move column to the left", - "column_header.moveRight_settings": "Move column to the right", + "column_header.hide_settings": "Скрыть настройки", + "column_header.moveLeft_settings": "Передвинуть колонку влево", + "column_header.moveRight_settings": "Передвинуть колонку вправо", "column_header.pin": "Закрепить", - "column_header.show_settings": "Show settings", + "column_header.show_settings": "Показать настройки", "column_header.unpin": "Открепить", "column_subheading.navigation": "Навигация", "column_subheading.settings": "Настройки", @@ -61,8 +61,10 @@ "confirmations.domain_block.message": "Вы на самом деле уверены, что хотите блокировать весь {domain}? В большинстве случаев нескольких отдельных блокировок или глушений достаточно.", "confirmations.mute.confirm": "Заглушить", "confirmations.mute.message": "Вы уверены, что хотите заглушить {name}?", - "confirmations.unfollow.confirm": "Unfollow", - "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?", + "confirmations.unfollow.confirm": "Отписаться", + "confirmations.unfollow.message": "Вы уверены, что хотите отписаться от {name}?", + "embed.instructions": "Embed this status on your website by copying the code below.", + "embed.preview": "Here is what it will look like:", "emoji_button.activity": "Занятия", "emoji_button.flags": "Флаги", "emoji_button.food": "Еда и напитки", @@ -94,8 +96,8 @@ "home.column_settings.show_replies": "Показывать ответы", "home.settings": "Настройки колонки", "lightbox.close": "Закрыть", - "lightbox.next": "Next", - "lightbox.previous": "Previous", + "lightbox.next": "Далее", + "lightbox.previous": "Назад", "loading_indicator.label": "Загрузка...", "media_gallery.toggle_visible": "Показать/скрыть", "missing_indicator.label": "Не найдено", @@ -119,8 +121,8 @@ "notifications.column_settings.favourite": "Нравится:", "notifications.column_settings.follow": "Новые подписчики:", "notifications.column_settings.mention": "Упоминания:", - "notifications.column_settings.push": "Push notifications", - "notifications.column_settings.push_meta": "This device", + "notifications.column_settings.push": "Push-уведомления", + "notifications.column_settings.push_meta": "Это устройство", "notifications.column_settings.reblog": "Продвижения:", "notifications.column_settings.show": "Показывать в колонке", "notifications.column_settings.sound": "Проигрывать звук", @@ -162,12 +164,14 @@ "standalone.public_title": "A look inside...", "status.cannot_reblog": "Этот статус не может быть продвинут", "status.delete": "Удалить", + "status.embed": "Embed", "status.favourite": "Нравится", "status.load_more": "Показать еще", "status.media_hidden": "Медиаконтент скрыт", "status.mention": "Упомянуть @{name}", "status.mute_conversation": "Заглушить тред", "status.open": "Развернуть статус", + "status.pin": "Pin on profile", "status.reblog": "Продвинуть", "status.reblogged_by": "{name} продвинул(а)", "status.reply": "Ответить", @@ -179,6 +183,7 @@ "status.show_less": "Свернуть", "status.show_more": "Развернуть", "status.unmute_conversation": "Снять глушение с треда", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "Написать", "tabs_bar.federated_timeline": "Глобальная", "tabs_bar.home": "Главная", diff --git a/app/javascript/mastodon/locales/th.json b/app/javascript/mastodon/locales/th.json index aa0929f82..069fdf7c3 100644 --- a/app/javascript/mastodon/locales/th.json +++ b/app/javascript/mastodon/locales/th.json @@ -63,6 +63,8 @@ "confirmations.mute.message": "Are you sure you want to mute {name}?", "confirmations.unfollow.confirm": "Unfollow", "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?", + "embed.instructions": "Embed this status on your website by copying the code below.", + "embed.preview": "Here is what it will look like:", "emoji_button.activity": "Activity", "emoji_button.flags": "Flags", "emoji_button.food": "Food & Drink", @@ -162,12 +164,14 @@ "standalone.public_title": "A look inside...", "status.cannot_reblog": "This post cannot be boosted", "status.delete": "Delete", + "status.embed": "Embed", "status.favourite": "Favourite", "status.load_more": "Load more", "status.media_hidden": "Media hidden", "status.mention": "Mention @{name}", "status.mute_conversation": "Mute conversation", "status.open": "Expand this status", + "status.pin": "Pin on profile", "status.reblog": "Boost", "status.reblogged_by": "{name} boosted", "status.reply": "Reply", @@ -179,6 +183,7 @@ "status.show_less": "Show less", "status.show_more": "Show more", "status.unmute_conversation": "Unmute conversation", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "Compose", "tabs_bar.federated_timeline": "Federated", "tabs_bar.home": "Home", diff --git a/app/javascript/mastodon/locales/tr.json b/app/javascript/mastodon/locales/tr.json index 37ce8597e..8a36bd207 100644 --- a/app/javascript/mastodon/locales/tr.json +++ b/app/javascript/mastodon/locales/tr.json @@ -63,6 +63,8 @@ "confirmations.mute.message": "{name} kullanıcısını sessize almak istiyor musunuz?", "confirmations.unfollow.confirm": "Unfollow", "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?", + "embed.instructions": "Embed this status on your website by copying the code below.", + "embed.preview": "Here is what it will look like:", "emoji_button.activity": "Aktivite", "emoji_button.flags": "Bayraklar", "emoji_button.food": "Yiyecek ve İçecek", @@ -162,12 +164,14 @@ "standalone.public_title": "A look inside...", "status.cannot_reblog": "Bu gönderi boost edilemez", "status.delete": "Sil", + "status.embed": "Embed", "status.favourite": "Favorilere ekle", "status.load_more": "Daha fazla", "status.media_hidden": "Gizli görsel", "status.mention": "Bahset @{name}", "status.mute_conversation": "Mute conversation", "status.open": "Bu gönderiyi genişlet", + "status.pin": "Pin on profile", "status.reblog": "Boost'la", "status.reblogged_by": "{name} boost etti", "status.reply": "Cevapla", @@ -179,6 +183,7 @@ "status.show_less": "Daha azı", "status.show_more": "Daha fazlası", "status.unmute_conversation": "Unmute conversation", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "Oluştur", "tabs_bar.federated_timeline": "Federe", "tabs_bar.home": "Ana sayfa", diff --git a/app/javascript/mastodon/locales/uk.json b/app/javascript/mastodon/locales/uk.json index fea7bd94e..1d06218e6 100644 --- a/app/javascript/mastodon/locales/uk.json +++ b/app/javascript/mastodon/locales/uk.json @@ -63,6 +63,8 @@ "confirmations.mute.message": "Ви впевнені, що хочете заглушити {name}?", "confirmations.unfollow.confirm": "Unfollow", "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?", + "embed.instructions": "Embed this status on your website by copying the code below.", + "embed.preview": "Here is what it will look like:", "emoji_button.activity": "Заняття", "emoji_button.flags": "Прапори", "emoji_button.food": "Їжа та напої", @@ -162,12 +164,14 @@ "standalone.public_title": "A look inside...", "status.cannot_reblog": "Цей допис не може бути передмухнутий", "status.delete": "Видалити", + "status.embed": "Embed", "status.favourite": "Подобається", "status.load_more": "Завантажити більше", "status.media_hidden": "Медіаконтент приховано", "status.mention": "Згадати", "status.mute_conversation": "Заглушити діалог", "status.open": "Розгорнути допис", + "status.pin": "Pin on profile", "status.reblog": "Передмухнути", "status.reblogged_by": "{name} передмухнув(-ла)", "status.reply": "Відповісти", @@ -179,6 +183,7 @@ "status.show_less": "Згорнути", "status.show_more": "Розгорнути", "status.unmute_conversation": "Зняти глушення з діалогу", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "Написати", "tabs_bar.federated_timeline": "Глобальна", "tabs_bar.home": "Головна", diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json index d0c4b3d1b..93faf8876 100644 --- a/app/javascript/mastodon/locales/zh-CN.json +++ b/app/javascript/mastodon/locales/zh-CN.json @@ -5,7 +5,7 @@ "account.edit_profile": "修改个人资料", "account.follow": "关注", "account.followers": "关注者", - "account.follows": "正关注", + "account.follows": "正在关注", "account.follows_you": "关注你", "account.media": "Media", "account.mention": "提及 @{name}", @@ -13,19 +13,19 @@ "account.posts": "嘟文", "account.report": "举报 @{name}", "account.requested": "等待审批", - "account.share": "Share @{name}'s profile", + "account.share": "分享 @{name}的个人资料", "account.unblock": "解除对 @{name} 的屏蔽", - "account.unblock_domain": "Unhide {domain}", + "account.unblock_domain": "解除封锁 {domain}", "account.unfollow": "取消关注", "account.unmute": "取消 @{name} 的静音", - "account.view_full_profile": "View full profile", + "account.view_full_profile": "查看完整资料", "boost_modal.combo": "如你想在下次路过时显示,请按{combo},", - "bundle_column_error.body": "Something went wrong while loading this component.", - "bundle_column_error.retry": "Try again", - "bundle_column_error.title": "Network error", - "bundle_modal_error.close": "Close", - "bundle_modal_error.message": "Something went wrong while loading this component.", - "bundle_modal_error.retry": "Try again", + "bundle_column_error.body": "载入组件出错。", + "bundle_column_error.retry": "再次尝试", + "bundle_column_error.title": "网络错误", + "bundle_modal_error.close": "关闭", + "bundle_modal_error.message": "载入组件出错。", + "bundle_modal_error.retry": "再次尝试", "column.blocks": "屏蔽用户", "column.community": "本站时间轴", "column.favourites": "赞过的嘟文", @@ -34,7 +34,7 @@ "column.mutes": "被静音的用户", "column.notifications": "通知", "column.public": "跨站公共时间轴", - "column_back_button.label": "Back", + "column_back_button.label": "返回", "column_header.hide_settings": "Hide settings", "column_header.moveLeft_settings": "Move column to the left", "column_header.moveRight_settings": "Move column to the right", @@ -61,8 +61,10 @@ "confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.", "confirmations.mute.confirm": "静音", "confirmations.mute.message": "想好了,真的要静音 {name}?", - "confirmations.unfollow.confirm": "Unfollow", - "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?", + "confirmations.unfollow.confirm": "取消关注", + "confirmations.unfollow.message": "确定要取消关注 {name}吗?", + "embed.instructions": "Embed this status on your website by copying the code below.", + "embed.preview": "Here is what it will look like:", "emoji_button.activity": "活动", "emoji_button.flags": "旗帜", "emoji_button.food": "食物和饮料", @@ -86,7 +88,7 @@ "getting_started.faq": "FAQ", "getting_started.heading": "开始使用", "getting_started.open_source_notice": "Mastodon 是一个开放源码的软件。你可以在官方 GitHub ({github}) 贡献或者回报问题。", - "getting_started.userguide": "User Guide", + "getting_started.userguide": "用户指南", "home.column_settings.advanced": "高端", "home.column_settings.basic": "基本", "home.column_settings.filter_regex": "使用正则表达式 (regex) 过滤", @@ -162,12 +164,14 @@ "standalone.public_title": "A look inside...", "status.cannot_reblog": "没法转嘟这条嘟文啦……", "status.delete": "删除", + "status.embed": "Embed", "status.favourite": "赞", "status.load_more": "加载更多", "status.media_hidden": "隐藏媒体内容", "status.mention": "提及 @{name}", "status.mute_conversation": "Mute conversation", "status.open": "展开嘟文", + "status.pin": "Pin on profile", "status.reblog": "转嘟", "status.reblogged_by": "{name} 转嘟", "status.reply": "回应", @@ -179,6 +183,7 @@ "status.show_less": "减少显示", "status.show_more": "显示更多", "status.unmute_conversation": "Unmute conversation", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "撰写", "tabs_bar.federated_timeline": "跨站", "tabs_bar.home": "主页", diff --git a/app/javascript/mastodon/locales/zh-HK.json b/app/javascript/mastodon/locales/zh-HK.json index 7312aae82..d689cd5ae 100644 --- a/app/javascript/mastodon/locales/zh-HK.json +++ b/app/javascript/mastodon/locales/zh-HK.json @@ -63,6 +63,8 @@ "confirmations.mute.message": "你確定要將{name}靜音嗎?", "confirmations.unfollow.confirm": "Unfollow", "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?", + "embed.instructions": "Embed this status on your website by copying the code below.", + "embed.preview": "Here is what it will look like:", "emoji_button.activity": "活動", "emoji_button.flags": "旗幟", "emoji_button.food": "飲飲食食", @@ -162,12 +164,14 @@ "standalone.public_title": "A look inside...", "status.cannot_reblog": "這篇文章無法被轉推", "status.delete": "刪除", + "status.embed": "Embed", "status.favourite": "喜歡", "status.load_more": "載入更多", "status.media_hidden": "隱藏媒體內容", "status.mention": "提及 @{name}", "status.mute_conversation": "Mute conversation", "status.open": "展開文章", + "status.pin": "Pin on profile", "status.reblog": "轉推", "status.reblogged_by": "{name} 轉推", "status.reply": "回應", @@ -179,6 +183,7 @@ "status.show_less": "減少顯示", "status.show_more": "顯示更多", "status.unmute_conversation": "Unmute conversation", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "撰寫", "tabs_bar.federated_timeline": "跨站", "tabs_bar.home": "主頁", diff --git a/app/javascript/mastodon/locales/zh-TW.json b/app/javascript/mastodon/locales/zh-TW.json index 1c2e35272..dcb9d7f3c 100644 --- a/app/javascript/mastodon/locales/zh-TW.json +++ b/app/javascript/mastodon/locales/zh-TW.json @@ -63,6 +63,8 @@ "confirmations.mute.message": "你確定要消音 {name} ?", "confirmations.unfollow.confirm": "Unfollow", "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?", + "embed.instructions": "Embed this status on your website by copying the code below.", + "embed.preview": "Here is what it will look like:", "emoji_button.activity": "活動", "emoji_button.flags": "旗幟", "emoji_button.food": "食物與飲料", @@ -162,12 +164,14 @@ "standalone.public_title": "A look inside...", "status.cannot_reblog": "此貼文無法轉推", "status.delete": "刪除", + "status.embed": "Embed", "status.favourite": "喜愛", "status.load_more": "載入更多", "status.media_hidden": "媒體已隱藏", "status.mention": "提到 @{name}", "status.mute_conversation": "消音對話", "status.open": "展開這個狀態", + "status.pin": "Pin on profile", "status.reblog": "轉推", "status.reblogged_by": "{name} 轉推了", "status.reply": "回應", @@ -179,6 +183,7 @@ "status.show_less": "看少點", "status.show_more": "看更多", "status.unmute_conversation": "不消音對話", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "編輯", "tabs_bar.federated_timeline": "聯盟", "tabs_bar.home": "家", diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js index 07207c93b..e7a3567b4 100644 --- a/app/javascript/mastodon/reducers/compose.js +++ b/app/javascript/mastodon/reducers/compose.js @@ -149,10 +149,20 @@ const privacyPreference = (a, b) => { } }; +const hydrate = (state, hydratedState) => { + state = clearAll(state.merge(hydratedState)); + + if (hydratedState.has('text')) { + state = state.set('text', hydratedState.get('text')); + } + + return state; +}; + export default function compose(state = initialState, action) { switch(action.type) { case STORE_HYDRATE: - return clearAll(state.merge(action.state.get('compose'))); + return hydrate(state, action.state.get('compose')); case COMPOSE_MOUNT: return state.set('mounted', true); case COMPOSE_UNMOUNT: diff --git a/app/javascript/mastodon/reducers/status_lists.js b/app/javascript/mastodon/reducers/status_lists.js index bbc973302..2ce27a454 100644 --- a/app/javascript/mastodon/reducers/status_lists.js +++ b/app/javascript/mastodon/reducers/status_lists.js @@ -3,6 +3,10 @@ import { FAVOURITED_STATUSES_EXPAND_SUCCESS, } from '../actions/favourites'; import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { + FAVOURITE_SUCCESS, + UNFAVOURITE_SUCCESS, +} from '../actions/interactions'; const initialState = ImmutableMap({ favourites: ImmutableMap({ @@ -27,12 +31,28 @@ const appendToList = (state, listType, statuses, next) => { })); }; +const prependOneToList = (state, listType, status) => { + return state.update(listType, listMap => listMap.withMutations(map => { + map.set('items', map.get('items').unshift(status.get('id'))); + })); +}; + +const removeOneFromList = (state, listType, status) => { + return state.update(listType, listMap => listMap.withMutations(map => { + map.set('items', map.get('items').filter(item => item !== status.get('id'))); + })); +}; + export default function statusLists(state = initialState, action) { switch(action.type) { case FAVOURITED_STATUSES_FETCH_SUCCESS: return normalizeList(state, 'favourites', action.statuses, action.next); case FAVOURITED_STATUSES_EXPAND_SUCCESS: return appendToList(state, 'favourites', action.statuses, action.next); + case FAVOURITE_SUCCESS: + return prependOneToList(state, 'favourites', action.status); + case UNFAVOURITE_SUCCESS: + return removeOneFromList(state, 'favourites', action.status); default: return state; } diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js index 3e40b0b42..38691dc43 100644 --- a/app/javascript/mastodon/reducers/statuses.js +++ b/app/javascript/mastodon/reducers/statuses.js @@ -7,6 +7,8 @@ import { FAVOURITE_SUCCESS, FAVOURITE_FAIL, UNFAVOURITE_SUCCESS, + PIN_SUCCESS, + UNPIN_SUCCESS, } from '../actions/interactions'; import { STATUS_FETCH_SUCCESS, @@ -114,6 +116,8 @@ export default function statuses(state = initialState, action) { case UNREBLOG_SUCCESS: case FAVOURITE_SUCCESS: case UNFAVOURITE_SUCCESS: + case PIN_SUCCESS: + case UNPIN_SUCCESS: return normalizeStatus(state, action.response); case FAVOURITE_REQUEST: return state.setIn([action.status.get('id'), 'favourited'], true); diff --git a/app/javascript/mastodon/scroll.js b/app/javascript/mastodon/scroll.js index 44f95b17f..2af07e0fb 100644 --- a/app/javascript/mastodon/scroll.js +++ b/app/javascript/mastodon/scroll.js @@ -26,5 +26,5 @@ const scroll = (node, key, target) => { }; }; -export const scrollRight = (node) => scroll(node, 'scrollLeft', node.scrollWidth); +export const scrollRight = (node, position) => scroll(node, 'scrollLeft', position); export const scrollTop = (node) => scroll(node, 'scrollTop', 0); diff --git a/app/javascript/mastodon/service_worker/web_push_notifications.js b/app/javascript/mastodon/service_worker/web_push_notifications.js index acb85f626..f63cff335 100644 --- a/app/javascript/mastodon/service_worker/web_push_notifications.js +++ b/app/javascript/mastodon/service_worker/web_push_notifications.js @@ -31,8 +31,8 @@ const notify = options => const group = cloneNotification(notifications[0]); group.title = formatGroupTitle(group.data.message, group.data.count + 1); - group.body = `${options.title}\n${group.body}`; - group.data = { ...group.data, count: group.data.count + 1 }; + group.body = `${options.title}\n${group.body}`; + group.data = { ...group.data, count: group.data.count + 1 }; return self.registration.showNotification(group.title, group); } @@ -43,18 +43,18 @@ const notify = options => const handlePush = (event) => { const options = event.data.json(); - options.body = options.data.nsfw || options.data.content; - options.image = options.image || undefined; // Null results in a network request (404) + options.body = options.data.nsfw || options.data.content; + options.dir = options.data.dir; + options.image = options.image || undefined; // Null results in a network request (404) options.timestamp = options.timestamp && new Date(options.timestamp); const expandAction = options.data.actions.find(action => action.todo === 'expand'); if (expandAction) { - options.actions = [expandAction]; - options.hiddenActions = options.data.actions.filter(action => action !== expandAction); - + options.actions = [expandAction]; + options.hiddenActions = options.data.actions.filter(action => action !== expandAction); options.data.hiddenImage = options.image; - options.image = undefined; + options.image = undefined; } else { options.actions = options.data.actions; } @@ -75,8 +75,8 @@ const cloneNotification = (notification) => { const expandNotification = (notification) => { const nextNotification = cloneNotification(notification); - nextNotification.body = notification.data.content; - nextNotification.image = notification.data.hiddenImage; + nextNotification.body = notification.data.content; + nextNotification.image = notification.data.hiddenImage; nextNotification.actions = notification.data.actions.filter(action => action.todo !== 'expand'); return self.registration.showNotification(nextNotification.title, nextNotification); @@ -105,8 +105,7 @@ const openUrl = url => const webClients = clientList.filter(client => /\/web\//.test(client.url)); if (webClients.length !== 0) { - const client = findBestClient(webClients); - + const client = findBestClient(webClients); const { pathname } = new URL(url); if (pathname.startsWith('/web/')) { @@ -126,8 +125,7 @@ const openUrl = url => }); const removeActionFromNotification = (notification, action) => { - const actions = notification.actions.filter(act => act.action !== action.action); - + const actions = notification.actions.filter(act => act.action !== action.action); const nextNotification = cloneNotification(notification); nextNotification.actions = actions; diff --git a/app/javascript/mastodon/web_push_subscription.js b/app/javascript/mastodon/web_push_subscription.js index 96ac63b52..3dbed09ea 100644 --- a/app/javascript/mastodon/web_push_subscription.js +++ b/app/javascript/mastodon/web_push_subscription.js @@ -48,7 +48,6 @@ export function register () { if (supportsPushNotifications) { if (!getApplicationServerKey()) { - // eslint-disable-next-line no-console console.error('The VAPID public key is not set. You will not be able to receive Web Push Notifications.'); return; } @@ -84,10 +83,8 @@ export function register () { }) .catch(error => { if (error.code === 20 && error.name === 'AbortError') { - // eslint-disable-next-line no-console console.warn('Your browser supports Web Push Notifications, but does not seem to implement the VAPID protocol.'); } else if (error.code === 5 && error.name === 'InvalidCharacterError') { - // eslint-disable-next-line no-console console.error('The VAPID public key seems to be invalid:', getApplicationServerKey()); } @@ -103,7 +100,6 @@ export function register () { } }); } else { - // eslint-disable-next-line no-console console.warn('Your browser does not support Web Push Notifications.'); } } diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js index e9bb4a42e..2058fad91 100644 --- a/app/javascript/packs/public.js +++ b/app/javascript/packs/public.js @@ -36,8 +36,20 @@ function main() { [].forEach.call(document.querySelectorAll('time.time-ago'), (content) => { const datetime = new Date(content.getAttribute('datetime')); + content.title = dateTimeFormat.format(datetime); content.textContent = relativeFormat.format(datetime); }); + + [].forEach.call(document.querySelectorAll('.logo-button'), (content) => { + content.addEventListener('click', (e) => { + e.preventDefault(); + window.open(e.target.href, 'mastodon-intent', 'width=400,height=400,resizable=no,menubar=no,status=no,scrollbars=yes'); + }); + }); + + if (window.parent) { + window.parent.postMessage(['setHeight', document.getElementsByTagName('html')[0].scrollHeight], '*'); + } }); delegate(document, '.video-player video', 'click', ({ target }) => { diff --git a/app/javascript/packs/share.js b/app/javascript/packs/share.js new file mode 100644 index 000000000..51e4ae38b --- /dev/null +++ b/app/javascript/packs/share.js @@ -0,0 +1,24 @@ +import loadPolyfills from '../mastodon/load_polyfills'; + +require.context('../images/', true); + +function loaded() { + const ComposeContainer = require('../mastodon/containers/compose_container').default; + const React = require('react'); + const ReactDOM = require('react-dom'); + const mountNode = document.getElementById('mastodon-compose'); + + if (mountNode !== null) { + const props = JSON.parse(mountNode.getAttribute('data-props')); + ReactDOM.render(<ComposeContainer {...props} />, mountNode); + } +} + +function main() { + const ready = require('../mastodon/ready').default; + ready(loaded); +} + +loadPolyfills().then(main).catch(error => { + console.error(error); +}); diff --git a/app/javascript/styles/about.scss b/app/javascript/styles/about.scss index 66da44086..28924738a 100644 --- a/app/javascript/styles/about.scss +++ b/app/javascript/styles/about.scss @@ -1,52 +1,96 @@ -.about-body { - .wrapper { - max-width: 600px; - margin: 0 auto; +.landing-page { + p, + li { + font-family: 'mastodon-font-sans-serif', sans-serif; + font-size: 16px; + font-weight: 400; + font-size: 16px; + line-height: 30px; + margin-bottom: 12px; color: $ui-primary-color; - padding-top: 50px; - padding-bottom: 50px; - &.thicc { - max-width: 800px; + a { + color: $ui-highlight-color; + text-decoration: underline; } } + em { + display: inline; + margin: 0; + padding: 0; + font-weight: 500; + background: transparent; + font-family: inherit; + font-size: inherit; + line-height: inherit; + color: lighten($ui-primary-color, 10%); + } + h1 { - font: 46px/52px 'mastodon-font-sans-serif', sans-serif; - font-weight: 600; + font-family: 'mastodon-font-display', sans-serif; + font-size: 26px; + line-height: 30px; + font-weight: 500; margin-bottom: 20px; - color: $ui-highlight-color; - padding: 20px 0; + color: $ui-secondary-color; - img { - margin-bottom: -5px; - margin-right: 5px; - width: 46px; - height: 46px; + small { + font-family: 'mastodon-font-sans-serif', sans-serif; + display: block; + font-size: 18px; + font-weight: 400; + color: $ui-base-lighter-color; } } h2 { font-family: 'mastodon-font-display', sans-serif; - font-size: 24px; - line-height: 28px; - font-weight: 400; + font-size: 22px; + line-height: 26px; + font-weight: 500; margin-bottom: 20px; - color: $primary-text-color; + color: $ui-secondary-color; } h3 { font-family: 'mastodon-font-display', sans-serif; - font-size: 20px; - line-height: 28px; - font-weight: 400; + font-size: 18px; + line-height: 24px; + font-weight: 500; + margin-bottom: 20px; + color: $ui-secondary-color; + } + + h4 { + font-family: 'mastodon-font-display', sans-serif; + font-size: 16px; + line-height: 24px; + font-weight: 500; + margin-bottom: 20px; + color: $ui-secondary-color; + } + + h5 { + font-family: 'mastodon-font-display', sans-serif; + font-size: 14px; + line-height: 24px; + font-weight: 500; + margin-bottom: 20px; + color: $ui-secondary-color; + } + + h6 { + font-family: 'mastodon-font-display', sans-serif; + font-size: 12px; + line-height: 24px; + font-weight: 500; margin-bottom: 20px; color: $ui-secondary-color; } ul, ol { - list-style: inherit; margin-left: 20px; &[type='a'] { @@ -58,219 +102,29 @@ } } - li > ol, - li > ul { - margin-top: 20px; - } - - p, - li { - font: 16px/28px 'mastodon-font-sans-serif', sans-serif; - font-weight: 400; - margin-bottom: 12px; - - a { - color: $ui-highlight-color; - text-decoration: underline; - } - } - - em { - display: inline-block; - padding: 7px 7px 5px; - margin: 0 2px; - background: $ui-primary-color; - color: $ui-base-color; - font: 16px/16px 'mastodon-font-sans-serif', sans-serif; - font-weight: 300; - } - - .screenshot { - box-shadow: 0 0 15px rgba($base-shadow-color, 0.4); - margin-bottom: 26px; - - img { - max-width: 100%; - height: auto; - display: block; - } + ul { + list-style: disc; } - .actions { - overflow: hidden; - margin-bottom: 20px; - - .info { - float: right; - text-align: right; - line-height: 36px; - - a { - color: $ui-primary-color; - text-decoration: underline; - } - } + ol { + list-style: decimal; } - @media screen and (max-width: 625px) { - .wrapper { - padding: 20px; - } + li > ol, + li > ul { + margin-top: 6px; } -} - -.information-board { - background: darken($ui-base-color, 4%); - padding: 20px 0; - - .panel { - position: absolute; - width: 280px; - box-sizing: border-box; - background: darken($ui-base-color, 8%); - padding: 20px; - padding-top: 10px; - border-radius: 4px 4px 0 0; - right: 0; - bottom: -40px; - - .panel-header { - font-family: 'mastodon-font-display', sans-serif; - font-size: 14px; - line-height: 24px; - font-weight: 500; - color: $ui-base-lighter-color; - padding-bottom: 5px; - margin-bottom: 15px; - border-bottom: 1px solid lighten($ui-base-color, 4%); - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - - a, - span { - font-weight: 400; - color: lighten($ui-base-color, 34%); - } - a { - text-decoration: none; - } - } + hr { + border-color: rgba($ui-base-lighter-color, .6); } .container { - position: relative; - padding-right: 280px + 15px; - } - - .information-board-sections { - display: flex; - justify-content: space-between; - flex-wrap: wrap; - } - - .section { - flex: 1 0 0; - font: 16px/28px 'mastodon-font-sans-serif', sans-serif; - text-align: right; - padding: 10px 15px; - - span, - strong { - display: block; - } - - span { - font-size: 16px; - - &:last-child { - color: $ui-secondary-color; - } - } - - strong { - font-weight: 500; - font-size: 32px; - line-height: 48px; - color: $primary-text-color; - } - } -} - -.owner { - text-align: center; - - .avatar { - @include avatar-size(80px); + width: 100%; + box-sizing: border-box; + max-width: 800px; margin: 0 auto; - margin-bottom: 15px; - - img { - @include avatar-radius(); - @include avatar-size(80px); - display: block; - } - } - - .name { - font-size: 14px; - - a { - display: block; - color: $primary-text-color; - text-decoration: none; - - &:hover { - .display_name { - text-decoration: underline; - } - } - } - - .username { - display: block; - color: $ui-primary-color; - } - } -} - -.features-list__row { - display: flex; - padding: 10px 0; - justify-content: space-between; - - &:first-child { - padding-top: 0; - } - - .visual { - flex: 0 0 auto; - display: flex; - align-items: center; - margin-left: 15px; - - .fa { - display: block; - color: $ui-primary-color; - font-size: 48px; - } - } - - .text { - font-size: 16px; - line-height: 30px; - color: $ui-base-lighter-color; - - h6 { - font-weight: 500; - color: $ui-primary-color; - } } -} - -.landing-page { - $lp-par-color: lighten($ui-base-color, 36%); .header-wrapper { padding-top: 15px; @@ -284,13 +138,16 @@ .hero .heading { padding-bottom: 30px; + font-family: 'mastodon-font-sans-serif', sans-serif; + font-size: 16px; + font-weight: 400; + font-size: 16px; + line-height: 30px; + color: $ui-primary-color; - p, li { - color: lighten($ui-base-color, 50%); - } - - li { - margin: 2px 0; + a { + color: $ui-highlight-color; + text-decoration: underline; } } } @@ -315,17 +172,6 @@ } } - p, - li { - font: inherit; - font-weight: inherit; - margin-bottom: 0; - } - - hr { - border-color: rgba($ui-base-lighter-color, .6); - } - .header { line-height: 30px; overflow: hidden; @@ -335,6 +181,62 @@ justify-content: space-between; } + .links { + position: relative; + z-index: 4; + + a { + display: flex; + justify-content: center; + align-items: center; + color: $ui-primary-color; + text-decoration: none; + padding: 12px 16px; + line-height: 32px; + font-family: 'mastodon-font-display', sans-serif; + font-weight: 500; + font-size: 14px; + + &:hover { + color: $ui-secondary-color; + } + } + + .brand { + a { + padding-left: 0; + padding-right: 0; + color: $white; + } + + img { + height: 32px; + position: relative; + top: 4px; + left: -10px; + } + } + + 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; @@ -387,6 +289,12 @@ } } + .heading { + position: relative; + z-index: 4; + padding-bottom: 150px; + } + .simple_form, .closed-registrations-message { background: darken($ui-base-color, 4%); @@ -408,12 +316,6 @@ } } - .heading { - position: relative; - z-index: 4; - padding-bottom: 150px; - } - .closed-registrations-message { min-height: 330px; display: flex; @@ -421,136 +323,140 @@ justify-content: space-between; } } + } - .links { + .about-short { + background: darken($ui-base-color, 4%); + padding: 50px 0; + font-family: 'mastodon-font-sans-serif', sans-serif; + font-size: 16px; + font-weight: 400; + font-size: 16px; + line-height: 30px; + color: $ui-primary-color; + + a { + color: $ui-highlight-color; + text-decoration: underline; + } + } + + .information-board { + background: darken($ui-base-color, 4%); + padding: 20px 0; + + .container { position: relative; - z-index: 4; + padding-right: 280px + 15px; + } - ul { - list-style: none; - margin: 0; + .information-board-sections { + display: flex; + justify-content: space-between; + flex-wrap: wrap; + } - li { - display: inline-block; - vertical-align: bottom; - margin: 0; + .section { + flex: 1 0 0; + font-family: 'mastodon-font-sans-serif', sans-serif; + font-size: 16px; + line-height: 28px; + color: $primary-text-color; + text-align: right; + padding: 10px 15px; - &:last-child a { - padding-right: 0; - } - } + span, + strong { + display: block; } - a { - display: flex; - justify-content: center; - align-items: center; - color: $ui-primary-color; - text-decoration: none; - padding: 12px 16px; - line-height: 32px; - font-family: 'mastodon-font-display', sans-serif; - font-weight: 500; - font-size: 14px; - - &:hover { + span { + &:last-child { color: $ui-secondary-color; } } - .brand { - a { - padding-left: 0; - padding-right: 0; - color: $white; - } - - img { - height: 32px; - position: relative; - top: 4px; - left: -10px; - } + strong { + font-weight: 500; + font-size: 32px; + line-height: 48px; } } - } - - .container { - width: 100%; - box-sizing: border-box; - max-width: 800px; - margin: 0 auto; - } - - .wrapper { - max-width: 800px; - margin: 0 auto; - padding: 0; - } - - .learn-more-cta, .extended-description { - padding: 50px 0; - font-weight: 400; - color: $lp-par-color; - font: 16px/1.6 'mastodon-font-sans-serif', sans-serif; - ul, - ol { - list-style: inherit; - margin-left: 20px; + .panel { + position: absolute; + width: 280px; + box-sizing: border-box; + background: darken($ui-base-color, 8%); + padding: 20px; + padding-top: 10px; + border-radius: 4px 4px 0 0; + right: 0; + bottom: -40px; - &[type='a'] { - list-style-type: lower-alpha; - } + .panel-header { + font-family: 'mastodon-font-display', sans-serif; + font-size: 14px; + line-height: 24px; + font-weight: 500; + color: $ui-primary-color; + padding-bottom: 5px; + margin-bottom: 15px; + border-bottom: 1px solid lighten($ui-base-color, 4%); + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + + a, + span { + font-weight: 400; + color: darken($ui-primary-color, 10%); + } - &[type='i'] { - list-style-type: lower-roman; + a { + text-decoration: none; + } } } - li > ol, - li > ul { - margin-top: 20px; - } + .owner { + text-align: center; - p, - li { - color: $lp-par-color; - margin-bottom: 6px; + .avatar { + width: 80px; + height: 80px; + margin: 0 auto; + margin-bottom: 15px; - a { - color: $ui-highlight-color; - text-decoration: underline; + img { + display: block; + width: 80px; + height: 80px; + border-radius: 48px; + } } - } - - li { - margin: 2px 0; - } - } - .learn-more-cta { - background: darken($ui-base-color, 4%); - padding: 50px 0; - p { - font-size: 16px; - line-height: 28px; - } - } + .name { + font-size: 14px; - h3 { - font-family: 'mastodon-font-display', sans-serif; - font-size: 16px; - line-height: 24px; - font-weight: 500; - margin-bottom: 20px; - color: $ui-primary-color; - } + a { + display: block; + color: $primary-text-color; + text-decoration: none; + + &:hover { + .display_name { + text-decoration: underline; + } + } + } - p { - font-size: 16px; - line-height: 28px; - color: $lp-par-color; + .username { + display: block; + color: $ui-primary-color; + } + } + } } .features { @@ -559,100 +465,121 @@ .container { display: flex; } - } - #mastodon-timeline { - display: flex; - -webkit-overflow-scrolling: touch; - -ms-overflow-style: -ms-autohiding-scrollbar; - font-family: 'mastodon-font-sans-serif', sans-serif; - font-size: 13px; - line-height: 18px; - font-weight: 400; - color: $primary-text-color; - width: 330px; - margin-right: 30px; - flex: 0 0 auto; - background: $ui-base-color; - overflow: hidden; - box-shadow: 0 0 6px rgba($black, 0.1); + #mastodon-timeline { + display: flex; + -webkit-overflow-scrolling: touch; + -ms-overflow-style: -ms-autohiding-scrollbar; + font-family: 'mastodon-font-sans-serif', sans-serif; + font-size: 13px; + line-height: 18px; + font-weight: 400; + color: $primary-text-color; + width: 330px; + margin-right: 30px; + flex: 0 0 auto; + background: $ui-base-color; + overflow: hidden; + box-shadow: 0 0 6px rgba($black, 0.1); + + .column-header { + color: inherit; + font-family: inherit; + font-size: 16px; + line-height: inherit; + font-weight: inherit; + margin: 0; + padding: 15px; + } - .column-header { - color: inherit; - font-family: inherit; - font-size: 16px; - line-height: inherit; - font-weight: inherit; - margin: 0; - padding: 15px; - } + .column { + padding: 0; + border-radius: 4px; + overflow: hidden; + } - .column { - padding: 0; - border-radius: 4px; - overflow: hidden; - } + .scrollable { + height: 400px; + } - .scrollable { - height: 400px; - } + p { + font-size: inherit; + line-height: inherit; + font-weight: inherit; + color: $primary-text-color; + margin-bottom: 20px; - p { - font-size: inherit; - line-height: inherit; - font-weight: inherit; - color: $primary-text-color; - margin-bottom: 20px; + &:last-child { + margin-bottom: 0; + } - &:last-child { - margin-bottom: 0; + a { + color: $ui-secondary-color; + text-decoration: none; + } } + } - a { - color: $ui-secondary-color; - text-decoration: none; + .about-mastodon { + max-width: 675px; + + p { + margin-bottom: 20px; } - } - } - .about-mastodon { - max-width: 675px; + .features-list { + margin-top: 20px; - p { - margin-bottom: 20px; - } + .features-list__row { + display: flex; + padding: 10px 0; + justify-content: space-between; - .features-list { - margin-top: 20px; - } - } + &:first-child { + padding-top: 0; + } - em { - display: inline; - margin: 0; - padding: 0; - font-weight: 500; - background: transparent; - font-family: inherit; - font-size: inherit; - line-height: inherit; - color: $ui-primary-color; + .visual { + flex: 0 0 auto; + display: flex; + align-items: center; + margin-left: 15px; + + .fa { + display: block; + color: $ui-primary-color; + font-size: 48px; + } + } + + .text { + font-size: 16px; + line-height: 30px; + color: $ui-primary-color; + + h6 { + font-size: inherit; + line-height: inherit; + margin-bottom: 0; + } + } + } + } + } } - h1 { - font-family: 'mastodon-font-display', sans-serif; - font-size: 26px; + .extended-description { + padding: 50px 0; + font-family: 'mastodon-font-sans-serif', sans-serif; + font-size: 16px; + font-weight: 400; + font-size: 16px; line-height: 30px; - margin-bottom: 0; - font-weight: 500; - color: $ui-secondary-color; + color: $ui-primary-color; - small { - font-family: 'mastodon-font-sans-serif', sans-serif; - display: block; - font-size: 18px; - font-weight: 400; - color: $ui-base-lighter-color; + a { + color: $ui-highlight-color; + text-decoration: underline; } } @@ -676,8 +603,15 @@ padding: 0 20px; } - .information-board .container { - padding-right: 20px; + .information-board { + + .container { + padding-right: 20px; + } + + .section { + text-align: center; + } .panel { position: static; @@ -691,10 +625,6 @@ } } - .information-board .section { - text-align: center; - } - .header-wrapper .mascot { left: 20px; } @@ -712,6 +642,7 @@ &.compact .hero .heading { padding-bottom: 20px; + text-align: initial; } } @@ -720,51 +651,41 @@ display: block; } - .links { - padding-top: 15px; - background: darken($ui-base-color, 4%); - } - .header { - .hero { - margin-top: 30px; - padding: 0; + .links { + padding-top: 15px; + background: darken($ui-base-color, 4%); - .heading { - padding: 0 20px 20px; + a { + padding: 12px 8px; } - } - - .floats { - display: none; - } - .heading, - .nav { - text-align: center; - } + .nav { + display: flex; + flex-flow: row wrap; + justify-content: space-around; + } - .nav { - display: flex; - flex-flow: row wrap; - justify-content: space-around; + .brand img { + left: 0; + top: 0; + } } - .links a { - padding: 12px 8px; - } + .hero { + margin-top: 30px; + padding: 0; - .heading h1 { - padding: 30px 0; - } + .floats { + display: none; + } - .links .brand img { - left: 0; - top: 0; - } + .heading { + padding: 30px 20px; + text-align: center; + } - .hero { .simple_form, .closed-registrations-message { background: darken($ui-base-color, 8%); @@ -775,7 +696,7 @@ } } - #mastodon-timeline { + .features #mastodon-timeline { height: 70vh; width: 100%; margin-bottom: 50px; diff --git a/app/javascript/styles/accounts.scss b/app/javascript/styles/accounts.scss index 5a9105109..744650554 100644 --- a/app/javascript/styles/accounts.scss +++ b/app/javascript/styles/accounts.scss @@ -1,34 +1,48 @@ .card { - display: flex; - background: $ui-base-color; + background-color: lighten($ui-base-color, 4%); background-size: cover; background-position: center; border-radius: 4px 4px 0 0; box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); overflow: hidden; + position: relative; + display: flex; + + &::after { + background: rgba(darken($ui-base-color, 8%), 0.5); + display: block; + content: ""; + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + z-index: 1; + } @media screen and (max-width: 740px) { border-radius: 0; box-shadow: none; } - .details { + .card__illustration { + padding: 60px 0; position: relative; - padding: 60px 0 0; - text-align: center; - flex: auto; + flex: 1 1 auto; + display: flex; + justify-content: center; + align-items: center; + } - &::after { - background: linear-gradient(rgba($base-shadow-color, 0.5), rgba($base-shadow-color, 0.8)); - display: block; - content: ""; - position: absolute; - left: 0; - top: 0; - width: 100%; - height: 100%; - z-index: 1; - } + .card__bio { + max-width: 260px; + flex: 1 1 auto; + display: flex; + flex-direction: column; + justify-content: space-between; + background: rgba(darken($ui-base-color, 8%), 0.8); + position: relative; + z-index: 2; } &.compact { @@ -46,14 +60,15 @@ .name { display: block; - position: relative; font-size: 20px; line-height: 18px * 1.5; color: $primary-text-color; + padding: 10px 15px; + padding-bottom: 0; font-weight: 500; - text-align: center; - text-shadow: 0 0 2px $base-shadow-color; + position: relative; z-index: 2; + margin-bottom: 30px; small { display: block; @@ -64,56 +79,102 @@ } .avatar { - position: relative; - @include avatar-size(120px); + width: 120px; margin: 0 auto; - margin-bottom: 15px; + position: relative; z-index: 2; img { - @include avatar-radius(); - @include avatar-size(120px); + width: 120px; + height: 120px; display: block; + border-radius: 120px; + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); } } .controls { position: absolute; - top: 10px; - right: 10px; + top: 15px; + left: 15px; z-index: 2; + + .icon-button { + color: rgba($white, 0.8); + text-decoration: none; + font-size: 13px; + line-height: 13px; + font-weight: 500; + + .fa { + font-weight: 400; + margin-right: 5px; + } + + &:hover, + &:active, + &:focus { + color: $white; + } + } + } + + .roles { + margin-bottom: 30px; + padding: 0 15px; } .details-counters { - display: inline-flex; - position: relative; + margin-top: 30px; + display: flex; flex-direction: row; - margin: 15px 0; - z-index: 2; + width: 100%; } .counter { - width: 80px; + width: 33.3%; + box-sizing: border-box; + flex: 0 0 auto; color: $ui-primary-color; padding: 5px 10px 0; + margin-bottom: 10px; + border-right: 1px solid lighten($ui-base-color, 4%); cursor: default; + text-align: center; position: relative; - & + .counter { - border-left: 1px solid $ui-primary-color; + a { + display: block; + } + + &:last-child { + border-right: 0; } - & > * { - opacity: .7; - transition: opacity .3s ease; + &::after { + display: block; + content: ""; + position: absolute; + bottom: -10px; + left: 0; + width: 100%; + border-bottom: 4px solid $ui-primary-color; + opacity: 0.5; + transition: all 400ms ease; } - &.active > *, &:hover > * { - opacity: 1; + &.active { + &::after { + border-bottom: 4px solid $ui-highlight-color; + opacity: 1; + } } - a { - display: block; + &:hover { + &::after { + opacity: 1; + transition-duration: 100ms; + } } a { @@ -123,87 +184,40 @@ .counter-label { font-size: 12px; - text-transform: uppercase; display: block; margin-bottom: 5px; - text-shadow: 0 0 2px $base-shadow-color; } .counter-number { font-weight: 500; font-size: 18px; color: $primary-text-color; + font-family: 'mastodon-font-display', sans-serif; } } .bio { - position: relative; font-size: 14px; line-height: 18px; - margin: 15px 0; - padding: 5px 10px; + padding: 0 15px; color: $ui-secondary-color; - z-index: 2; - } - - .metadata { - position: relative; - min-width: 180px; - max-width: 40%; - background: rgba($base-shadow-color, 0.8); - color: $primary-text-color; - text-align: left; - overflow-y: auto; - white-space: pre-wrap; - z-index: 3; - - .metadata-item { - border-bottom: 1px $ui-primary-color solid; - padding: 15px 10px; - font-size: 18px; - line-height: 24px; - overflow: hidden; - text-overflow: ellipsis; - - a { - color: $ui-highlight-color; - text-decoration: none; - - &:hover { - text-decoration: underline; - } - } - - b { - display: block; - font-size: 12px; - line-height: 16px; - text-transform: uppercase; - color: $ui-primary-color; - - a { - color: $ui-primary-color; - } - } - } } -} - - - -@media screen and (max-width: 500px) { - .card { + @media screen and (max-width: 480px) { display: block; - .metadata { + .card__bio { max-width: none; - background: $base-shadow-color; - border-top: 1px $ui-primary-color solid; + } - .metadata-item { - padding: 15px 20px; - } + .name, + .roles { + text-align: center; + margin-bottom: 15px; + } + + .bio { + margin-bottom: 15px; } } } @@ -282,7 +296,9 @@ } .next, - .prev { + .prev, + .next a, + .prev a { display: inline-block; } } @@ -290,13 +306,15 @@ .accounts-grid { box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); - background: $simple-background-color; + background: darken($simple-background-color, 8%); border-radius: 0 0 4px 4px; - padding: 20px 10px; + padding: 20px 5px; padding-bottom: 10px; overflow: hidden; display: flex; flex-wrap: wrap; + z-index: 2; + position: relative; @media screen and (max-width: 740px) { border-radius: 0; @@ -306,35 +324,64 @@ .account-grid-card { box-sizing: border-box; width: 335px; - border: 1px solid $ui-secondary-color; + background: $simple-background-color; border-radius: 4px; color: $ui-base-color; - margin-bottom: 10px; + margin: 0 5px 10px; + position: relative; - &:nth-child(odd) { - margin-right: 10px; + @media screen and (max-width: 740px) { + width: calc(100% - 10px); } .account-grid-card__header { overflow: hidden; - padding: 10px; - border-bottom: 1px solid $ui-secondary-color; + height: 100px; + border-radius: 4px 4px 0 0; + background-color: lighten($ui-base-color, 4%); + background-size: cover; + background-position: center; + position: relative; + + &::after { + background: rgba(darken($ui-base-color, 8%), 0.5); + display: block; + content: ""; + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + z-index: 1; + } + } + + .account-grid-card__avatar { + box-sizing: border-box; + padding: 15px; + position: absolute; + z-index: 2; + top: 100px - (40px + 2px); + left: -2px; } .avatar { - @include avatar-size(60px); - float: left; - margin-right: 15px; + width: 80px; + height: 80px; img { - @include avatar-radius(); - @include avatar-size(60px); display: block; + width: 80px; + height: 80px; + border-radius: 80px; + border: 2px solid $simple-background-color; } } .name { + padding: 15px; padding-top: 10px; + padding-left: 15px + 80px + 15px; a { display: block; @@ -342,6 +389,7 @@ text-decoration: none; text-overflow: ellipsis; overflow: hidden; + font-weight: 500; &:hover { .display_name { @@ -352,30 +400,38 @@ } .display_name { - font-size: 14px; + font-size: 16px; display: block; + text-overflow: ellipsis; + overflow: hidden; } .username { - color: $ui-highlight-color; + color: lighten($ui-base-color, 34%); + font-size: 14px; + font-weight: 400; } .note { - padding: 10px; + padding: 10px 15px; padding-top: 15px; - color: $ui-primary-color; + box-sizing: border-box; + color: lighten($ui-base-color, 26%); word-wrap: break-word; + min-height: 80px; } } } .nothing-here { + width: 100%; + display: block; color: $ui-primary-color; font-size: 14px; font-weight: 500; text-align: center; - padding: 15px 0; - padding-bottom: 25px; + padding: 60px 0; + padding-top: 55px; cursor: default; } @@ -396,14 +452,15 @@ } & > div { - @include avatar-size(48px); float: left; margin-right: 10px; + width: 48px; + height: 48px; } .avatar { - @include avatar-radius(); display: block; + border-radius: 4px; } .display-name { @@ -439,3 +496,43 @@ color: $ui-base-color; } } + +.activity-stream-tabs { + background: $simple-background-color; + border-bottom: 1px solid $ui-secondary-color; + position: relative; + z-index: 2; + + a { + display: inline-block; + padding: 15px; + text-decoration: none; + color: $ui-highlight-color; + text-transform: uppercase; + font-weight: 500; + + &:hover, + &:active, + &:focus { + color: lighten($ui-highlight-color, 8%); + } + + &.active { + color: $ui-base-color; + cursor: default; + } + } +} + +.account-role { + display: inline-block; + padding: 4px 6px; + cursor: default; + border-radius: 3px; + font-size: 12px; + line-height: 12px; + font-weight: 500; + color: $success-green; + background-color: rgba($success-green, 0.1); + border: 1px solid rgba($success-green, 0.5); +} diff --git a/app/javascript/styles/admin.scss b/app/javascript/styles/admin.scss index 4c3bbdfc5..b86de75b6 100644 --- a/app/javascript/styles/admin.scss +++ b/app/javascript/styles/admin.scss @@ -32,7 +32,7 @@ a { display: block; - padding: 15px 25px; + padding: 15px; color: rgba($primary-text-color, 0.7); text-decoration: none; transition: all 200ms linear; @@ -61,6 +61,7 @@ a { border: 0; + padding: 15px 35px; &.selected { color: $primary-text-color; @@ -98,7 +99,7 @@ h6 { font-size: 16px; - color: $ui-primary-color; + color: $ui-secondary-color; line-height: 28px; font-weight: 400; } @@ -123,10 +124,10 @@ } .muted-hint { - color: lighten($ui-base-color, 27%); + color: $ui-primary-color; a { - color: $ui-primary-color; + color: $ui-highlight-color; } } @@ -139,15 +140,23 @@ .simple_form { max-width: 400px; - .label_input { - label.select { - width: 50%; - } + &.edit_user, + &.new_form_admin_settings, + &.new_form_two_factor_confirmation, + &.new_form_delete_confirmation, + &.new_import, + &.new_domain_block, + &.edit_domain_block { + max-width: none; + } - select { - width: 50%; - float: right; - } + .form_two_factor_confirmation_code, + .form_delete_confirmation_password { + max-width: 400px; + } + + .actions { + max-width: 400px; } } @@ -227,27 +236,25 @@ .report-accounts { display: flex; + flex-wrap: wrap; margin-bottom: 20px; } .report-accounts__item { - flex: 1 1 0; display: flex; + flex: 250px; flex-direction: column; + margin: 0 5px; & > strong { display: block; - margin-bottom: 10px; + margin: 0 0 10px -5px; font-weight: 500; font-size: 14px; line-height: 18px; color: $ui-secondary-color; } - &:first-child { - margin-right: 10px; - } - .account-card { flex: 1 1 auto; } @@ -261,6 +268,11 @@ .activity-stream { flex: 2 0 0; margin-right: 20px; + max-width: calc(100% - 60px); + + .entry { + border-radius: 4px; + } } } @@ -280,18 +292,25 @@ .batch-form-box { display: flex; - margin-bottom: 10px; + flex-wrap: wrap; + margin-bottom: 5px; #form_status_batch_action { - margin-right: 5px; + margin: 0 5px 5px 0; font-size: 14px; } + input.button { + margin: 0 5px 5px 0; + } + .media-spoiler-toggle-buttons { margin-left: auto; .button { overflow: visible; + margin: 0 0 5px 5px; + float: right; } } } diff --git a/app/javascript/styles/basics.scss b/app/javascript/styles/basics.scss index 4e51b555c..05c93b42e 100644 --- a/app/javascript/styles/basics.scss +++ b/app/javascript/styles/basics.scss @@ -7,13 +7,28 @@ body { line-height: 18px; font-weight: 400; color: $primary-text-color; - padding-bottom: 140px; + padding-bottom: 20px; text-rendering: optimizelegibility; font-feature-settings: "kern"; text-size-adjust: none; -webkit-tap-highlight-color: rgba(0,0,0,0); -webkit-tap-highlight-color: transparent; + &.system-font { + // system-ui => standard property (Chrome/Android WebView 56+, Opera 43+, Safari 11+) + // -apple-system => Safari <11 specific + // BlinkMacSystemFont => Chrome <56 on macOS specific + // Segoe UI => Windows 7/8/10 + // Oxygen => KDE + // Ubuntu => Unity/Ubuntu + // Cantarell => GNOME + // Fira Sans => Firefox OS + // Droid Sans => Older Androids (<4.0) + // Helvetica Neue => Older macOS <10.11 + // mastodon-font-sans-serif => web-font (Roboto) fallback and newer Androids (>=4.0) + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", mastodon-font-sans-serif, sans-serif; + } + &.app-body { position: fixed; width: 100%; @@ -46,10 +61,6 @@ body { height: 100%; padding: 0; } - - @media screen and (max-width: 400px) { - padding-bottom: 0; - } } button { @@ -68,18 +79,3 @@ button { align-items: center; justify-content: center; } - -.system-font { - // system-ui => standard property (Chrome/Android WebView 56+, Opera 43+, Safari 11+) - // -apple-system => Safari <11 specific - // BlinkMacSystemFont => Chrome <56 on macOS specific - // Segoe UI => Windows 7/8/10 - // Oxygen => KDE - // Ubuntu => Unity/Ubuntu - // Cantarell => GNOME - // Fira Sans => Firefox OS - // Droid Sans => Older Androids (<4.0) - // Helvetica Neue => Older macOS <10.11 - // mastodon-font-sans-serif => web-font (Roboto) fallback and newer Androids (>=4.0) - font-family: system-ui, -apple-system,BlinkMacSystemFont, "Segoe UI","Oxygen", "Ubuntu","Cantarell","Fira Sans","Droid Sans","Helvetica Neue",mastodon-font-sans-serif, sans-serif; -} diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss index 0d086ed45..557355260 100644 --- a/app/javascript/styles/components.scss +++ b/app/javascript/styles/components.scss @@ -238,6 +238,8 @@ line-height: 0; display: inline-block; width: 0; + height: 0; + position: absolute; } .ellipsis { @@ -395,6 +397,11 @@ bottom: -1px; right: 8px; } + + ::-webkit-scrollbar-track:hover, + ::-webkit-scrollbar-track:active { + background-color: rgba($base-overlay-background, 0.3); + } } } @@ -1288,6 +1295,8 @@ strong, span { display: block; + text-overflow: ellipsis; + overflow: hidden; } strong { @@ -1876,6 +1885,10 @@ &:hover { text-decoration: underline; } + + &:last-child { + padding: 0 15px 0 0; + } } .column-back-button__icon { @@ -2130,7 +2143,7 @@ .character-counter__wrapper { line-height: 36px; - margin-right: 16px; + margin: 0 16px 0 8px; padding-top: 10px; } @@ -2313,6 +2326,18 @@ button.icon-button.active i.fa-retweet { background: lighten($ui-base-color, 8%); } +.status-card.horizontal { + display: block; + + .status-card__image { + width: 100%; + } + + .status-card__image-image { + border-radius: 4px 4px 0 0; + } +} + .status-card__image-image { border-radius: 4px 0 0 4px; display: block; @@ -2671,12 +2696,8 @@ button.icon-button.active i.fa-retweet { } .media-spoiler { - align-items: center; background: $base-overlay-background; color: $primary-text-color; - cursor: pointer; - display: flex; - flex-direction: column; border: 0; width: 100%; height: 100%; @@ -2695,6 +2716,22 @@ button.icon-button.active i.fa-retweet { } } +.media-spoiler__video { + align-items: center; + background: $base-overlay-background; + color: $primary-text-color; + cursor: pointer; + display: flex; + flex-direction: column; + border: 0; + width: 100%; + height: 100%; + justify-content: center; + position: relative; + text-align: center; + z-index: 100; +} + .media-spoiler__warning { display: block; font-size: 14px; @@ -3533,7 +3570,8 @@ button.icon-button.active i.fa-retweet { } .onboarding-modal, -.error-modal { +.error-modal, +.embed-modal { background: $ui-secondary-color; color: $ui-base-color; border-radius: 8px; @@ -4348,6 +4386,15 @@ noscript { margin: 30px auto; color: $ui-secondary-color; max-width: 400px; + + a { + color: $ui-highlight-color; + text-decoration: underline; + + &:hover { + text-decoration: none; + } + } } } @@ -4455,3 +4502,64 @@ noscript { height: 100% !important; } } + +.embed-modal { + max-width: 80vw; + max-height: 80vh; + + h4 { + padding: 30px; + font-weight: 500; + font-size: 16px; + text-align: center; + } + + .embed-modal__container { + padding: 10px; + + .hint { + margin-bottom: 15px; + } + + .embed-modal__html { + color: $ui-secondary-color; + outline: 0; + box-sizing: border-box; + display: block; + width: 100%; + border: none; + padding: 10px; + font-family: 'mastodon-font-monospace', monospace; + background: $ui-base-color; + color: $ui-primary-color; + font-size: 14px; + margin: 0; + margin-bottom: 15px; + + &::-moz-focus-inner { + border: 0; + } + + &::-moz-focus-inner, + &:focus, + &:active { + outline: 0 !important; + } + + &:focus { + background: lighten($ui-base-color, 4%); + } + + @media screen and (max-width: 600px) { + font-size: 16px; + } + } + + .embed-modal__iframe { + width: 400px; + max-width: 100%; + overflow: hidden; + border: 0; + } + } +} diff --git a/app/javascript/styles/containers.scss b/app/javascript/styles/containers.scss index 536f4e5a1..af2589e23 100644 --- a/app/javascript/styles/containers.scss +++ b/app/javascript/styles/containers.scss @@ -44,6 +44,22 @@ } } +.compose-standalone { + .compose-form { + width: 400px; + margin: 0 auto; + padding: 20px 0; + margin-top: 40px; + box-sizing: border-box; + + @media screen and (max-width: 400px) { + width: 100%; + margin-top: 0; + padding: 20px; + } + } +} + .account-header { width: 400px; margin: 0 auto; @@ -56,7 +72,7 @@ margin-bottom: -30px; margin-top: 40px; - @media screen and (max-width: 400px) { + @media screen and (max-width: 440px) { width: 100%; margin: 0; margin-bottom: 10px; @@ -81,10 +97,13 @@ .name { flex: 1 1 auto; color: $ui-secondary-color; + width: calc(100% - 88px); .username { display: block; font-weight: 500; + text-overflow: ellipsis; + overflow: hidden; } } @@ -92,5 +111,6 @@ display: block; font-size: 32px; line-height: 40px; + margin-left: 8px; } } diff --git a/app/javascript/styles/forms.scss b/app/javascript/styles/forms.scss index 62094e98e..747610237 100644 --- a/app/javascript/styles/forms.scss +++ b/app/javascript/styles/forms.scss @@ -24,7 +24,7 @@ code { p.hint { margin-bottom: 15px; - color: lighten($ui-base-color, 32%); + color: $ui-primary-color; &.subtle-hint { text-align: center; @@ -32,10 +32,10 @@ code { line-height: 18px; margin-top: 15px; margin-bottom: 0; - color: $ui-base-lighter-color; + color: $ui-primary-color; a { - color: $ui-primary-color; + color: $ui-highlight-color; } } } @@ -53,7 +53,6 @@ code { label { flex: 0 0 auto; - width: 100px; } input { @@ -65,12 +64,37 @@ code { padding: 15px 0; margin-bottom: 0; + .label_input { + flex-wrap: wrap; + align-items: flex-start; + } + + &.select .label_input { + align-items: initial; + } + .label_input > label { font-family: inherit; font-size: 16px; color: $primary-text-color; display: block; padding-top: 5px; + margin-bottom: 5px; + flex: 1; + min-width: 150px; + word-wrap: break-word; + + &.select { + flex: 0; + } + + & ~ * { + margin-left: 10px; + } + } + + ul { + flex: 390px; } &.boolean { @@ -359,17 +383,23 @@ code { color: $ui-secondary-color; font-weight: 500; } + + @media screen and (max-width: 740px) and (min-width: 441px) { + margin-top: 40px; + } } .qr-wrapper { display: flex; + flex-wrap: wrap; + align-items: flex-start; } .qr-code { flex: 0 0 auto; background: $simple-background-color; padding: 4px; - margin-bottom: 20px; + margin: 0 10px 20px 0; box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); display: inline-block; @@ -380,8 +410,9 @@ code { } .qr-alternative { - margin-left: 10px; - color: $ui-primary-color; + margin-bottom: 20px; + color: $ui-secondary-color; + flex: 150px; samp { display: block; @@ -391,7 +422,6 @@ code { .table-form { p { - max-width: 400px; margin-bottom: 15px; strong { @@ -403,7 +433,6 @@ code { .simple_form, .table-form { .warning { - max-width: 400px; box-sizing: border-box; background: rgba($error-value-color, 0.5); color: $primary-text-color; diff --git a/app/javascript/styles/landing_strip.scss b/app/javascript/styles/landing_strip.scss index d2ac5b822..15ff84912 100644 --- a/app/javascript/styles/landing_strip.scss +++ b/app/javascript/styles/landing_strip.scss @@ -5,6 +5,8 @@ padding: 14px; border-radius: 4px; margin-bottom: 20px; + display: flex; + align-items: center; strong, a { @@ -15,4 +17,15 @@ color: inherit; text-decoration: underline; } + + .logo { + width: 30px; + height: 30px; + flex: 0 0 auto; + margin-right: 15px; + } + + @media screen and (max-width: 740px) { + margin-bottom: 0; + } } diff --git a/app/javascript/styles/rtl.scss b/app/javascript/styles/rtl.scss index 4966fbc21..6c003d69a 100644 --- a/app/javascript/styles/rtl.scss +++ b/app/javascript/styles/rtl.scss @@ -8,7 +8,7 @@ body.rtl { } .character-counter__wrapper { - margin-right: 0; + margin-right: 8px; margin-left: 16px; } @@ -32,6 +32,11 @@ body.rtl { right: auto; } + .column-header__back-button { + padding-left: 5px; + padding-right: 0; + } + .column-header__setting-arrows { float: left; } @@ -54,25 +59,64 @@ body.rtl { right: 10px; } - .status { + .status, + .activity-stream .status.light { padding-left: 10px; padding-right: 68px; } - .status__info .status__display-name { + .status__info .status__display-name, + .activity-stream .status.light .status__display-name { padding-left: 25px; padding-right: 0; } + .activity-stream .pre-header { + padding-right: 68px; + padding-left: 0; + } + + .status__prepend { + margin-left: 0; + margin-right: 68px; + } + + .status__prepend-icon-wrapper { + left: auto; + right: -26px; + } + + .activity-stream .pre-header .pre-header__icon { + left: auto; + right: 42px; + } + + .account__avatar-overlay-overlay { + right: auto; + left: 0; + } + .column-back-button--slim-button { right: auto; left: 0; } - .status__relative-time { + .status__relative-time, + .activity-stream .status.light .status__header .status__meta { float: left; } + .activity-stream .detailed-status.light .detailed-status__display-name > div { + float: right; + margin-right: 0; + margin-left: 10px; + } + + .activity-stream .detailed-status.light .detailed-status__meta span > span { + margin-left: 0; + margin-right: 6px; + } + .status__action-bar-button { float: right; margin-right: 0; @@ -129,6 +173,78 @@ body.rtl { right: -2.14285714em; } + .admin-wrapper .sidebar ul a i.fa, + a.table-action-link i.fa { + margin-right: 0; + margin-left: 5px; + } + + .simple_form .check_boxes .checkbox label, + .simple_form .input.with_label.boolean label.checkbox { + padding-left: 0; + padding-right: 25px; + } + + .simple_form .check_boxes .checkbox input[type="checkbox"], + .simple_form .input.boolean input[type="checkbox"] { + left: auto; + right: 0; + } + + .simple_form .input-with-append .input input { + padding-left: 127px; + padding-right: 0; + } + + .simple_form .input-with-append .append { + right: auto; + left: 0; + } + + .table th, + .table td { + text-align: right; + } + + .filters .filter-subset { + margin-right: 0; + margin-left: 45px; + } + + .landing-page .header-wrapper .mascot { + right: 60px; + left: auto; + } + + .landing-page .header .hero .floats .float-1 { + left: -120px; + right: auto; + } + + .landing-page .header .hero .floats .float-2 { + left: 210px; + right: auto; + } + + .landing-page .header .hero .floats .float-3 { + left: 110px; + right: auto; + } + + .landing-page .header .links .brand img { + left: 0; + } + + .landing-page .fa-external-link { + padding-right: 5px; + padding-left: 0 !important; + } + + .landing-page .features #mastodon-timeline { + margin-right: 0; + margin-left: 30px; + } + @media screen and (min-width: 1025px) { .column, .drawer { @@ -139,11 +255,6 @@ body.rtl { padding-left: 5px; padding-right: 10px; } - - &:last-child { - padding-right: 0; - padding-left: 10px; - } } .columns-area > div { diff --git a/app/javascript/styles/stream_entries.scss b/app/javascript/styles/stream_entries.scss index 90a43388b..35225c045 100644 --- a/app/javascript/styles/stream_entries.scss +++ b/app/javascript/styles/stream_entries.scss @@ -8,6 +8,7 @@ .detailed-status.light, .status.light { border-bottom: 1px solid $ui-secondary-color; + animation: none; } &:last-child { @@ -34,6 +35,14 @@ } } } + + @media screen and (max-width: 740px) { + &, + .detailed-status.light, + .status.light { + border-radius: 0 !important; + } + } } &.with-header { @@ -44,6 +53,14 @@ .status.light { border-radius: 0; } + + &:last-child { + &, + .detailed-status.light, + .status.light { + border-radius: 0 0 4px 4px; + } + } } } } @@ -382,19 +399,52 @@ .embed { .activity-stream { - border-radius: 4px; box-shadow: none; .entry { - &:last-child { - border-radius: 0 0 4px 4px; - } - &:first-child { - border-radius: 4px 4px 0 0; + .detailed-status.light { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: flex-start; - &:last-child { - border-radius: 4px; + .detailed-status__display-name { + flex: 1; + margin: 0 5px 15px 0; + } + + .button.button-secondary.logo-button { + flex: 0 auto; + font-size: 14px; + + svg { + width: 20px; + height: auto; + vertical-align: middle; + margin-right: 5px; + + path:first-child { + fill: $ui-primary-color; + } + + path:last-child { + fill: $simple-background-color; + } + } + + &:active, + &:focus, + &:hover { + svg path:first-child { + fill: lighten($ui-primary-color, 4%); + } + } + } + + .status__content, + .detailed-status__meta { + flex: 100%; } } } diff --git a/app/javascript/styles/tables.scss b/app/javascript/styles/tables.scss index 6e54c59c0..f6e57e196 100644 --- a/app/javascript/styles/tables.scss +++ b/app/javascript/styles/tables.scss @@ -46,7 +46,7 @@ &.inline-table { td, th { - padding: 8px 0; + padding: 8px 2px; } & > tbody > tr:nth-child(odd) > td, diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb new file mode 100644 index 000000000..b06dd6194 --- /dev/null +++ b/app/lib/activitypub/activity.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +class ActivityPub::Activity + include JsonLdHelper + + def initialize(json, account) + @json = json + @account = account + @object = @json['object'] + end + + def perform + raise NotImplementedError + end + + class << self + def factory(json, account) + @json = json + klass&.new(json, account) + end + + private + + def klass + case @json['type'] + when 'Create' + ActivityPub::Activity::Create + when 'Announce' + ActivityPub::Activity::Announce + when 'Delete' + ActivityPub::Activity::Delete + when 'Follow' + ActivityPub::Activity::Follow + when 'Like' + ActivityPub::Activity::Like + when 'Block' + ActivityPub::Activity::Block + when 'Update' + ActivityPub::Activity::Update + when 'Undo' + ActivityPub::Activity::Undo + when 'Accept' + ActivityPub::Activity::Accept + when 'Reject' + ActivityPub::Activity::Reject + end + end + end + + protected + + def status_from_uri(uri) + ActivityPub::TagManager.instance.uri_to_resource(uri, Status) + end + + def account_from_uri(uri) + ActivityPub::TagManager.instance.uri_to_resource(uri, Account) + end + + def object_uri + @object_uri ||= value_or_id(@object) + end + + def redis + Redis.current + end + + def distribute(status) + notify_about_reblog(status) if reblog_of_local_account?(status) + notify_about_mentions(status) + crawl_links(status) + distribute_to_followers(status) + end + + def reblog_of_local_account?(status) + status.reblog? && status.reblog.account.local? + end + + def notify_about_reblog(status) + NotifyService.new.call(status.reblog.account, status) + end + + def notify_about_mentions(status) + status.mentions.includes(:account).each do |mention| + next unless mention.account.local? && audience_includes?(mention.account) + NotifyService.new.call(mention.account, mention) + end + end + + def crawl_links(status) + return if status.spoiler_text? + LinkCrawlWorker.perform_async(status.id) + end + + def distribute_to_followers(status) + ::DistributionWorker.perform_async(status.id) + end + + def delete_arrived_first?(uri) + redis.exists("delete_upon_arrival:#{@account.id}:#{uri}") + end + + def delete_later!(uri) + redis.setex("delete_upon_arrival:#{@account.id}:#{uri}", 6.hours.seconds, uri) + end +end diff --git a/app/lib/activitypub/activity/accept.rb b/app/lib/activitypub/activity/accept.rb new file mode 100644 index 000000000..bd90c9019 --- /dev/null +++ b/app/lib/activitypub/activity/accept.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class ActivityPub::Activity::Accept < ActivityPub::Activity + def perform + case @object['type'] + when 'Follow' + accept_follow + end + end + + private + + def accept_follow + target_account = account_from_uri(target_uri) + + return if target_account.nil? || !target_account.local? + + follow_request = FollowRequest.find_by(account: target_account, target_account: @account) + follow_request&.authorize! + end + + def target_uri + @target_uri ||= value_or_id(@object['actor']) + end +end diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb new file mode 100644 index 000000000..c4da405c7 --- /dev/null +++ b/app/lib/activitypub/activity/announce.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class ActivityPub::Activity::Announce < ActivityPub::Activity + def perform + original_status = status_from_uri(object_uri) + original_status ||= fetch_remote_original_status + + return if original_status.nil? || delete_arrived_first?(@json['id']) + + status = Status.find_by(account: @account, reblog: original_status) + + return status unless status.nil? + + status = Status.create!(account: @account, reblog: original_status, uri: @json['id']) + distribute(status) + status + end + + private + + def fetch_remote_original_status + if object_uri.start_with?('http') + ActivityPub::FetchRemoteStatusService.new.call(object_uri) + elsif @object['url'].present? + ::FetchRemoteStatusService.new.call(@object['url']) + end + end +end diff --git a/app/lib/activitypub/activity/block.rb b/app/lib/activitypub/activity/block.rb new file mode 100644 index 000000000..f630d5db2 --- /dev/null +++ b/app/lib/activitypub/activity/block.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class ActivityPub::Activity::Block < ActivityPub::Activity + def perform + target_account = account_from_uri(object_uri) + + return if target_account.nil? || !target_account.local? || delete_arrived_first?(@json['id']) || @account.blocking?(target_account) + + UnfollowService.new.call(target_account, @account) if target_account.following?(@account) + @account.block!(target_account) + end +end diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb new file mode 100644 index 000000000..9a34484f5 --- /dev/null +++ b/app/lib/activitypub/activity/create.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +class ActivityPub::Activity::Create < ActivityPub::Activity + def perform + return if delete_arrived_first?(object_uri) || unsupported_object_type? + + status = find_existing_status + + return status unless status.nil? + + ApplicationRecord.transaction do + status = Status.create!(status_params) + + process_tags(status) + process_attachments(status) + end + + resolve_thread(status) + distribute(status) + forward_for_reply if status.public_visibility? || status.unlisted_visibility? + + status + end + + private + + def find_existing_status + status = status_from_uri(object_uri) + status ||= Status.find_by(uri: @object['atomUri']) if @object['atomUri'].present? + status + end + + def status_params + { + uri: @object['id'], + url: object_url || @object['id'], + account: @account, + text: text_from_content || '', + language: language_from_content, + spoiler_text: @object['summary'] || '', + created_at: @object['published'] || Time.now.utc, + reply: @object['inReplyTo'].present?, + sensitive: @object['sensitive'] || false, + visibility: visibility_from_audience, + thread: replied_to_status, + conversation: conversation_from_uri(@object['conversation']), + } + end + + def process_tags(status) + return unless @object['tag'].is_a?(Array) + + @object['tag'].each do |tag| + case tag['type'] + when 'Hashtag' + process_hashtag tag, status + when 'Mention' + process_mention tag, status + end + end + end + + def process_hashtag(tag, status) + hashtag = tag['name'].gsub(/\A#/, '').mb_chars.downcase + hashtag = Tag.where(name: hashtag).first_or_initialize(name: hashtag) + + status.tags << hashtag + end + + def process_mention(tag, status) + account = account_from_uri(tag['href']) + account = FetchRemoteAccountService.new.call(tag['href']) if account.nil? + return if account.nil? + account.mentions.create(status: status) + end + + def process_attachments(status) + return unless @object['attachment'].is_a?(Array) + + @object['attachment'].each do |attachment| + next if unsupported_media_type?(attachment['mediaType']) + + href = Addressable::URI.parse(attachment['url']).normalize.to_s + media_attachment = MediaAttachment.create(status: status, account: status.account, remote_url: href) + + next if skip_download? + + media_attachment.file_remote_url = href + media_attachment.save + end + end + + def resolve_thread(status) + return unless status.reply? && status.thread.nil? + ThreadResolveWorker.perform_async(status.id, in_reply_to_uri) + end + + def conversation_from_uri(uri) + return nil if uri.nil? + return Conversation.find_by(id: TagManager.instance.unique_tag_to_local_id(uri, 'Conversation')) if TagManager.instance.local_id?(uri) + Conversation.find_by(uri: uri) || Conversation.create!(uri: uri) + end + + def visibility_from_audience + if equals_or_includes?(@object['to'], ActivityPub::TagManager::COLLECTIONS[:public]) + :public + elsif equals_or_includes?(@object['cc'], ActivityPub::TagManager::COLLECTIONS[:public]) + :unlisted + elsif equals_or_includes?(@object['to'], @account.followers_url) + :private + else + :direct + end + end + + def audience_includes?(account) + uri = ActivityPub::TagManager.instance.uri_for(account) + equals_or_includes?(@object['to'], uri) || equals_or_includes?(@object['cc'], uri) + end + + def replied_to_status + return @replied_to_status if defined?(@replied_to_status) + + if in_reply_to_uri.blank? + @replied_to_status = nil + else + @replied_to_status = status_from_uri(in_reply_to_uri) + @replied_to_status ||= status_from_uri(@object['inReplyToAtomUri']) if @object['inReplyToAtomUri'].present? + @replied_to_status + end + end + + def in_reply_to_uri + value_or_id(@object['inReplyTo']) + end + + def text_from_content + if @object['content'].present? + @object['content'] + elsif language_map? + @object['contentMap'].values.first + end + end + + def language_from_content + return nil unless language_map? + @object['contentMap'].keys.first + end + + def object_url + return if @object['url'].blank? + + value = first_of_value(@object['url']) + + return value if value.is_a?(String) + + value['href'] + end + + def language_map? + @object['contentMap'].is_a?(Hash) && !@object['contentMap'].empty? + end + + def unsupported_object_type? + @object.is_a?(String) || !%w(Article Note).include?(@object['type']) + end + + def unsupported_media_type?(mime_type) + mime_type.present? && !(MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES).include?(mime_type) + end + + def skip_download? + return @skip_download if defined?(@skip_download) + @skip_download ||= DomainBlock.find_by(domain: @account.domain)&.reject_media? + end + + def reply_to_local? + !replied_to_status.nil? && replied_to_status.account.local? + end + + def forward_for_reply + return unless @json['signature'].present? && reply_to_local? + ActivityPub::RawDistributionWorker.perform_async(Oj.dump(@json), replied_to_status.account_id) + end +end diff --git a/app/lib/activitypub/activity/delete.rb b/app/lib/activitypub/activity/delete.rb new file mode 100644 index 000000000..4c6afb090 --- /dev/null +++ b/app/lib/activitypub/activity/delete.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class ActivityPub::Activity::Delete < ActivityPub::Activity + def perform + if @account.uri == object_uri + delete_person + else + delete_note + end + end + + private + + def delete_person + SuspendAccountService.new.call(@account) + end + + def delete_note + status = Status.find_by(uri: object_uri, account: @account) + status ||= Status.find_by(uri: @object['atomUri'], account: @account) if @object.is_a?(Hash) && @object['atomUri'].present? + + delete_later!(object_uri) + + return if status.nil? + + forward_for_reblogs(status) + delete_now!(status) + end + + def forward_for_reblogs(status) + return if @json['signature'].blank? + + ActivityPub::RawDistributionWorker.push_bulk(status.reblogs.includes(:account).references(:account).merge(Account.local).pluck(:account_id)) do |account_id| + [payload, account_id] + end + end + + def delete_now!(status) + RemoveStatusService.new.call(status) + end + + def payload + @payload ||= Oj.dump(@json) + end +end diff --git a/app/lib/activitypub/activity/follow.rb b/app/lib/activitypub/activity/follow.rb new file mode 100644 index 000000000..8adbbb9c3 --- /dev/null +++ b/app/lib/activitypub/activity/follow.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class ActivityPub::Activity::Follow < ActivityPub::Activity + def perform + target_account = account_from_uri(object_uri) + + return if target_account.nil? || !target_account.local? || delete_arrived_first?(@json['id']) || @account.requested?(target_account) + + # Fast-forward repeat follow requests + if @account.following?(target_account) + AuthorizeFollowService.new.call(@account, target_account, skip_follow_request: true) + return + end + + follow_request = FollowRequest.create!(account: @account, target_account: target_account) + + if target_account.locked? + NotifyService.new.call(target_account, follow_request) + else + AuthorizeFollowService.new.call(@account, target_account) + NotifyService.new.call(target_account, ::Follow.find_by(account: @account, target_account: target_account)) + end + end +end diff --git a/app/lib/activitypub/activity/like.rb b/app/lib/activitypub/activity/like.rb new file mode 100644 index 000000000..674d5fe47 --- /dev/null +++ b/app/lib/activitypub/activity/like.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class ActivityPub::Activity::Like < ActivityPub::Activity + def perform + original_status = status_from_uri(object_uri) + + return if original_status.nil? || !original_status.account.local? || delete_arrived_first?(@json['id']) || @account.favourited?(original_status) + + favourite = original_status.favourites.create!(account: @account) + NotifyService.new.call(original_status.account, favourite) + end +end diff --git a/app/lib/activitypub/activity/reject.rb b/app/lib/activitypub/activity/reject.rb new file mode 100644 index 000000000..d815feeb6 --- /dev/null +++ b/app/lib/activitypub/activity/reject.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class ActivityPub::Activity::Reject < ActivityPub::Activity + def perform + case @object['type'] + when 'Follow' + reject_follow + end + end + + private + + def reject_follow + target_account = account_from_uri(target_uri) + + return if target_account.nil? || !target_account.local? + + follow_request = FollowRequest.find_by(account: target_account, target_account: @account) + follow_request&.reject! + end + + def target_uri + @target_uri ||= value_or_id(@object['actor']) + end +end diff --git a/app/lib/activitypub/activity/undo.rb b/app/lib/activitypub/activity/undo.rb new file mode 100644 index 000000000..4b0905de2 --- /dev/null +++ b/app/lib/activitypub/activity/undo.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +class ActivityPub::Activity::Undo < ActivityPub::Activity + def perform + case @object['type'] + when 'Announce' + undo_announce + when 'Follow' + undo_follow + when 'Like' + undo_like + when 'Block' + undo_block + end + end + + private + + def undo_announce + status = Status.find_by(uri: object_uri, account: @account) + + if status.nil? + delete_later!(object_uri) + else + RemoveStatusService.new.call(status) + end + end + + def undo_follow + target_account = account_from_uri(target_uri) + + return if target_account.nil? || !target_account.local? + + if @account.following?(target_account) + @account.unfollow!(target_account) + elsif @account.requested?(target_account) + FollowRequest.find_by(account: @account, target_account: target_account)&.destroy + else + delete_later!(object_uri) + end + end + + def undo_like + status = status_from_uri(target_uri) + + return if status.nil? || !status.account.local? + + if @account.favourited?(status) + favourite = status.favourites.where(account: @account).first + favourite&.destroy + else + delete_later!(object_uri) + end + end + + def undo_block + target_account = account_from_uri(target_uri) + + return if target_account.nil? || !target_account.local? + + if @account.blocking?(target_account) + UnblockService.new.call(@account, target_account) + else + delete_later!(object_uri) + end + end + + def target_uri + @target_uri ||= value_or_id(@object['object']) + end +end diff --git a/app/lib/activitypub/activity/update.rb b/app/lib/activitypub/activity/update.rb new file mode 100644 index 000000000..0134b4015 --- /dev/null +++ b/app/lib/activitypub/activity/update.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class ActivityPub::Activity::Update < ActivityPub::Activity + def perform + case @object['type'] + when 'Person' + update_account + end + end + + private + + def update_account + return if @account.uri != object_uri + ActivityPub::ProcessAccountService.new.call(@account.username, @account.domain, @object) + end +end diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb index 0a70207bc..6ed66a239 100644 --- a/app/lib/activitypub/adapter.rb +++ b/app/lib/activitypub/adapter.rb @@ -1,13 +1,34 @@ # frozen_string_literal: true class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base + CONTEXT = { + '@context': [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + + { + 'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers', + 'sensitive' => 'as:sensitive', + 'Hashtag' => 'as:Hashtag', + 'ostatus' => 'http://ostatus.org#', + 'atomUri' => 'ostatus:atomUri', + 'inReplyToAtomUri' => 'ostatus:inReplyToAtomUri', + 'conversation' => 'ostatus:conversation', + }, + ], + }.freeze + def self.default_key_transform :camel_lower end + def self.transform_key_casing!(value, _options) + ActivityPub::CaseTransform.camel_lower(value) + end + def serializable_hash(options = nil) options = serialization_options(options) - serialized_hash = { '@context': 'https://www.w3.org/ns/activitystreams' }.merge(ActiveModelSerializers::Adapter::Attributes.new(serializer, instance_options).serializable_hash(options)) + serialized_hash = CONTEXT.merge(ActiveModelSerializers::Adapter::Attributes.new(serializer, instance_options).serializable_hash(options)) self.class.transform_key_casing!(serialized_hash, instance_options) end end diff --git a/app/lib/activitypub/case_transform.rb b/app/lib/activitypub/case_transform.rb new file mode 100644 index 000000000..7f716f862 --- /dev/null +++ b/app/lib/activitypub/case_transform.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module ActivityPub::CaseTransform + class << self + def camel_lower_cache + @camel_lower_cache ||= {} + end + + def camel_lower(value) + case value + when Array then value.map { |item| camel_lower(item) } + when Hash then value.deep_transform_keys! { |key| camel_lower(key) } + when Symbol then camel_lower(value.to_s).to_sym + when String + camel_lower_cache[value] ||= if value.start_with?('_:') + '_:' + value.gsub(/\A_:/, '').underscore.camelize(:lower) + else + value.underscore.camelize(:lower) + end + else value + end + end + end +end diff --git a/app/lib/activitypub/linked_data_signature.rb b/app/lib/activitypub/linked_data_signature.rb new file mode 100644 index 000000000..adb8b6cdf --- /dev/null +++ b/app/lib/activitypub/linked_data_signature.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +class ActivityPub::LinkedDataSignature + include JsonLdHelper + + CONTEXT = 'https://w3id.org/identity/v1' + + def initialize(json) + @json = json.with_indifferent_access + end + + def verify_account! + return unless @json['signature'].is_a?(Hash) + + type = @json['signature']['type'] + creator_uri = @json['signature']['creator'] + signature = @json['signature']['signatureValue'] + + return unless type == 'RsaSignature2017' + + creator = ActivityPub::TagManager.instance.uri_to_resource(creator_uri, Account) + creator ||= ActivityPub::FetchRemoteKeyService.new.call(creator_uri) + + return if creator.nil? + + options_hash = hash(@json['signature'].without('type', 'id', 'signatureValue').merge('@context' => CONTEXT)) + document_hash = hash(@json.without('signature')) + to_be_verified = options_hash + document_hash + + if creator.keypair.public_key.verify(OpenSSL::Digest::SHA256.new, Base64.decode64(signature), to_be_verified) + creator + end + end + + def sign!(creator) + options = { + 'type' => 'RsaSignature2017', + 'creator' => [ActivityPub::TagManager.instance.uri_for(creator), '#main-key'].join, + 'created' => Time.now.utc.iso8601, + } + + options_hash = hash(options.without('type', 'id', 'signatureValue').merge('@context' => CONTEXT)) + document_hash = hash(@json.without('signature')) + to_be_signed = options_hash + document_hash + + signature = Base64.strict_encode64(creator.keypair.sign(OpenSSL::Digest::SHA256.new, to_be_signed)) + + @json.merge('signature' => options.merge('signatureValue' => signature)) + end + + private + + def hash(obj) + Digest::SHA256.hexdigest(canonicalize(obj)) + end +end diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb index ec42bcad3..de575d9e6 100644 --- a/app/lib/activitypub/tag_manager.rb +++ b/app/lib/activitypub/tag_manager.rb @@ -6,6 +6,8 @@ class ActivityPub::TagManager include Singleton include RoutingHelper + CONTEXT = 'https://www.w3.org/ns/activitystreams' + COLLECTIONS = { public: 'https://www.w3.org/ns/activitystreams#Public', }.freeze @@ -17,6 +19,7 @@ class ActivityPub::TagManager when :person short_account_url(target) when :note, :comment, :activity + return activity_account_status_url(target.account, target) if target.reblog? short_account_status_url(target.account, target) end end @@ -28,10 +31,17 @@ class ActivityPub::TagManager when :person account_url(target) when :note, :comment, :activity + return activity_account_status_url(target.account, target) if target.reblog? account_status_url(target.account, target) end end + def activity_uri_for(target) + return nil unless %i(note comment activity).include?(target.object_type) && target.local? + + activity_account_status_url(target.account, target) + end + # Primary audience of a status # Public statuses go out to primarily the public collection # Unlisted and private statuses go out primarily to the followers collection @@ -66,4 +76,32 @@ class ActivityPub::TagManager cc end + + def local_uri?(uri) + uri = Addressable::URI.parse(uri) + host = uri.normalized_host + host = "#{host}:#{uri.port}" if uri.port + + !host.nil? && (::TagManager.instance.local_domain?(host) || ::TagManager.instance.web_domain?(host)) + end + + def uri_to_local_id(uri, param = :id) + path_params = Rails.application.routes.recognize_path(uri) + path_params[param] + end + + def uri_to_resource(uri, klass) + if local_uri?(uri) + case klass.name + when 'Account' + klass.find_local(uri_to_local_id(uri, :username)) + else + klass.find_by(id: uri_to_local_id(uri)) + end + elsif ::TagManager.instance.local_id?(uri) + klass.find_by(id: ::TagManager.instance.unique_tag_to_local_id(uri, klass.to_s)) + else + klass.find_by(uri: uri.split('#').first) + end + end end diff --git a/app/lib/ostatus/activity/base.rb b/app/lib/ostatus/activity/base.rb index e1477f0eb..1dc7abee3 100644 --- a/app/lib/ostatus/activity/base.rb +++ b/app/lib/ostatus/activity/base.rb @@ -29,21 +29,43 @@ class OStatus::Activity::Base end def url - link = @xml.at_xpath('./xmlns:link[@rel="alternate"]', xmlns: TagManager::XMLNS) + link = @xml.xpath('./xmlns:link[@rel="alternate"]', xmlns: TagManager::XMLNS).find { |link_candidate| link_candidate['type'] == 'text/html' } link.nil? ? nil : link['href'] end + def activitypub_uri + link = @xml.xpath('./xmlns:link[@rel="alternate"]', xmlns: TagManager::XMLNS).find { |link_candidate| ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(link_candidate['type']) } + link.nil? ? nil : link['href'] + end + + def activitypub_uri? + activitypub_uri.present? + end + private def find_status(uri) if TagManager.instance.local_id?(uri) local_id = TagManager.instance.unique_tag_to_local_id(uri, 'Status') return Status.find_by(id: local_id) + elsif ActivityPub::TagManager.instance.local_uri?(uri) + local_id = ActivityPub::TagManager.instance.uri_to_local_id(uri) + return Status.find_by(id: local_id) end Status.find_by(uri: uri) end + def find_activitypub_status(uri, href) + tag_matches = /tag:([^,:]+)[^:]*:objectId=([\d]+)/.match(uri) + href_matches = %r{/users/([^/]+)}.match(href) + + unless tag_matches.nil? || href_matches.nil? + uri = "https://#{tag_matches[1]}/users/#{href_matches[1]}/statuses/#{tag_matches[2]}" + Status.find_by(uri: uri) + end + end + def redis Redis.current end diff --git a/app/lib/ostatus/activity/creation.rb b/app/lib/ostatus/activity/creation.rb index e22f746f2..1a23c9efa 100644 --- a/app/lib/ostatus/activity/creation.rb +++ b/app/lib/ostatus/activity/creation.rb @@ -9,6 +9,11 @@ class OStatus::Activity::Creation < OStatus::Activity::Base return [nil, false] if @account.suspended? + if activitypub_uri? && [:public, :unlisted].include?(visibility_scope) + result = perform_via_activitypub + return result if result.first.present? + end + Rails.logger.debug "Creating remote status #{id}" # Return early if status already exists in db @@ -16,24 +21,28 @@ class OStatus::Activity::Creation < OStatus::Activity::Base return [status, false] unless status.nil? - status = Status.create!( - uri: id, - url: url, - account: @account, - reblog: reblog, - text: content, - spoiler_text: content_warning, - created_at: published, - reply: thread?, - language: content_language, - visibility: visibility_scope, - conversation: find_or_create_conversation, - thread: thread? ? find_status(thread.first) : nil - ) - - save_mentions(status) - save_hashtags(status) - save_media(status) + cached_reblog = reblog + + ApplicationRecord.transaction do + status = Status.create!( + uri: id, + url: url, + account: @account, + reblog: cached_reblog, + text: content, + spoiler_text: content_warning, + created_at: published, + reply: thread?, + language: content_language, + visibility: visibility_scope, + conversation: find_or_create_conversation, + thread: thread? ? find_status(thread.first) || find_activitypub_status(thread.first, thread.second) : nil + ) + + save_mentions(status) + save_hashtags(status) + save_media(status) + end if thread? && status.thread.nil? Rails.logger.debug "Trying to attach #{status.id} (#{id}) to #{thread.first}" @@ -48,6 +57,10 @@ class OStatus::Activity::Creation < OStatus::Activity::Base [status, true] end + def perform_via_activitypub + [find_status(activitypub_uri) || ActivityPub::FetchRemoteStatusService.new.call(activitypub_uri), false] + end + def content @xml.at_xpath('./xmlns:content', xmlns: TagManager::XMLNS).content end diff --git a/app/lib/ostatus/activity/deletion.rb b/app/lib/ostatus/activity/deletion.rb index 860faf501..c98f5ee0a 100644 --- a/app/lib/ostatus/activity/deletion.rb +++ b/app/lib/ostatus/activity/deletion.rb @@ -3,7 +3,9 @@ class OStatus::Activity::Deletion < OStatus::Activity::Base def perform Rails.logger.debug "Deleting remote status #{id}" - status = Status.find_by(uri: id, account: @account) + + status = Status.find_by(uri: id, account: @account) + status ||= Status.find_by(uri: activitypub_uri, account: @account) if activitypub_uri? if status.nil? redis.setex("delete_upon_arrival:#{@account.id}:#{id}", 6 * 3_600, id) diff --git a/app/lib/ostatus/activity/remote.rb b/app/lib/ostatus/activity/remote.rb index ecec6886c..5b204b6d8 100644 --- a/app/lib/ostatus/activity/remote.rb +++ b/app/lib/ostatus/activity/remote.rb @@ -2,6 +2,10 @@ class OStatus::Activity::Remote < OStatus::Activity::Base def perform - find_status(id) || FetchRemoteStatusService.new.call(url) + if activitypub_uri? + find_status(activitypub_uri) || FetchRemoteStatusService.new.call(url) + else + find_status(id) || FetchRemoteStatusService.new.call(url) + end end end diff --git a/app/lib/ostatus/atom_serializer.rb b/app/lib/ostatus/atom_serializer.rb index 0d62361be..b8e22a381 100644 --- a/app/lib/ostatus/atom_serializer.rb +++ b/app/lib/ostatus/atom_serializer.rb @@ -65,7 +65,7 @@ class OStatus::AtomSerializer add_namespaces(entry) if root - append_element(entry, 'id', TagManager.instance.unique_tag(stream_entry.created_at, stream_entry.activity_id, stream_entry.activity_type)) + append_element(entry, 'id', TagManager.instance.uri_for(stream_entry.status)) append_element(entry, 'published', stream_entry.created_at.iso8601) append_element(entry, 'updated', stream_entry.updated_at.iso8601) append_element(entry, 'title', stream_entry&.status&.title || "#{stream_entry.account.acct} deleted status") @@ -79,11 +79,14 @@ class OStatus::AtomSerializer if stream_entry.status.nil? append_element(entry, 'content', 'Deleted status') + elsif stream_entry.status.destroyed? + append_element(entry, 'content', 'Deleted status') + append_element(entry, 'link', nil, rel: :alternate, type: 'application/activity+json', href: ActivityPub::TagManager.instance.uri_for(stream_entry.status)) if stream_entry.account.local? else serialize_status_attributes(entry, stream_entry.status) end - append_element(entry, 'link', nil, rel: :alternate, type: 'text/html', href: account_stream_entry_url(stream_entry.account, stream_entry)) + append_element(entry, 'link', nil, rel: :alternate, type: 'text/html', href: TagManager.instance.url_for(stream_entry.status)) append_element(entry, 'link', nil, rel: :self, type: 'application/atom+xml', href: account_stream_entry_url(stream_entry.account, stream_entry, format: 'atom')) append_element(entry, 'thr:in-reply-to', nil, ref: TagManager.instance.uri_for(stream_entry.thread), href: TagManager.instance.url_for(stream_entry.thread)) if stream_entry.threaded? append_element(entry, 'ostatus:conversation', nil, ref: conversation_uri(stream_entry.status.conversation)) unless stream_entry&.status&.conversation_id.nil? @@ -343,6 +346,8 @@ class OStatus::AtomSerializer end def serialize_status_attributes(entry, status) + append_element(entry, 'link', nil, rel: :alternate, type: 'application/activity+json', href: ActivityPub::TagManager.instance.uri_for(status)) if status.account.local? + append_element(entry, 'summary', status.spoiler_text, 'xml:lang': status.language) if status.spoiler_text? append_element(entry, 'content', Formatter.instance.format(status).to_str, type: 'html', 'xml:lang': status.language) diff --git a/app/lib/request.rb b/app/lib/request.rb index e73c5ac20..c01e07925 100644 --- a/app/lib/request.rb +++ b/app/lib/request.rb @@ -12,15 +12,21 @@ class Request @headers = {} set_common_headers! + set_digest! if options.key?(:body) end - def on_behalf_of(account) + def on_behalf_of(account, key_id_format = :acct) raise ArgumentError unless account.local? - @account = account + + @account = account + @key_id_format = key_id_format + + self end def add_headers(new_headers) @headers.merge!(new_headers) + self end def perform @@ -40,8 +46,11 @@ class Request @headers['Date'] = Time.now.utc.httpdate end + def set_digest! + @headers['Digest'] = "SHA-256=#{Digest::SHA256.base64digest(@options[:body])}" + end + def signature - key_id = @account.to_webfinger_s algorithm = 'rsa-sha256' signature = Base64.strict_encode64(@account.keypair.sign(OpenSSL::Digest::SHA256.new, signed_string)) @@ -60,6 +69,15 @@ class Request @user_agent ||= "#{HTTP::Request::USER_AGENT} (Mastodon/#{Mastodon::Version}; +#{root_url})" end + def key_id + case @key_id_format + when :acct + @account.to_webfinger_s + when :uri + [ActivityPub::TagManager.instance.uri_for(@account), '#main-key'].join + end + end + def timeout { write: 10, connect: 10, read: 10 } end diff --git a/app/lib/stream_entry_finder.rb b/app/lib/status_finder.rb index 0ea33229c..4d1aed297 100644 --- a/app/lib/stream_entry_finder.rb +++ b/app/lib/status_finder.rb @@ -1,20 +1,22 @@ # frozen_string_literal: true -class StreamEntryFinder +class StatusFinder attr_reader :url def initialize(url) @url = url end - def stream_entry + def status verify_action! + raise ActiveRecord::RecordNotFound unless TagManager.instance.local_url?(url) + case recognized_params[:controller] when 'stream_entries' - StreamEntry.find(recognized_params[:id]) + StreamEntry.find(recognized_params[:id]).status when 'statuses' - Status.find(recognized_params[:id]).stream_entry + Status.find(recognized_params[:id]) else raise ActiveRecord::RecordNotFound end diff --git a/app/lib/tag_manager.rb b/app/lib/tag_manager.rb index 5f87a2a48..f33a20c6f 100644 --- a/app/lib/tag_manager.rb +++ b/app/lib/tag_manager.rb @@ -49,12 +49,17 @@ class TagManager def unique_tag_to_local_id(tag, expected_type) return nil unless local_id?(tag) - matches = Regexp.new("objectId=([\\d]+):objectType=#{expected_type}").match(tag) - return matches[1] unless matches.nil? + + if ActivityPub::TagManager.instance.local_uri?(tag) + ActivityPub::TagManager.instance.uri_to_local_id(tag) + else + matches = Regexp.new("objectId=([\\d]+):objectType=#{expected_type}").match(tag) + return matches[1] unless matches.nil? + end end def local_id?(id) - id.start_with?("tag:#{Rails.configuration.x.local_domain}") + id.start_with?("tag:#{Rails.configuration.x.local_domain}") || ActivityPub::TagManager.instance.local_uri?(id) end def web_domain?(domain) @@ -92,7 +97,7 @@ class TagManager when :person account_url(target) when :note, :comment, :activity - unique_tag(target.created_at, target.id, 'Status') + target.uri || unique_tag(target.created_at, target.id, 'Status') end end diff --git a/app/models/account.rb b/app/models/account.rb index e217733f5..d0ebf5a5e 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -77,6 +77,10 @@ class Account < ApplicationRecord has_many :mentions, inverse_of: :account, dependent: :destroy has_many :notifications, inverse_of: :account, dependent: :destroy + # Pinned statuses + has_many :status_pins, inverse_of: :account, dependent: :destroy + has_many :pinned_statuses, -> { reorder('status_pins.created_at DESC') }, through: :status_pins, class_name: 'Status', source: :status + # Media has_many :media_attachments, dependent: :destroy @@ -91,7 +95,7 @@ class Account < ApplicationRecord scope :local, -> { where(domain: nil) } scope :without_followers, -> { where(followers_count: 0) } scope :with_followers, -> { where('followers_count > 0') } - scope :expiring, ->(time) { where(subscription_expires_at: nil).or(where('subscription_expires_at < ?', time)).remote.with_followers } + scope :expiring, ->(time) { remote.where.not(subscription_expires_at: nil).where('subscription_expires_at < ?', time) } scope :partitioned, -> { order('row_number() over (partition by domain)') } scope :silenced, -> { where(silenced: true) } scope :suspended, -> { where(suspended: true) } @@ -105,6 +109,7 @@ class Account < ApplicationRecord :current_sign_in_ip, :current_sign_in_at, :confirmed?, + :admin?, :locale, to: :user, prefix: true, @@ -133,11 +138,11 @@ class Account < ApplicationRecord end def keypair - OpenSSL::PKey::RSA.new(private_key || public_key) + @keypair ||= OpenSSL::PKey::RSA.new(private_key || public_key) end def subscription(webhook_url) - OStatus2::Subscription.new(remote_url, secret: secret, lease_seconds: 30.days.seconds, webhook: webhook_url, hub: hub_url) + @subscription ||= OStatus2::Subscription.new(remote_url, secret: secret, webhook: webhook_url, hub: hub_url) end def save_with_optional_media! @@ -171,6 +176,10 @@ class Account < ApplicationRecord reorder(nil).pluck('distinct accounts.domain') end + def inboxes + reorder(nil).where(protocol: :activitypub).pluck("distinct coalesce(nullif(accounts.shared_inbox_url, ''), accounts.inbox_url)") + end + def triadic_closures(account, limit: 5, offset: 0) sql = <<-SQL.squish WITH first_degree AS ( @@ -263,7 +272,7 @@ class Account < ApplicationRecord def generate_keys return unless local? - keypair = OpenSSL::PKey::RSA.new(Rails.env.test? ? 1024 : 2048) + keypair = OpenSSL::PKey::RSA.new(Rails.env.test? ? 512 : 2048) self.private_key = keypair.to_pem self.public_key = keypair.public_key.to_pem end diff --git a/app/models/concerns/account_avatar.rb b/app/models/concerns/account_avatar.rb index b0ec689a7..8a5c9a22c 100644 --- a/app/models/concerns/account_avatar.rb +++ b/app/models/concerns/account_avatar.rb @@ -8,7 +8,7 @@ module AccountAvatar class_methods do def avatar_styles(file) styles = { original: '120x120#' } - styles[:static] = { animated: false } if file.content_type == 'image/gif' + styles[:static] = { format: 'png', convert_options: '-coalesce' } if file.content_type == 'image/gif' styles end diff --git a/app/models/concerns/account_header.rb b/app/models/concerns/account_header.rb index 542e25abe..aff2aa3f9 100644 --- a/app/models/concerns/account_header.rb +++ b/app/models/concerns/account_header.rb @@ -8,7 +8,7 @@ module AccountHeader class_methods do def header_styles(file) styles = { original: '700x335#' } - styles[:static] = { animated: false } if file.content_type == 'image/gif' + styles[:static] = { format: 'png', convert_options: '-coalesce' } if file.content_type == 'image/gif' styles end diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb index 9ffed2910..b26520f5b 100644 --- a/app/models/concerns/account_interactions.rb +++ b/app/models/concerns/account_interactions.rb @@ -138,4 +138,8 @@ module AccountInteractions def reblogged?(status) status.proper.reblogs.where(account: self).exists? end + + def pinned?(status) + status_pins.where(status: status).exists? + end end diff --git a/app/models/concerns/remotable.rb b/app/models/concerns/remotable.rb index 1bd87a642..270043a9e 100644 --- a/app/models/concerns/remotable.rb +++ b/app/models/concerns/remotable.rb @@ -10,6 +10,8 @@ module Remotable alt_method_name = "reset_#{attachment_name}!".to_sym define_method method_name do |url| + return if url.blank? + begin parsed_url = Addressable::URI.parse(url).normalize rescue Addressable::URI::InvalidURIError diff --git a/app/models/import.rb b/app/models/import.rb index 815e02589..4656c3af6 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -28,4 +28,5 @@ class Import < ApplicationRecord has_attached_file :data, url: '/system/:hash.:extension', hash_secret: ENV['PAPERCLIP_SECRET'] validates_attachment_content_type :data, content_type: FILE_TYPES + validates_attachment_presence :data end diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb index 1e8c6d00a..d83ca44f1 100644 --- a/app/models/media_attachment.rb +++ b/app/models/media_attachment.rb @@ -142,9 +142,11 @@ class MediaAttachment < ApplicationRecord def populate_meta meta = {} + file.queued_for_write.each do |style, file| begin geo = Paperclip::Geometry.from_file file + meta[style] = { width: geo.width.to_i, height: geo.height.to_i, @@ -155,6 +157,7 @@ class MediaAttachment < ApplicationRecord meta[style] = {} end end + meta end diff --git a/app/models/preview_card.rb b/app/models/preview_card.rb index c334c48aa..b7efac354 100644 --- a/app/models/preview_card.rb +++ b/app/models/preview_card.rb @@ -4,16 +4,13 @@ # Table name: preview_cards # # id :integer not null, primary key -# status_id :integer # url :string default(""), not null -# title :string -# description :string +# title :string default(""), not null +# description :string default(""), not null # image_file_name :string # image_content_type :string # image_file_size :integer # image_updated_at :datetime -# created_at :datetime not null -# updated_at :datetime not null # type :integer default("link"), not null # html :text default(""), not null # author_name :string default(""), not null @@ -22,6 +19,8 @@ # provider_url :string default(""), not null # width :integer default(0), not null # height :integer default(0), not null +# created_at :datetime not null +# updated_at :datetime not null # class PreviewCard < ApplicationRecord @@ -31,21 +30,37 @@ class PreviewCard < ApplicationRecord enum type: [:link, :photo, :video, :rich] - belongs_to :status + has_and_belongs_to_many :statuses - has_attached_file :image, styles: { original: '120x120#' }, convert_options: { all: '-quality 80 -strip' } + has_attached_file :image, styles: { original: '280x120>' }, convert_options: { all: '-quality 80 -strip' } include Attachmentable include Remotable - validates :url, presence: true + validates :url, presence: true, uniqueness: true validates_attachment_content_type :image, content_type: IMAGE_MIME_TYPES validates_attachment_size :image, less_than: 1.megabytes + before_save :extract_dimensions, if: :link? + def save_with_optional_image! save! rescue ActiveRecord::RecordInvalid self.image = nil save! end + + private + + def extract_dimensions + file = image.queued_for_write[:original] + + return if file.nil? + + geo = Paperclip::Geometry.from_file(file) + self.width = geo.width.to_i + self.height = geo.height.to_i + rescue Paperclip::Errors::NotIdentifiedByImageMagickError + nil + end end diff --git a/app/models/remote_follow.rb b/app/models/remote_follow.rb index 8366d43c5..c3f867743 100644 --- a/app/models/remote_follow.rb +++ b/app/models/remote_follow.rb @@ -42,7 +42,7 @@ class RemoteFollow def acct_resource @_acct_resource ||= Goldfinger.finger("acct:#{acct}") - rescue Goldfinger::Error + rescue Goldfinger::Error, HTTP::ConnectionError nil end diff --git a/app/models/session_activation.rb b/app/models/session_activation.rb index 7eb16af8f..c1645223b 100644 --- a/app/models/session_activation.rb +++ b/app/models/session_activation.rb @@ -25,6 +25,7 @@ # class SessionActivation < ApplicationRecord + belongs_to :user, inverse_of: :session_activations, required: true belongs_to :access_token, class_name: 'Doorkeeper::AccessToken', dependent: :destroy belongs_to :web_push_subscription, class_name: 'Web::PushSubscription', dependent: :destroy diff --git a/app/models/status.rb b/app/models/status.rb index 24eaf7071..fdc230d8f 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -22,6 +22,7 @@ # reblogs_count :integer default(0), not null # language :string # conversation_id :integer +# local :boolean # class Status < ApplicationRecord @@ -47,10 +48,12 @@ class Status < ApplicationRecord has_many :replies, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :thread has_many :mentions, dependent: :destroy has_many :media_attachments, dependent: :destroy + has_and_belongs_to_many :tags + has_and_belongs_to_many :preview_cards has_one :notification, as: :activity, dependent: :destroy - has_one :preview_card, dependent: :destroy + has_one :stream_entry, as: :activity, inverse_of: :status validates :uri, uniqueness: true, unless: :local? validates :text, presence: true, unless: :reblog? @@ -82,7 +85,7 @@ class Status < ApplicationRecord end def local? - uri.nil? + attributes['local'] || uri.nil? end def reblog? @@ -90,7 +93,11 @@ class Status < ApplicationRecord end def verb - reblog? ? :share : :post + if destroyed? + :delete + else + reblog? ? :share : :post + end end def object_type @@ -110,7 +117,11 @@ class Status < ApplicationRecord end def title - reblog? ? "#{account.acct} shared a status by #{reblog.account.acct}" : "New status by #{account.acct}" + if destroyed? + "#{account.acct} deleted status" + else + reblog? ? "#{account.acct} shared a status by #{reblog.account.acct}" : "New status by #{account.acct}" + end end def hidden? @@ -121,11 +132,14 @@ class Status < ApplicationRecord !sensitive? && media_attachments.any? end + after_create :store_uri, if: :local? + before_validation :prepare_contents, if: :local? before_validation :set_reblog before_validation :set_visibility before_validation :set_conversation before_validation :set_sensitivity + before_validation :set_local class << self def not_in_filtered_languages(account) @@ -164,6 +178,10 @@ class Status < ApplicationRecord ConversationMute.select('conversation_id').where(conversation_id: conversation_ids).where(account_id: account_id).map { |m| [m.conversation_id, true] }.to_h end + def pins_map(status_ids, account_id) + StatusPin.select('status_id').where(status_id: status_ids).where(account_id: account_id).map { |p| [p.status_id, true] }.to_h + end + def reload_stale_associations!(cached_items) account_ids = [] @@ -239,6 +257,10 @@ class Status < ApplicationRecord private + def store_uri + update_attribute(:uri, ActivityPub::TagManager.instance.uri_for(self)) if uri.nil? + end + def prepare_contents text&.strip! spoiler_text&.strip! @@ -278,4 +300,8 @@ class Status < ApplicationRecord thread.account_id end end + + def set_local + self.local = account.local? + end end diff --git a/app/models/status_pin.rb b/app/models/status_pin.rb new file mode 100644 index 000000000..a72c19750 --- /dev/null +++ b/app/models/status_pin.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: status_pins +# +# id :integer not null, primary key +# account_id :integer not null +# status_id :integer not null +# created_at :datetime not null +# updated_at :datetime not null +# + +class StatusPin < ApplicationRecord + belongs_to :account, required: true + belongs_to :status, required: true + + validates_with StatusPinValidator +end diff --git a/app/models/subscription.rb b/app/models/subscription.rb index bf643c1f9..14f1a140c 100644 --- a/app/models/subscription.rb +++ b/app/models/subscription.rb @@ -26,6 +26,7 @@ class Subscription < ApplicationRecord scope :confirmed, -> { where(confirmed: true) } scope :future_expiration, -> { where(arel_table[:expires_at].gt(Time.now.utc)) } + scope :expired, -> { where(arel_table[:expires_at].lt(Time.now.utc)) } scope :active, -> { confirmed.future_expiration } def lease_seconds=(value) diff --git a/app/models/user.rb b/app/models/user.rb index 96a2d09b7..5e548c1ef 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -46,6 +46,8 @@ class User < ApplicationRecord belongs_to :account, inverse_of: :user, required: true accepts_nested_attributes_for :account + has_many :applications, class_name: 'Doorkeeper::Application', as: :owner + validates :locale, inclusion: I18n.available_locales.map(&:to_s), if: :locale? validates_with BlacklistedEmailValidator, if: :email_changed? @@ -108,10 +110,21 @@ class User < ApplicationRecord settings.noindex end + def token_for_app(a) + return nil if a.nil? || a.owner != self + Doorkeeper::AccessToken + .find_or_create_by(application_id: a.id, resource_owner_id: id) do |t| + + t.scopes = a.scopes + t.expires_in = Doorkeeper.configuration.access_token_expires_in + t.use_refresh_token = Doorkeeper.configuration.refresh_token_enabled? + end + end + def activate_session(request) session_activations.activate(session_id: SecureRandom.hex, user_agent: request.user_agent, - ip: request.ip).session_id + ip: request.remote_ip).session_id end def exclusive_session(id) diff --git a/app/models/web/push_subscription.rb b/app/models/web/push_subscription.rb index e76f61278..cb15dfa37 100644 --- a/app/models/web/push_subscription.rb +++ b/app/models/web/push_subscription.rb @@ -13,59 +13,14 @@ # require 'webpush' -require_relative '../../models/setting' class Web::PushSubscription < ApplicationRecord - include RoutingHelper - include StreamEntriesHelper - include ActionView::Helpers::TranslationHelper - include ActionView::Helpers::SanitizeHelper - has_one :session_activation - before_create :send_welcome_notification - def push(notification) - name = display_name notification.from_account - title = title_str(name, notification) - body = body_str notification - dir = dir_str body - url = url_str notification - image = image_str notification - actions = actions_arr notification - - access_token = actions.empty? ? nil : find_or_create_access_token(notification).token - nsfw = notification.target_status.nil? || notification.target_status.spoiler_text.empty? ? nil : notification.target_status.spoiler_text - - # TODO: Make sure that the payload does not exceed 4KB - Webpush::PayloadTooLarge - Webpush.payload_send( - message: JSON.generate( - title: title, - dir: dir, - image: image, - badge: full_asset_url('badge.png', skip_pipeline: true), - tag: notification.id, - timestamp: notification.created_at, - icon: notification.from_account.avatar_static_url, - data: { - content: decoder.decode(strip_tags(body)), - nsfw: nsfw.nil? ? nil : decoder.decode(strip_tags(nsfw)), - url: url, - actions: actions, - access_token: access_token, - message: translate('push_notifications.group.title'), # Do not pass count, will be formatted in the ServiceWorker - } - ), - endpoint: endpoint, - p256dh: key_p256dh, - auth: key_auth, - vapid: { - subject: "mailto:#{Setting.site_contact_email}", - private_key: Rails.configuration.x.vapid_private_key, - public_key: Rails.configuration.x.vapid_public_key, - }, - ttl: 40 * 60 * 60 # 48 hours - ) + I18n.with_locale(session_activation.user.locale || I18n.default_locale) do + push_payload(message_from(notification), 48.hours.seconds) + end end def pushable?(notification) @@ -73,120 +28,47 @@ class Web::PushSubscription < ApplicationRecord end def as_payload - payload = { - id: id, - endpoint: endpoint, - } - + payload = { id: id, endpoint: endpoint } payload[:alerts] = data['alerts'] if data && data.key?('alerts') - payload end - private - - def title_str(name, notification) - case notification.type - when :mention then translate('push_notifications.mention.title', name: name) - when :follow then translate('push_notifications.follow.title', name: name) - when :favourite then translate('push_notifications.favourite.title', name: name) - when :reblog then translate('push_notifications.reblog.title', name: name) - end + def access_token + find_or_create_access_token.token end - def body_str(notification) - case notification.type - when :mention then notification.target_status.text - when :follow then notification.from_account.note - when :favourite then notification.target_status.text - when :reblog then notification.target_status.text - end - end - - def url_str(notification) - case notification.type - when :mention then web_url("statuses/#{notification.target_status.id}") - when :follow then web_url("accounts/#{notification.from_account.id}") - when :favourite then web_url("statuses/#{notification.target_status.id}") - when :reblog then web_url("statuses/#{notification.target_status.id}") - end - end - - def actions_arr(notification) - actions = - case notification.type - when :mention then [ - { - title: translate('push_notifications.mention.action_favourite'), - icon: full_asset_url('web-push-icon_favourite.png', skip_pipeline: true), - todo: 'request', - method: 'POST', - action: "/api/v1/statuses/#{notification.target_status.id}/favourite", - }, - ] - else [] - end - - should_hide = notification.type.equal?(:mention) && !notification.target_status.nil? && (notification.target_status.sensitive || !notification.target_status.spoiler_text.empty?) - can_boost = notification.type.equal?(:mention) && !notification.target_status.nil? && !notification.target_status.hidden? - - if should_hide - actions.insert(0, title: translate('push_notifications.mention.action_expand'), icon: full_asset_url('web-push-icon_expand.png', skip_pipeline: true), todo: 'expand', action: 'expand') - end - - if can_boost - actions << { title: translate('push_notifications.mention.action_boost'), icon: full_asset_url('web-push-icon_reblog.png', skip_pipeline: true), todo: 'request', method: 'POST', action: "/api/v1/statuses/#{notification.target_status.id}/reblog" } - end - - actions - end - - def image_str(notification) - return nil if notification.target_status.nil? || notification.target_status.media_attachments.empty? - - full_asset_url(notification.target_status.media_attachments.first.file.url(:small)) - end + private - def dir_str(body) - rtl?(body) ? 'rtl' : 'ltr' - end + def push_payload(message, ttl = 5.minutes.seconds) + # TODO: Make sure that the payload does not + # exceed 4KB - Webpush::PayloadTooLarge - def send_welcome_notification Webpush.payload_send( - message: JSON.generate( - title: translate('push_notifications.subscribed.title'), - icon: full_asset_url('android-chrome-192x192.png', skip_pipeline: true), - badge: full_asset_url('badge.png', skip_pipeline: true), - data: { - content: translate('push_notifications.subscribed.body'), - actions: [], - url: web_url('notifications'), - message: translate('push_notifications.group.title'), # Do not pass count, will be formatted in the ServiceWorker - } - ), + message: Oj.dump(message), endpoint: endpoint, p256dh: key_p256dh, auth: key_auth, + ttl: ttl, vapid: { - subject: "mailto:#{Setting.site_contact_email}", + subject: "mailto:#{::Setting.site_contact_email}", private_key: Rails.configuration.x.vapid_private_key, public_key: Rails.configuration.x.vapid_public_key, - }, - ttl: 5 * 60 # 5 minutes + } ) end - def find_or_create_access_token(notification) + def message_from(notification) + serializable_resource = ActiveModelSerializers::SerializableResource.new(notification, serializer: Web::NotificationSerializer, scope: self, scope_name: :current_push_subscription) + serializable_resource.as_json + end + + def find_or_create_access_token Doorkeeper::AccessToken.find_or_create_for( Doorkeeper::Application.find_by(superapp: true), - notification.account.user.id, + session_activation.user_id, Doorkeeper::OAuth::Scopes.from_string('read write follow'), Doorkeeper.configuration.access_token_expires_in, Doorkeeper.configuration.refresh_token_enabled? ) end - - def decoder - @decoder ||= HTMLEntities.new - end end diff --git a/app/presenters/account_relationships_presenter.rb b/app/presenters/account_relationships_presenter.rb index 657807863..a30558bac 100644 --- a/app/presenters/account_relationships_presenter.rb +++ b/app/presenters/account_relationships_presenter.rb @@ -4,12 +4,12 @@ class AccountRelationshipsPresenter attr_reader :following, :followed_by, :blocking, :muting, :requested, :domain_blocking - def initialize(account_ids, current_account_id) - @following = Account.following_map(account_ids, current_account_id) - @followed_by = Account.followed_by_map(account_ids, current_account_id) - @blocking = Account.blocking_map(account_ids, current_account_id) - @muting = Account.muting_map(account_ids, current_account_id) - @requested = Account.requested_map(account_ids, current_account_id) - @domain_blocking = Account.domain_blocking_map(account_ids, current_account_id) + def initialize(account_ids, current_account_id, options = {}) + @following = Account.following_map(account_ids, current_account_id).merge(options[:following_map] || {}) + @followed_by = Account.followed_by_map(account_ids, current_account_id).merge(options[:followed_by_map] || {}) + @blocking = Account.blocking_map(account_ids, current_account_id).merge(options[:blocking_map] || {}) + @muting = Account.muting_map(account_ids, current_account_id).merge(options[:muting_map] || {}) + @requested = Account.requested_map(account_ids, current_account_id).merge(options[:requested_map] || {}) + @domain_blocking = Account.domain_blocking_map(account_ids, current_account_id).merge(options[:domain_blocking_map] || {}) end end diff --git a/app/presenters/initial_state_presenter.rb b/app/presenters/initial_state_presenter.rb index 9507aad4a..70c496be8 100644 --- a/app/presenters/initial_state_presenter.rb +++ b/app/presenters/initial_state_presenter.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true class InitialStatePresenter < ActiveModelSerializers::Model - attributes :settings, :push_subscription, :token, :current_account, :admin + attributes :settings, :push_subscription, :token, + :current_account, :admin, :text end diff --git a/app/presenters/instance_presenter.rb b/app/presenters/instance_presenter.rb index 4de6b5e0d..b1afb9e1f 100644 --- a/app/presenters/instance_presenter.rb +++ b/app/presenters/instance_presenter.rb @@ -40,4 +40,8 @@ class InstancePresenter '' end end + + def source_url + Mastodon::Version.source_url + end end diff --git a/app/presenters/status_relationships_presenter.rb b/app/presenters/status_relationships_presenter.rb index 03294015f..10b449504 100644 --- a/app/presenters/status_relationships_presenter.rb +++ b/app/presenters/status_relationships_presenter.rb @@ -1,19 +1,24 @@ # frozen_string_literal: true class StatusRelationshipsPresenter - attr_reader :reblogs_map, :favourites_map, :mutes_map + attr_reader :reblogs_map, :favourites_map, :mutes_map, :pins_map - def initialize(statuses, current_account_id = nil, reblogs_map: {}, favourites_map: {}, mutes_map: {}) + def initialize(statuses, current_account_id = nil, options = {}) if current_account_id.nil? @reblogs_map = {} @favourites_map = {} @mutes_map = {} + @pins_map = {} else - status_ids = statuses.compact.flat_map { |s| [s.id, s.reblog_of_id] }.uniq - conversation_ids = statuses.compact.map(&:conversation_id).compact.uniq - @reblogs_map = Status.reblogs_map(status_ids, current_account_id).merge(reblogs_map) - @favourites_map = Status.favourites_map(status_ids, current_account_id).merge(favourites_map) - @mutes_map = Status.mutes_map(conversation_ids, current_account_id).merge(mutes_map) + statuses = statuses.compact + status_ids = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.uniq + conversation_ids = statuses.map(&:conversation_id).compact.uniq + pinnable_status_ids = statuses.map(&:proper).select { |s| s.account_id == current_account_id && %w(public unlisted).include?(s.visibility) }.map(&:id) + + @reblogs_map = Status.reblogs_map(status_ids, current_account_id).merge(options[:reblogs_map] || {}) + @favourites_map = Status.favourites_map(status_ids, current_account_id).merge(options[:favourites_map] || {}) + @mutes_map = Status.mutes_map(conversation_ids, current_account_id).merge(options[:mutes_map] || {}) + @pins_map = Status.pins_map(pinnable_status_ids, current_account_id).merge(options[:pins_map] || {}) end end end diff --git a/app/serializers/activitypub/accept_follow_serializer.rb b/app/serializers/activitypub/accept_follow_serializer.rb index ce900bc78..3e23591a5 100644 --- a/app/serializers/activitypub/accept_follow_serializer.rb +++ b/app/serializers/activitypub/accept_follow_serializer.rb @@ -1,10 +1,14 @@ # frozen_string_literal: true class ActivityPub::AcceptFollowSerializer < ActiveModel::Serializer - attributes :type, :actor + attributes :id, :type, :actor has_one :object, serializer: ActivityPub::FollowSerializer + def id + [ActivityPub::TagManager.instance.uri_for(object.target_account), '#accepts/follows/', object.id].join + end + def type 'Accept' end diff --git a/app/serializers/activitypub/activity_serializer.rb b/app/serializers/activitypub/activity_serializer.rb index 69e2160c5..349495e84 100644 --- a/app/serializers/activitypub/activity_serializer.rb +++ b/app/serializers/activitypub/activity_serializer.rb @@ -6,11 +6,11 @@ class ActivityPub::ActivitySerializer < ActiveModel::Serializer has_one :proper, key: :object, serializer: ActivityPub::NoteSerializer def id - [ActivityPub::TagManager.instance.uri_for(object), '/activity'].join + [ActivityPub::TagManager.instance.activity_uri_for(object)].join end def type - object.reblog? ? 'Announce' : 'Create' + announce? ? 'Announce' : 'Create' end def actor @@ -24,4 +24,8 @@ class ActivityPub::ActivitySerializer < ActiveModel::Serializer def cc ActivityPub::TagManager.instance.cc(object) end + + def announce? + object.reblog? + end end diff --git a/app/serializers/activitypub/actor_serializer.rb b/app/serializers/activitypub/actor_serializer.rb index f5e626d73..a11178f5b 100644 --- a/app/serializers/activitypub/actor_serializer.rb +++ b/app/serializers/activitypub/actor_serializer.rb @@ -4,11 +4,41 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer include RoutingHelper attributes :id, :type, :following, :followers, - :inbox, :outbox, :preferred_username, - :name, :summary, :icon, :image + :inbox, :outbox, + :preferred_username, :name, :summary, + :url, :manually_approves_followers has_one :public_key, serializer: ActivityPub::PublicKeySerializer + class ImageSerializer < ActiveModel::Serializer + include RoutingHelper + + attributes :type, :url + + def type + 'Image' + end + + def url + full_asset_url(object.url(:original)) + end + end + + class EndpointsSerializer < ActiveModel::Serializer + include RoutingHelper + + attributes :shared_inbox + + def shared_inbox + inbox_url + end + end + + has_one :endpoints, serializer: EndpointsSerializer + + has_one :icon, serializer: ImageSerializer, if: :avatar_exists? + has_one :image, serializer: ImageSerializer, if: :header_exists? + def id account_url(object) end @@ -26,13 +56,17 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer end def inbox - nil + account_inbox_url(object) end def outbox account_outbox_url(object) end + def endpoints + object + end + def preferred_username object.username end @@ -46,14 +80,30 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer end def icon - full_asset_url(object.avatar.url(:original)) + object.avatar end def image - full_asset_url(object.header.url(:original)) + object.header end def public_key object end + + def url + short_account_url(object) + end + + def avatar_exists? + object.avatar.exists? + end + + def header_exists? + object.header.exists? + end + + def manually_approves_followers + object.locked + end end diff --git a/app/serializers/activitypub/block_serializer.rb b/app/serializers/activitypub/block_serializer.rb index a001b213b..b3bd9f868 100644 --- a/app/serializers/activitypub/block_serializer.rb +++ b/app/serializers/activitypub/block_serializer.rb @@ -1,9 +1,13 @@ # frozen_string_literal: true class ActivityPub::BlockSerializer < ActiveModel::Serializer - attributes :type, :actor + attributes :id, :type, :actor attribute :virtual_object, key: :object + def id + [ActivityPub::TagManager.instance.uri_for(object.account), '#blocks/', object.id].join + end + def type 'Block' end diff --git a/app/serializers/activitypub/delete_serializer.rb b/app/serializers/activitypub/delete_serializer.rb index 77098b1b0..87a43b95d 100644 --- a/app/serializers/activitypub/delete_serializer.rb +++ b/app/serializers/activitypub/delete_serializer.rb @@ -1,8 +1,29 @@ # frozen_string_literal: true class ActivityPub::DeleteSerializer < ActiveModel::Serializer - attributes :type, :actor - attribute :virtual_object, key: :object + class TombstoneSerializer < ActiveModel::Serializer + attributes :id, :type, :atom_uri + + def id + ActivityPub::TagManager.instance.uri_for(object) + end + + def type + 'Tombstone' + end + + def atom_uri + ::TagManager.instance.uri_for(object) + end + end + + attributes :id, :type, :actor + + has_one :object, serializer: TombstoneSerializer + + def id + [ActivityPub::TagManager.instance.uri_for(object), '#delete'].join + end def type 'Delete' @@ -11,8 +32,4 @@ class ActivityPub::DeleteSerializer < ActiveModel::Serializer def actor ActivityPub::TagManager.instance.uri_for(object.account) end - - def virtual_object - ActivityPub::TagManager.instance.uri_for(object) - end end diff --git a/app/serializers/activitypub/follow_serializer.rb b/app/serializers/activitypub/follow_serializer.rb index 1953a2d7b..86c9992fe 100644 --- a/app/serializers/activitypub/follow_serializer.rb +++ b/app/serializers/activitypub/follow_serializer.rb @@ -1,9 +1,13 @@ # frozen_string_literal: true class ActivityPub::FollowSerializer < ActiveModel::Serializer - attributes :type, :actor + attributes :id, :type, :actor attribute :virtual_object, key: :object + def id + [ActivityPub::TagManager.instance.uri_for(object.account), '#follows/', object.id].join + end + def type 'Follow' end diff --git a/app/serializers/activitypub/like_serializer.rb b/app/serializers/activitypub/like_serializer.rb index 4226913f5..c1a7ff6f6 100644 --- a/app/serializers/activitypub/like_serializer.rb +++ b/app/serializers/activitypub/like_serializer.rb @@ -1,9 +1,13 @@ # frozen_string_literal: true class ActivityPub::LikeSerializer < ActiveModel::Serializer - attributes :type, :actor + attributes :id, :type, :actor attribute :virtual_object, key: :object + def id + [ActivityPub::TagManager.instance.uri_for(object.account), '#likes/', object.id].join + end + def type 'Like' end diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb index 4c13f8e59..d42f54263 100644 --- a/app/serializers/activitypub/note_serializer.rb +++ b/app/serializers/activitypub/note_serializer.rb @@ -3,7 +3,9 @@ class ActivityPub::NoteSerializer < ActiveModel::Serializer attributes :id, :type, :summary, :content, :in_reply_to, :published, :url, - :attributed_to, :to, :cc, :sensitive + :attributed_to, :to, :cc, :sensitive, + :atom_uri, :in_reply_to_atom_uri, + :conversation has_many :media_attachments, key: :attachment has_many :virtual_tags, key: :tag @@ -25,7 +27,13 @@ class ActivityPub::NoteSerializer < ActiveModel::Serializer end def in_reply_to - ActivityPub::TagManager.instance.uri_for(object.thread) if object.reply? + return unless object.reply? + + if object.thread.uri.nil? || object.thread.uri.start_with?('http') + ActivityPub::TagManager.instance.uri_for(object.thread) + else + object.thread.url + end end def published @@ -52,6 +60,30 @@ class ActivityPub::NoteSerializer < ActiveModel::Serializer object.mentions + object.tags end + def atom_uri + return unless object.local? + + ::TagManager.instance.uri_for(object) + end + + def in_reply_to_atom_uri + return unless object.reply? + + ::TagManager.instance.uri_for(object.thread) + end + + def conversation + if object.conversation.uri? + object.conversation.uri + else + TagManager.instance.unique_tag(object.conversation.created_at, object.conversation.id, 'Conversation') + end + end + + def local? + object.account.local? + end + class MediaAttachmentSerializer < ActiveModel::Serializer include RoutingHelper diff --git a/app/serializers/activitypub/reject_follow_serializer.rb b/app/serializers/activitypub/reject_follow_serializer.rb index 28584d627..7814f4f57 100644 --- a/app/serializers/activitypub/reject_follow_serializer.rb +++ b/app/serializers/activitypub/reject_follow_serializer.rb @@ -1,10 +1,14 @@ # frozen_string_literal: true class ActivityPub::RejectFollowSerializer < ActiveModel::Serializer - attributes :type, :actor + attributes :id, :type, :actor has_one :object, serializer: ActivityPub::FollowSerializer + def id + [ActivityPub::TagManager.instance.uri_for(object.target_account), '#rejects/follows/', object.id].join + end + def type 'Reject' end diff --git a/app/serializers/activitypub/undo_announce_serializer.rb b/app/serializers/activitypub/undo_announce_serializer.rb new file mode 100644 index 000000000..839847e22 --- /dev/null +++ b/app/serializers/activitypub/undo_announce_serializer.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class ActivityPub::UndoAnnounceSerializer < ActiveModel::Serializer + attributes :id, :type, :actor + + has_one :object, serializer: ActivityPub::ActivitySerializer + + def id + [ActivityPub::TagManager.instance.uri_for(object.account), '#announces/', object.id, '/undo'].join + end + + def type + 'Undo' + end + + def actor + ActivityPub::TagManager.instance.uri_for(object.account) + end +end diff --git a/app/serializers/activitypub/undo_block_serializer.rb b/app/serializers/activitypub/undo_block_serializer.rb index f71faa729..2f43d8402 100644 --- a/app/serializers/activitypub/undo_block_serializer.rb +++ b/app/serializers/activitypub/undo_block_serializer.rb @@ -1,10 +1,14 @@ # frozen_string_literal: true class ActivityPub::UndoBlockSerializer < ActiveModel::Serializer - attributes :type, :actor + attributes :id, :type, :actor has_one :object, serializer: ActivityPub::BlockSerializer + def id + [ActivityPub::TagManager.instance.uri_for(object.account), '#blocks/', object.id, '/undo'].join + end + def type 'Undo' end diff --git a/app/serializers/activitypub/undo_follow_serializer.rb b/app/serializers/activitypub/undo_follow_serializer.rb index fe91f5f1c..e5b7f143d 100644 --- a/app/serializers/activitypub/undo_follow_serializer.rb +++ b/app/serializers/activitypub/undo_follow_serializer.rb @@ -1,10 +1,14 @@ # frozen_string_literal: true class ActivityPub::UndoFollowSerializer < ActiveModel::Serializer - attributes :type, :actor + attributes :id, :type, :actor has_one :object, serializer: ActivityPub::FollowSerializer + def id + [ActivityPub::TagManager.instance.uri_for(object.account), '#follows/', object.id, '/undo'].join + end + def type 'Undo' end diff --git a/app/serializers/activitypub/undo_like_serializer.rb b/app/serializers/activitypub/undo_like_serializer.rb index db9cd1d0d..25f4ccaae 100644 --- a/app/serializers/activitypub/undo_like_serializer.rb +++ b/app/serializers/activitypub/undo_like_serializer.rb @@ -1,10 +1,14 @@ # frozen_string_literal: true class ActivityPub::UndoLikeSerializer < ActiveModel::Serializer - attributes :type, :actor + attributes :id, :type, :actor has_one :object, serializer: ActivityPub::LikeSerializer + def id + [ActivityPub::TagManager.instance.uri_for(object.account), '#likes/', object.id, '/undo'].join + end + def type 'Undo' end diff --git a/app/serializers/activitypub/update_serializer.rb b/app/serializers/activitypub/update_serializer.rb index 322305da8..ebc667d96 100644 --- a/app/serializers/activitypub/update_serializer.rb +++ b/app/serializers/activitypub/update_serializer.rb @@ -1,10 +1,14 @@ # frozen_string_literal: true class ActivityPub::UpdateSerializer < ActiveModel::Serializer - attributes :type, :actor + attributes :id, :type, :actor has_one :object, serializer: ActivityPub::ActorSerializer + def id + [ActivityPub::TagManager.instance.uri_for(object), '#updates/', object.updated_at.to_i].join + end + def type 'Update' end diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index 0191948b1..32ffcc688 100644 --- a/app/serializers/initial_state_serializer.rb +++ b/app/serializers/initial_state_serializer.rb @@ -19,7 +19,6 @@ class InitialStateSerializer < ActiveModel::Serializer store[:boost_modal] = object.current_account.user.setting_boost_modal store[:delete_modal] = object.current_account.user.setting_delete_modal store[:auto_play_gif] = object.current_account.user.setting_auto_play_gif - store[:system_font_ui] = object.current_account.user.setting_system_font_ui end store @@ -34,6 +33,8 @@ class InitialStateSerializer < ActiveModel::Serializer store[:default_sensitive] = object.current_account.user.setting_default_sensitive end + store[:text] = object.text if object.text + store end diff --git a/app/serializers/oembed_serializer.rb b/app/serializers/oembed_serializer.rb index 78376d253..bd05da585 100644 --- a/app/serializers/oembed_serializer.rb +++ b/app/serializers/oembed_serializer.rb @@ -21,7 +21,7 @@ class OEmbedSerializer < ActiveModel::Serializer end def author_url - account_url(object.account) + short_account_url(object.account) end def provider_name @@ -37,13 +37,15 @@ class OEmbedSerializer < ActiveModel::Serializer end def html - tag :iframe, - src: embed_account_stream_entry_url(object.account, object), - style: 'width: 100%; overflow: hidden', - frameborder: '0', - scrolling: 'no', - width: width, - height: height + attributes = { + src: embed_short_account_status_url(object.account, object), + class: 'mastodon-embed', + style: 'max-width: 100%; border: none;', + width: width, + height: height, + } + + content_tag :iframe, nil, attributes end def width diff --git a/app/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb index 8e32f9cb3..a97137909 100644 --- a/app/serializers/rest/instance_serializer.rb +++ b/app/serializers/rest/instance_serializer.rb @@ -2,7 +2,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer attributes :uri, :title, :description, :email, - :version, :urls + :version, :urls, :stats def uri Rails.configuration.x.local_domain @@ -24,7 +24,21 @@ class REST::InstanceSerializer < ActiveModel::Serializer Mastodon::Version.to_s end + def stats + { + user_count: instance_presenter.user_count, + status_count: instance_presenter.status_count, + domain_count: instance_presenter.domain_count, + } + end + def urls { streaming_api: Rails.configuration.x.streaming_api_base_url } end + + private + + def instance_presenter + @instance_presenter ||= InstancePresenter.new + end end diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb index 246b12a90..298a3bb40 100644 --- a/app/serializers/rest/status_serializer.rb +++ b/app/serializers/rest/status_serializer.rb @@ -8,6 +8,7 @@ class REST::StatusSerializer < ActiveModel::Serializer attribute :favourited, if: :current_user? attribute :reblogged, if: :current_user? attribute :muted, if: :current_user? + attribute :pinned, if: :pinnable? belongs_to :reblog, serializer: REST::StatusSerializer belongs_to :application @@ -57,6 +58,21 @@ class REST::StatusSerializer < ActiveModel::Serializer end end + def pinned + if instance_options && instance_options[:relationships] + instance_options[:relationships].pins_map[object.id] || false + else + current_user.account.pinned?(object) + end + end + + def pinnable? + current_user? && + current_user.account_id == object.account_id && + !object.reblog? && + %w(public unlisted).include?(object.visibility) + end + class ApplicationSerializer < ActiveModel::Serializer attributes :name, :website end diff --git a/app/serializers/web/notification_serializer.rb b/app/serializers/web/notification_serializer.rb new file mode 100644 index 000000000..e5524fe7a --- /dev/null +++ b/app/serializers/web/notification_serializer.rb @@ -0,0 +1,169 @@ +# frozen_string_literal: true + +class Web::NotificationSerializer < ActiveModel::Serializer + include RoutingHelper + include StreamEntriesHelper + + class DataSerializer < ActiveModel::Serializer + include RoutingHelper + include StreamEntriesHelper + include ActionView::Helpers::SanitizeHelper + + attributes :content, :nsfw, :url, :actions, + :access_token, :message, :dir + + def content + decoder.decode(strip_tags(body)) + end + + def dir + rtl?(body) ? 'rtl' : 'ltr' + end + + def nsfw + return if object.target_status.nil? + object.target_status.spoiler_text.presence + end + + def url + case object.type + when :mention + web_url("statuses/#{object.target_status.id}") + when :follow + web_url("accounts/#{object.from_account.id}") + when :favourite + web_url("statuses/#{object.target_status.id}") + when :reblog + web_url("statuses/#{object.target_status.id}") + end + end + + def actions + return @actions if defined?(@actions) + + @actions = [] + + if object.type == :mention + @actions << expand_action if collapsed? + @actions << favourite_action + @actions << reblog_action if rebloggable? + end + + @actions + end + + def access_token + return if actions.empty? + current_push_subscription.access_token + end + + def message + I18n.t('push_notifications.group.title') + end + + private + + def body + case object.type + when :mention + object.target_status.text + when :follow + object.from_account.note + when :favourite + object.target_status.text + when :reblog + object.target_status.text + end + end + + def decoder + @decoder ||= HTMLEntities.new + end + + def expand_action + { + title: I18n.t('push_notifications.mention.action_expand'), + icon: full_asset_url('web-push-icon_expand.png', skip_pipeline: true), + todo: 'expand', + action: 'expand', + } + end + + def favourite_action + { + title: I18n.t('push_notifications.mention.action_favourite'), + icon: full_asset_url('web-push-icon_favourite.png', skip_pipeline: true), + todo: 'request', + method: 'POST', + action: "/api/v1/statuses/#{object.target_status.id}/favourite", + } + end + + def reblog_action + { + title: I18n.t('push_notifications.mention.action_boost'), + icon: full_asset_url('web-push-icon_reblog.png', skip_pipeline: true), + todo: 'request', + method: 'POST', + action: "/api/v1/statuses/#{object.target_status.id}/reblog", + } + end + + def collapsed? + !object.target_status.nil? && (object.target_status.sensitive? || object.target_status.spoiler_text.present?) + end + + def rebloggable? + !object.target_status.nil? && !object.target_status.hidden? + end + end + + attributes :title, :image, :badge, :tag, + :timestamp, :icon + + has_one :data, serializer: DataSerializer + + def title + case object.type + when :mention + I18n.t('push_notifications.mention.title', name: name) + when :follow + I18n.t('push_notifications.follow.title', name: name) + when :favourite + I18n.t('push_notifications.favourite.title', name: name) + when :reblog + I18n.t('push_notifications.reblog.title', name: name) + end + end + + def image + return if object.target_status.nil? || object.target_status.media_attachments.empty? + full_asset_url(object.target_status.media_attachments.first.file.url(:small)) + end + + def badge + full_asset_url('badge.png', skip_pipeline: true) + end + + def tag + object.id + end + + def timestamp + object.created_at + end + + def icon + object.from_account.avatar_static_url + end + + def data + object + end + + private + + def name + display_name(object.from_account) + end +end diff --git a/app/services/activitypub/fetch_remote_account_service.rb b/app/services/activitypub/fetch_remote_account_service.rb new file mode 100644 index 000000000..3eeca585e --- /dev/null +++ b/app/services/activitypub/fetch_remote_account_service.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +class ActivityPub::FetchRemoteAccountService < BaseService + include JsonLdHelper + + # Should be called when uri has already been checked for locality + # Does a WebFinger roundtrip on each call + def call(uri, prefetched_json = nil) + @json = body_to_json(prefetched_json) || fetch_resource(uri) + + return unless supported_context? && expected_type? + + @uri = @json['id'] + @username = @json['preferredUsername'] + @domain = Addressable::URI.parse(uri).normalized_host + + return unless verified_webfinger? + + ActivityPub::ProcessAccountService.new.call(@username, @domain, @json) + rescue Oj::ParseError + nil + end + + private + + def verified_webfinger? + webfinger = Goldfinger.finger("acct:#{@username}@#{@domain}") + confirmed_username, confirmed_domain = split_acct(webfinger.subject) + + return true if @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero? + + webfinger = Goldfinger.finger("acct:#{confirmed_username}@#{confirmed_domain}") + confirmed_username, confirmed_domain = split_acct(webfinger.subject) + self_reference = webfinger.link('self') + + return false if self_reference&.href != @uri + + @username = confirmed_username + @domain = confirmed_domain + + true + rescue Goldfinger::Error + false + end + + def split_acct(acct) + acct.gsub(/\Aacct:/, '').split('@') + end + + def supported_context? + super(@json) + end + + def expected_type? + @json['type'] == 'Person' + end +end diff --git a/app/services/activitypub/fetch_remote_key_service.rb b/app/services/activitypub/fetch_remote_key_service.rb new file mode 100644 index 000000000..ebd64071e --- /dev/null +++ b/app/services/activitypub/fetch_remote_key_service.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +class ActivityPub::FetchRemoteKeyService < BaseService + include JsonLdHelper + + # Returns account that owns the key + def call(uri, prefetched_json = nil) + @json = body_to_json(prefetched_json) || fetch_resource(uri) + + return unless supported_context?(@json) && expected_type? + return find_account(uri, @json) if person? + + @owner = fetch_resource(owner_uri) + + return unless supported_context?(@owner) && confirmed_owner? + + find_account(owner_uri, @owner) + end + + private + + def find_account(uri, prefetched_json) + account = ActivityPub::TagManager.instance.uri_to_resource(uri, Account) + account ||= ActivityPub::FetchRemoteAccountService.new.call(uri, prefetched_json) + account + end + + def expected_type? + person? || public_key? + end + + def person? + @json['type'] == 'Person' + end + + def public_key? + @json['publicKeyPem'].present? && @json['owner'].present? + end + + def owner_uri + @owner_uri ||= value_or_id(@json['owner']) + end + + def confirmed_owner? + @owner['type'] == 'Person' && value_or_id(@owner['publicKey']) == @json['id'] + end +end diff --git a/app/services/activitypub/fetch_remote_status_service.rb b/app/services/activitypub/fetch_remote_status_service.rb new file mode 100644 index 000000000..68ca58d62 --- /dev/null +++ b/app/services/activitypub/fetch_remote_status_service.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +class ActivityPub::FetchRemoteStatusService < BaseService + include JsonLdHelper + + # Should be called when uri has already been checked for locality + def call(uri, prefetched_json = nil) + @json = body_to_json(prefetched_json) || fetch_resource(uri) + + return unless supported_context? + + activity = activity_json + actor_id = value_or_id(activity['actor']) + + return unless expected_type?(activity) && trustworthy_attribution?(uri, actor_id) + + actor = ActivityPub::TagManager.instance.uri_to_resource(actor_id, Account) + actor = ActivityPub::FetchRemoteAccountService.new.call(actor_id) if actor.nil? + + ActivityPub::Activity.factory(activity, actor).perform + end + + private + + def activity_json + if %w(Note Article).include? @json['type'] + { + 'type' => 'Create', + 'actor' => first_of_value(@json['attributedTo']), + 'object' => @json, + } + else + @json + end + end + + def trustworthy_attribution?(uri, attributed_to) + Addressable::URI.parse(uri).normalized_host.casecmp(Addressable::URI.parse(attributed_to).normalized_host).zero? + end + + def supported_context? + super(@json) + end + + def expected_type?(json) + %w(Create Announce).include? json['type'] + end +end diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb new file mode 100644 index 000000000..29eb1c2e1 --- /dev/null +++ b/app/services/activitypub/process_account_service.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +class ActivityPub::ProcessAccountService < BaseService + include JsonLdHelper + + # Should be called with confirmed valid JSON + # and WebFinger-resolved username and domain + def call(username, domain, json) + return if json['inbox'].blank? + + @json = json + @uri = @json['id'] + @username = username + @domain = domain + @account = Account.find_by(uri: @uri) + + create_account if @account.nil? + upgrade_account if @account.ostatus? + update_account + + @account + rescue Oj::ParseError + nil + end + + private + + def create_account + @account = Account.new + @account.protocol = :activitypub + @account.username = @username + @account.domain = @domain + @account.uri = @uri + @account.suspended = true if auto_suspend? + @account.silenced = true if auto_silence? + @account.private_key = nil + @account.save! + end + + def update_account + @account.last_webfingered_at = Time.now.utc + @account.protocol = :activitypub + @account.inbox_url = @json['inbox'] || '' + @account.outbox_url = @json['outbox'] || '' + @account.shared_inbox_url = (@json['endpoints'].is_a?(Hash) ? @json['endpoints']['sharedInbox'] : @json['sharedInbox']) || '' + @account.followers_url = @json['followers'] || '' + @account.url = url || @uri + @account.display_name = @json['name'] || '' + @account.note = @json['summary'] || '' + @account.avatar_remote_url = image_url('icon') + @account.header_remote_url = image_url('image') + @account.public_key = public_key || '' + @account.locked = @json['manuallyApprovesFollowers'] || false + @account.save! + end + + def upgrade_account + ActivityPub::PostUpgradeWorker.perform_async(@account.domain) + end + + def image_url(key) + value = first_of_value(@json[key]) + + return if value.nil? + return value['url'] if value.is_a?(Hash) + + image = fetch_resource(value) + image['url'] if image + end + + def public_key + value = first_of_value(@json['publicKey']) + + return if value.nil? + return value['publicKeyPem'] if value.is_a?(Hash) + + key = fetch_resource(value) + key['publicKeyPem'] if key + end + + def url + return if @json['url'].blank? + + value = first_of_value(@json['url']) + + return value if value.is_a?(String) + + value['href'] + end + + def auto_suspend? + domain_block && domain_block.suspend? + end + + def auto_silence? + domain_block && domain_block.silence? + end + + def domain_block + return @domain_block if defined?(@domain_block) + @domain_block = DomainBlock.find_by(domain: @domain) + end +end diff --git a/app/services/activitypub/process_collection_service.rb b/app/services/activitypub/process_collection_service.rb new file mode 100644 index 000000000..bc04c50ba --- /dev/null +++ b/app/services/activitypub/process_collection_service.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +class ActivityPub::ProcessCollectionService < BaseService + include JsonLdHelper + + def call(body, account) + @account = account + @json = Oj.load(body, mode: :strict) + + return if @account.suspended? || !supported_context? + + return if different_actor? && verify_account!.nil? + + case @json['type'] + when 'Collection', 'CollectionPage' + process_items @json['items'] + when 'OrderedCollection', 'OrderedCollectionPage' + process_items @json['orderedItems'] + else + process_items [@json] + end + rescue Oj::ParseError + nil + end + + private + + def different_actor? + @json['actor'].present? && value_or_id(@json['actor']) != @account.uri && @json['signature'].present? + end + + def process_items(items) + items.reverse_each.map { |item| process_item(item) }.compact + end + + def supported_context? + super(@json) + end + + def process_item(item) + activity = ActivityPub::Activity.factory(item, @account) + activity&.perform + end + + def verify_account! + @account = ActivityPub::LinkedDataSignature.new(@json).verify_account! + end +end diff --git a/app/services/authorize_follow_service.rb b/app/services/authorize_follow_service.rb index 41815a393..b1bff8962 100644 --- a/app/services/authorize_follow_service.rb +++ b/app/services/authorize_follow_service.rb @@ -1,14 +1,36 @@ # frozen_string_literal: true class AuthorizeFollowService < BaseService - def call(source_account, target_account) - follow_request = FollowRequest.find_by!(account: source_account, target_account: target_account) - follow_request.authorize! - NotificationWorker.perform_async(build_xml(follow_request), target_account.id, source_account.id) unless source_account.local? + def call(source_account, target_account, options = {}) + if options[:skip_follow_request] + follow_request = FollowRequest.new(account: source_account, target_account: target_account) + else + follow_request = FollowRequest.find_by!(account: source_account, target_account: target_account) + follow_request.authorize! + end + + create_notification(follow_request) unless source_account.local? + follow_request end private + def create_notification(follow_request) + if follow_request.account.ostatus? + NotificationWorker.perform_async(build_xml(follow_request), follow_request.target_account_id, follow_request.account_id) + elsif follow_request.account.activitypub? + ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), follow_request.target_account_id, follow_request.account.inbox_url) + end + end + + def build_json(follow_request) + Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new( + follow_request, + serializer: ActivityPub::AcceptFollowSerializer, + adapter: ActivityPub::Adapter + ).as_json).sign!(follow_request.target_account)) + end + def build_xml(follow_request) OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.authorize_follow_request_salmon(follow_request)) end diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb index ab810c628..86eaa5735 100644 --- a/app/services/batched_remove_status_service.rb +++ b/app/services/batched_remove_status_service.rb @@ -15,19 +15,26 @@ class BatchedRemoveStatusService < BaseService @mentions = statuses.map { |s| [s.id, s.mentions.includes(:account).to_a] }.to_h @tags = statuses.map { |s| [s.id, s.tags.pluck(:name)] }.to_h - @stream_entry_batches = [] - @salmon_batches = [] - @json_payloads = statuses.map { |s| [s.id, Oj.dump(event: :delete, payload: s.id)] }.to_h + @stream_entry_batches = [] + @salmon_batches = [] + @activity_json_batches = [] + @json_payloads = statuses.map { |s| [s.id, Oj.dump(event: :delete, payload: s.id)] }.to_h + @activity_json = {} + @activity_xml = {} # Ensure that rendered XML reflects destroyed state - Status.where(id: statuses.map(&:id)).in_batches.destroy_all + statuses.each(&:destroy) # Batch by source account statuses.group_by(&:account_id).each do |_, account_statuses| account = account_statuses.first.account unpush_from_home_timelines(account_statuses) - batch_stream_entries(account_statuses) if account.local? + + if account.local? + batch_stream_entries(account, account_statuses) + batch_activity_json(account, account_statuses) + end end # Cannot be batched @@ -36,17 +43,32 @@ class BatchedRemoveStatusService < BaseService batch_salmon_slaps(status) if status.local? end - Pubsubhubbub::DistributionWorker.push_bulk(@stream_entry_batches) { |batch| batch } + Pubsubhubbub::RawDistributionWorker.push_bulk(@stream_entry_batches) { |batch| batch } NotificationWorker.push_bulk(@salmon_batches) { |batch| batch } + ActivityPub::DeliveryWorker.push_bulk(@activity_json_batches) { |batch| batch } end private - def batch_stream_entries(statuses) - stream_entry_ids = statuses.map { |s| s.stream_entry.id } + def batch_stream_entries(account, statuses) + statuses.each do |status| + @stream_entry_batches << [build_xml(status.stream_entry), account.id] + end + end - stream_entry_ids.each_slice(100) do |batch_of_stream_entry_ids| - @stream_entry_batches << [batch_of_stream_entry_ids] + def batch_activity_json(account, statuses) + account.followers.inboxes.each do |inbox_url| + statuses.each do |status| + @activity_json_batches << [build_json(status), account.id, inbox_url] + end + end + + statuses.each do |status| + other_recipients = (status.mentions + status.reblogs).map(&:account).reject(&:local?).select(&:activitypub?).uniq(&:id) + + other_recipients.each do |target_account| + @activity_json_batches << [build_json(status), account.id, target_account.inbox_url] + end end end @@ -78,11 +100,10 @@ class BatchedRemoveStatusService < BaseService def batch_salmon_slaps(status) return if @mentions[status.id].empty? - payload = stream_entry_to_xml(status.stream_entry.reload) - recipients = @mentions[status.id].map(&:account).reject(&:local?).uniq(&:domain).map(&:id) + recipients = @mentions[status.id].map(&:account).reject(&:local?).select(&:ostatus?).uniq(&:domain).map(&:id) recipients.each do |recipient_id| - @salmon_batches << [payload, status.account_id, recipient_id] + @salmon_batches << [build_xml(status.stream_entry), status.account_id, recipient_id] end end @@ -111,4 +132,24 @@ class BatchedRemoveStatusService < BaseService def redis Redis.current end + + def build_json(status) + return @activity_json[status.id] if @activity_json.key?(status.id) + + @activity_json[status.id] = sign_json(status, ActiveModelSerializers::SerializableResource.new( + status, + serializer: status.reblog? ? ActivityPub::UndoAnnounceSerializer : ActivityPub::DeleteSerializer, + adapter: ActivityPub::Adapter + ).as_json) + end + + def build_xml(stream_entry) + return @activity_xml[stream_entry.id] if @activity_xml.key?(stream_entry.id) + + @activity_xml[stream_entry.id] = stream_entry_to_xml(stream_entry) + end + + def sign_json(status, json) + Oj.dump(ActivityPub::LinkedDataSignature.new(json).sign!(status.account)) + end end diff --git a/app/services/block_domain_service.rb b/app/services/block_domain_service.rb index a6b3c4cdb..1473bc841 100644 --- a/app/services/block_domain_service.rb +++ b/app/services/block_domain_service.rb @@ -30,7 +30,7 @@ class BlockDomainService < BaseService def suspend_accounts! blocked_domain_accounts.where(suspended: false).find_each do |account| - account.subscription(api_subscription_url(account.id)).unsubscribe if account.subscribed? + UnsubscribeService.new.call(account) if account.subscribed? SuspendAccountService.new.call(account) end end diff --git a/app/services/block_service.rb b/app/services/block_service.rb index 5d7bf6a3b..b39c3eef2 100644 --- a/app/services/block_service.rb +++ b/app/services/block_service.rb @@ -12,11 +12,28 @@ class BlockService < BaseService block = account.block!(target_account) BlockWorker.perform_async(account.id, target_account.id) - NotificationWorker.perform_async(build_xml(block), account.id, target_account.id) unless target_account.local? + create_notification(block) unless target_account.local? + block end private + def create_notification(block) + if block.target_account.ostatus? + NotificationWorker.perform_async(build_xml(block), block.account_id, block.target_account_id) + elsif block.target_account.activitypub? + ActivityPub::DeliveryWorker.perform_async(build_json(block), block.account_id, block.target_account.inbox_url) + end + end + + def build_json(block) + Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new( + block, + serializer: ActivityPub::BlockSerializer, + adapter: ActivityPub::Adapter + ).as_json).sign!(block.account)) + end + def build_xml(block) OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.block_salmon(block)) end diff --git a/app/services/favourite_service.rb b/app/services/favourite_service.rb index 291f9e56e..44df3ed13 100644 --- a/app/services/favourite_service.rb +++ b/app/services/favourite_service.rb @@ -15,18 +15,32 @@ class FavouriteService < BaseService return favourite unless favourite.nil? favourite = Favourite.create!(account: account, status: status) - - if status.local? - NotifyService.new.call(favourite.status.account, favourite) - else - NotificationWorker.perform_async(build_xml(favourite), account.id, status.account_id) - end - + create_notification(favourite) favourite end private + def create_notification(favourite) + status = favourite.status + + if status.account.local? + NotifyService.new.call(status.account, favourite) + elsif status.account.ostatus? + NotificationWorker.perform_async(build_xml(favourite), favourite.account_id, status.account_id) + elsif status.account.activitypub? + ActivityPub::DeliveryWorker.perform_async(build_json(favourite), favourite.account_id, status.account.inbox_url) + end + end + + def build_json(favourite) + Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new( + favourite, + serializer: ActivityPub::LikeSerializer, + adapter: ActivityPub::Adapter + ).as_json).sign!(favourite.account)) + end + def build_xml(favourite) OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.favourite_salmon(favourite)) end diff --git a/app/services/fetch_atom_service.rb b/app/services/fetch_atom_service.rb index 3ac441e3e..9c5777b5d 100644 --- a/app/services/fetch_atom_service.rb +++ b/app/services/fetch_atom_service.rb @@ -1,21 +1,17 @@ # frozen_string_literal: true class FetchAtomService < BaseService + include JsonLdHelper + def call(url) return if url.blank? - response = Request.new(:head, url).perform - - Rails.logger.debug "Remote status HEAD request returned code #{response.code}" + result = process(url) - response = Request.new(:get, url).perform if response.code == 405 + # retry without ActivityPub + result ||= process(url) if @unsupported_activity - Rails.logger.debug "Remote status GET request returned code #{response.code}" - - return nil if response.code != 200 - return [url, fetch(url)] if response.mime_type == 'application/atom+xml' - return process_headers(url, response) if response['Link'].present? - process_html(fetch(url)) + result rescue OpenSSL::SSL::SSLError => e Rails.logger.debug "SSL error: #{e}" nil @@ -26,27 +22,67 @@ class FetchAtomService < BaseService private - def process_html(body) - Rails.logger.debug 'Processing HTML' + def process(url, terminal = false) + @url = url + perform_request + process_response(terminal) + end + + def perform_request + accept = 'text/html' + accept = 'application/activity+json, application/ld+json, application/atom+xml, ' + accept unless @unsupported_activity + + @response = Request.new(:get, @url) + .add_headers('Accept' => accept) + .perform + end - page = Nokogiri::HTML(body) - alternate_link = page.xpath('//link[@rel="alternate"]').find { |link| link['type'] == 'application/atom+xml' } + def process_response(terminal = false) + return nil if @response.code != 200 - return nil if alternate_link.nil? - [alternate_link['href'], fetch(alternate_link['href'])] + if @response.mime_type == 'application/atom+xml' + [@url, @response.to_s, :ostatus] + elsif ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(@response.mime_type) + if supported_activity?(@response.to_s) + [@url, @response.to_s, :activitypub] + else + @unsupported_activity = true + nil + end + elsif @response['Link'] && !terminal + process_headers + elsif @response.mime_type == 'text/html' && !terminal + process_html + end end - def process_headers(url, response) - Rails.logger.debug 'Processing link header' + def process_html + page = Nokogiri::HTML(@response.to_s) + + json_link = page.xpath('//link[@rel="alternate"]').find { |link| ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(link['type']) } + atom_link = page.xpath('//link[@rel="alternate"]').find { |link| link['type'] == 'application/atom+xml' } + + result ||= process(json_link['href'], terminal: true) unless json_link.nil? || @unsupported_activity + result ||= process(atom_link['href'], terminal: true) unless atom_link.nil? + + result + end + + def process_headers + link_header = LinkHeader.parse(@response['Link'].is_a?(Array) ? @response['Link'].first : @response['Link']) + + json_link = link_header.find_link(%w(rel alternate), %w(type application/activity+json)) || link_header.find_link(%w(rel alternate), ['type', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"']) + atom_link = link_header.find_link(%w(rel alternate), %w(type application/atom+xml)) - link_header = LinkHeader.parse(response['Link'].is_a?(Array) ? response['Link'].first : response['Link']) - alternate_link = link_header.find_link(%w(rel alternate), %w(type application/atom+xml)) + result ||= process(json_link.href, terminal: true) unless json_link.nil? || @unsupported_activity + result ||= process(atom_link.href, terminal: true) unless atom_link.nil? - return process_html(fetch(url)) if alternate_link.nil? - [alternate_link.href, fetch(alternate_link.href)] + result end - def fetch(url) - Request.new(:get, url).perform.to_s + def supported_activity?(body) + json = body_to_json(body) + return false unless supported_context?(json) + json['type'] == 'Person' ? json['inbox'].present? : true end end diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb index 20c85e0ea..c38e9e7df 100644 --- a/app/services/fetch_link_card_service.rb +++ b/app/services/fetch_link_card_service.rb @@ -4,29 +4,45 @@ class FetchLinkCardService < BaseService URL_PATTERN = %r{https?://\S+} def call(status) - # Get first http/https URL that isn't local - url = parse_urls(status) + @status = status + @url = parse_urls - return if url.nil? + return if @url.nil? || @status.preview_cards.any? - url = url.to_s - card = PreviewCard.where(status: status).first_or_initialize(status: status, url: url) - res = Request.new(:head, url).perform + @url = @url.to_s - return if res.code != 200 || res.mime_type != 'text/html' + RedisLock.acquire(lock_options) do |lock| + if lock.acquired? + @card = PreviewCard.find_by(url: @url) + process_url if @card.nil? + end + end - attempt_opengraph(card, url) unless attempt_oembed(card, url) + attach_card unless @card.nil? rescue HTTP::ConnectionError, OpenSSL::SSL::SSLError nil end private - def parse_urls(status) - if status.local? - urls = status.text.match(URL_PATTERN).to_a.map { |uri| Addressable::URI.parse(uri).normalize } + def process_url + @card = PreviewCard.new(url: @url) + res = Request.new(:head, @url).perform + + return if res.code != 200 || res.mime_type != 'text/html' + + attempt_oembed || attempt_opengraph + end + + def attach_card + @status.preview_cards << @card + end + + def parse_urls + if @status.local? + urls = @status.text.match(URL_PATTERN).to_a.map { |uri| Addressable::URI.parse(uri).normalize } else - html = Nokogiri::HTML(status.text) + html = Nokogiri::HTML(@status.text) links = html.css('a') urls = links.map { |a| Addressable::URI.parse(a['href']).normalize unless skip_link?(a) }.compact end @@ -44,41 +60,41 @@ class FetchLinkCardService < BaseService a['rel']&.include?('tag') || a['class']&.include?('u-url') end - def attempt_oembed(card, url) - response = OEmbed::Providers.get(url) + def attempt_oembed + response = OEmbed::Providers.get(@url) - card.type = response.type - card.title = response.respond_to?(:title) ? response.title : '' - card.author_name = response.respond_to?(:author_name) ? response.author_name : '' - card.author_url = response.respond_to?(:author_url) ? response.author_url : '' - card.provider_name = response.respond_to?(:provider_name) ? response.provider_name : '' - card.provider_url = response.respond_to?(:provider_url) ? response.provider_url : '' - card.width = 0 - card.height = 0 + @card.type = response.type + @card.title = response.respond_to?(:title) ? response.title : '' + @card.author_name = response.respond_to?(:author_name) ? response.author_name : '' + @card.author_url = response.respond_to?(:author_url) ? response.author_url : '' + @card.provider_name = response.respond_to?(:provider_name) ? response.provider_name : '' + @card.provider_url = response.respond_to?(:provider_url) ? response.provider_url : '' + @card.width = 0 + @card.height = 0 - case card.type + case @card.type when 'link' - card.image = URI.parse(response.thumbnail_url) if response.respond_to?(:thumbnail_url) + @card.image = URI.parse(response.thumbnail_url) if response.respond_to?(:thumbnail_url) when 'photo' - card.url = response.url - card.width = response.width.presence || 0 - card.height = response.height.presence || 0 + @card.url = response.url + @card.width = response.width.presence || 0 + @card.height = response.height.presence || 0 when 'video' - card.width = response.width.presence || 0 - card.height = response.height.presence || 0 - card.html = Formatter.instance.sanitize(response.html, Sanitize::Config::MASTODON_OEMBED) + @card.width = response.width.presence || 0 + @card.height = response.height.presence || 0 + @card.html = Formatter.instance.sanitize(response.html, Sanitize::Config::MASTODON_OEMBED) when 'rich' # Most providers rely on <script> tags, which is a no-no return false end - card.save_with_optional_image! + @card.save_with_optional_image! rescue OEmbed::NotFound false end - def attempt_opengraph(card, url) - response = Request.new(:get, url).perform + def attempt_opengraph + response = Request.new(:get, @url).perform return if response.code != 200 || response.mime_type != 'text/html' @@ -88,19 +104,23 @@ class FetchLinkCardService < BaseService detector.strip_tags = true guess = detector.detect(html, response.charset) - page = Nokogiri::HTML(html, nil, guess&.fetch(:encoding)) + page = Nokogiri::HTML(html, nil, guess&.fetch(:encoding)) - card.type = :link - card.title = meta_property(page, 'og:title') || page.at_xpath('//title')&.content - card.description = meta_property(page, 'og:description') || meta_property(page, 'description') - card.image_remote_url = meta_property(page, 'og:image') if meta_property(page, 'og:image') + @card.type = :link + @card.title = meta_property(page, 'og:title') || page.at_xpath('//title')&.content || '' + @card.description = meta_property(page, 'og:description') || meta_property(page, 'description') || '' + @card.image_remote_url = meta_property(page, 'og:image') if meta_property(page, 'og:image') - return if card.title.blank? + return if @card.title.blank? - card.save_with_optional_image! + @card.save_with_optional_image! end def meta_property(html, property) html.at_xpath("//meta[@property=\"#{property}\"]")&.attribute('content')&.value || html.at_xpath("//meta[@name=\"#{property}\"]")&.attribute('content')&.value end + + def lock_options + { redis: Redis.current, key: "fetch:#{@url}" } + end end diff --git a/app/services/fetch_remote_account_service.rb b/app/services/fetch_remote_account_service.rb index 8eed0d454..7c618a0b0 100644 --- a/app/services/fetch_remote_account_service.rb +++ b/app/services/fetch_remote_account_service.rb @@ -3,16 +3,20 @@ class FetchRemoteAccountService < BaseService include AuthorExtractor - def call(url, prefetched_body = nil) + def call(url, prefetched_body = nil, protocol = :ostatus) if prefetched_body.nil? - atom_url, body = FetchAtomService.new.call(url) + resource_url, body, protocol = FetchAtomService.new.call(url) else - atom_url = url - body = prefetched_body + resource_url = url + body = prefetched_body end - return nil if atom_url.nil? - process_atom(atom_url, body) + case protocol + when :ostatus + process_atom(resource_url, body) + when :activitypub + ActivityPub::FetchRemoteAccountService.new.call(resource_url, body) + end end private diff --git a/app/services/fetch_remote_resource_service.rb b/app/services/fetch_remote_resource_service.rb index 2c1c1f05f..341664272 100644 --- a/app/services/fetch_remote_resource_service.rb +++ b/app/services/fetch_remote_resource_service.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class FetchRemoteResourceService < BaseService + include JsonLdHelper + attr_reader :url def call(url) @@ -14,11 +16,11 @@ class FetchRemoteResourceService < BaseService private def process_url - case xml_root - when 'feed' - FetchRemoteAccountService.new.call(atom_url, body) - when 'entry' - FetchRemoteStatusService.new.call(atom_url, body) + case type + when 'Person' + FetchRemoteAccountService.new.call(atom_url, body, protocol) + when 'Note' + FetchRemoteStatusService.new.call(atom_url, body, protocol) end end @@ -31,7 +33,26 @@ class FetchRemoteResourceService < BaseService end def body - fetched_atom_feed.last + fetched_atom_feed.second + end + + def protocol + fetched_atom_feed.third + end + + def type + return json_data['type'] if protocol == :activitypub + + case xml_root + when 'feed' + 'Person' + when 'entry' + 'Note' + end + end + + def json_data + @_json_data ||= body_to_json(body) end def xml_root diff --git a/app/services/fetch_remote_status_service.rb b/app/services/fetch_remote_status_service.rb index b9f5f97b1..18af18059 100644 --- a/app/services/fetch_remote_status_service.rb +++ b/app/services/fetch_remote_status_service.rb @@ -3,16 +3,20 @@ class FetchRemoteStatusService < BaseService include AuthorExtractor - def call(url, prefetched_body = nil) + def call(url, prefetched_body = nil, protocol = :ostatus) if prefetched_body.nil? - atom_url, body = FetchAtomService.new.call(url) + resource_url, body, protocol = FetchAtomService.new.call(url) else - atom_url = url - body = prefetched_body + resource_url = url + body = prefetched_body end - return nil if atom_url.nil? - process_atom(atom_url, body) + case protocol + when :ostatus + process_atom(resource_url, body) + when :activitypub + ActivityPub::FetchRemoteStatusService.new.call(resource_url, body) + end end private diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb index 3155feaa4..a92eb6b88 100644 --- a/app/services/follow_service.rb +++ b/app/services/follow_service.rb @@ -14,7 +14,7 @@ class FollowService < BaseService return if source_account.following?(target_account) - if target_account.locked? + if target_account.locked? || target_account.activitypub? request_follow(source_account, target_account) else direct_follow(source_account, target_account) @@ -28,9 +28,11 @@ class FollowService < BaseService if target_account.local? NotifyService.new.call(target_account, follow_request) - else + elsif target_account.ostatus? NotificationWorker.perform_async(build_follow_request_xml(follow_request), source_account.id, target_account.id) AfterRemoteFollowRequestWorker.perform_async(follow_request.id) + elsif target_account.activitypub? + ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), source_account.id, target_account.inbox_url) end follow_request @@ -63,4 +65,12 @@ class FollowService < BaseService def build_follow_xml(follow) OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.follow_salmon(follow)) end + + def build_json(follow_request) + Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new( + follow_request, + serializer: ActivityPub::FollowSerializer, + adapter: ActivityPub::Adapter + ).as_json).sign!(follow_request.account)) + end end diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index 0ecd8a9cd..56011a005 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -42,6 +42,8 @@ class PostStatusService < BaseService # match both with and without U+FE0F (the emoji variation selector) unless /👁\ufe0f?\z/.match?(status.content) Pubsubhubbub::DistributionWorker.perform_async(status.stream_entry.id) + ActivityPub::DistributionWorker.perform_async(status.id) + ActivityPub::ReplyDistributionWorker.perform_async(status.id) if status.reply? && status.thread.account.local? end if options[:idempotency].present? diff --git a/app/services/process_interaction_service.rb b/app/services/process_interaction_service.rb index cc99cde03..d04e926e7 100644 --- a/app/services/process_interaction_service.rb +++ b/app/services/process_interaction_service.rb @@ -67,10 +67,13 @@ class ProcessInteractionService < BaseService def follow!(account, target_account) follow = account.follow!(target_account) + FollowRequest.find_by(account: account, target_account: target_account)&.destroy NotifyService.new.call(target_account, follow) end def follow_request!(account, target_account) + return if account.requested?(target_account) + follow_request = FollowRequest.create!(account: account, target_account: target_account) NotifyService.new.call(target_account, follow_request) end @@ -88,6 +91,7 @@ class ProcessInteractionService < BaseService def unfollow!(account, target_account) account.unfollow!(target_account) + FollowRequest.find_by(account: account, target_account: target_account)&.destroy end def reflect_block!(account, target_account) diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb index 438033d22..f123bf869 100644 --- a/app/services/process_mentions_service.rb +++ b/app/services/process_mentions_service.rb @@ -28,18 +28,32 @@ class ProcessMentionsService < BaseService end status.mentions.includes(:account).each do |mention| - mentioned_account = mention.account - - if mentioned_account.local? - NotifyService.new.call(mentioned_account, mention) - else - NotificationWorker.perform_async(stream_entry_to_xml(status.stream_entry), status.account_id, mentioned_account.id) - end + create_notification(status, mention) end end private + def create_notification(status, mention) + mentioned_account = mention.account + + if mentioned_account.local? + NotifyService.new.call(mentioned_account, mention) + elsif mentioned_account.ostatus? && (Rails.configuration.x.use_ostatus_privacy || !status.stream_entry.hidden?) + NotificationWorker.perform_async(stream_entry_to_xml(status.stream_entry), status.account_id, mentioned_account.id) + elsif mentioned_account.activitypub? + ActivityPub::DeliveryWorker.perform_async(build_json(mention.status), mention.status.account_id, mentioned_account.inbox_url) + end + end + + def build_json(status) + Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new( + status, + serializer: ActivityPub::ActivitySerializer, + adapter: ActivityPub::Adapter + ).as_json).sign!(status.account)) + end + def follow_remote_account_service @follow_remote_account_service ||= ResolveRemoteAccountService.new end diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb index 497cdb4f5..52e3ba0e0 100644 --- a/app/services/reblog_service.rb +++ b/app/services/reblog_service.rb @@ -20,17 +20,35 @@ class ReblogService < BaseService reblog = account.statuses.create!(reblog: reblogged_status, text: '') DistributionWorker.perform_async(reblog.id) + unless /👁$/.match?(reblogged_status.content) Pubsubhubbub::DistributionWorker.perform_async(reblog.stream_entry.id) + ActivityPub::DistributionWorker.perform_async(reblog.id) end + create_notification(reblog) + reblog + end + + private - if reblogged_status.local? - NotifyService.new.call(reblog.reblog.account, reblog) - else - NotificationWorker.perform_async(stream_entry_to_xml(reblog.stream_entry), account.id, reblog.reblog.account_id) + def create_notification(reblog) + reblogged_status = reblog.reblog + + if reblogged_status.account.local? + NotifyService.new.call(reblogged_status.account, reblog) + elsif reblogged_status.account.ostatus? + NotificationWorker.perform_async(stream_entry_to_xml(reblog.stream_entry), reblog.account_id, reblogged_status.account_id) + elsif reblogged_status.account.activitypub? && !reblogged_status.account.following?(reblog.account) + ActivityPub::DeliveryWorker.perform_async(build_json(reblog), reblog.account_id, reblogged_status.account.inbox_url) end + end - reblog + def build_json(reblog) + Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new( + reblog, + serializer: ActivityPub::ActivitySerializer, + adapter: ActivityPub::Adapter + ).as_json).sign!(reblog.account)) end end diff --git a/app/services/reject_follow_service.rb b/app/services/reject_follow_service.rb index fd7e66c23..c1f7bcb60 100644 --- a/app/services/reject_follow_service.rb +++ b/app/services/reject_follow_service.rb @@ -4,11 +4,28 @@ class RejectFollowService < BaseService def call(source_account, target_account) follow_request = FollowRequest.find_by!(account: source_account, target_account: target_account) follow_request.reject! - NotificationWorker.perform_async(build_xml(follow_request), target_account.id, source_account.id) unless source_account.local? + create_notification(follow_request) unless source_account.local? + follow_request end private + def create_notification(follow_request) + if follow_request.account.ostatus? + NotificationWorker.perform_async(build_xml(follow_request), follow_request.target_account_id, follow_request.account_id) + elsif follow_request.account.activitypub? + ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), follow_request.target_account_id, follow_request.account.inbox_url) + end + end + + def build_json(follow_request) + Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new( + follow_request, + serializer: ActivityPub::RejectFollowSerializer, + adapter: ActivityPub::Adapter + ).as_json).sign!(follow_request.target_account)) + end + def build_xml(follow_request) OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.reject_follow_request_salmon(follow_request)) end diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb index a5281f586..83fc77043 100644 --- a/app/services/remove_status_service.rb +++ b/app/services/remove_status_service.rb @@ -22,8 +22,8 @@ class RemoveStatusService < BaseService return unless @account.local? - remove_from_mentioned(@stream_entry.reload) - Pubsubhubbub::DistributionWorker.perform_async(@stream_entry.id) + remove_from_remote_followers + remove_from_remote_affected end private @@ -38,15 +38,52 @@ class RemoveStatusService < BaseService end end - def remove_from_mentioned(stream_entry) - salmon_xml = stream_entry_to_xml(stream_entry) - target_accounts = @mentions.map(&:account).reject(&:local?).uniq(&:domain) + def remove_from_remote_affected + # People who got mentioned in the status, or who + # reblogged it from someone else might not follow + # the author and wouldn't normally receive the + # delete notification - so here, we explicitly + # send it to them - NotificationWorker.push_bulk(target_accounts) do |target_account| - [salmon_xml, stream_entry.account_id, target_account.id] + target_accounts = (@mentions.map(&:account).reject(&:local?) + @reblogs.map(&:account).reject(&:local?)).uniq(&:id) + + # Ostatus + NotificationWorker.push_bulk(target_accounts.select(&:ostatus?).uniq(&:domain)) do |target_account| + [salmon_xml, @account.id, target_account.id] + 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] + end + end + + def remove_from_remote_followers + # OStatus + Pubsubhubbub::RawDistributionWorker.perform_async(salmon_xml, @account.id) + + # ActivityPub + ActivityPub::DeliveryWorker.push_bulk(@account.followers.inboxes) do |inbox_url| + [signed_activity_json, @account.id, inbox_url] end end + def salmon_xml + @salmon_xml ||= stream_entry_to_xml(@stream_entry) + end + + def signed_activity_json + @signed_activity_json ||= Oj.dump(ActivityPub::LinkedDataSignature.new(activity_json).sign!(@account)) + end + + def activity_json + @activity_json ||= ActiveModelSerializers::SerializableResource.new( + @status, + serializer: @status.reblog? ? ActivityPub::UndoAnnounceSerializer : ActivityPub::DeleteSerializer, + adapter: ActivityPub::Adapter + ).as_json + end + def remove_reblogs # We delete reblogs of the status before the original status, # because once original status is gone, reblogs will disappear diff --git a/app/services/resolve_remote_account_service.rb b/app/services/resolve_remote_account_service.rb index e0e2ebc83..7031c98f5 100644 --- a/app/services/resolve_remote_account_service.rb +++ b/app/services/resolve_remote_account_service.rb @@ -2,6 +2,7 @@ class ResolveRemoteAccountService < BaseService include OStatus2::MagicKey + include JsonLdHelper DFRN_NS = 'http://purl.org/macgirvin/dfrn/1.0' @@ -12,6 +13,7 @@ class ResolveRemoteAccountService < BaseService # @return [Account] def call(uri, update_profile = true, redirected = nil) @username, @domain = uri.split('@') + @update_profile = update_profile return Account.find_local(@username) if TagManager.instance.local_domain?(@domain) @@ -42,10 +44,11 @@ class ResolveRemoteAccountService < BaseService if lock.acquired? @account = Account.find_remote(@username, @domain) - create_account if @account.nil? - update_account - - update_account_profile if update_profile + if activitypub_ready? + handle_activitypub + else + handle_ostatus + end end end @@ -58,18 +61,46 @@ class ResolveRemoteAccountService < BaseService private def links_missing? - @webfinger.link('http://schemas.google.com/g/2010#updates-from').nil? || + !(activitypub_ready? || ostatus_ready?) + end + + def ostatus_ready? + !(@webfinger.link('http://schemas.google.com/g/2010#updates-from').nil? || @webfinger.link('salmon').nil? || @webfinger.link('http://webfinger.net/rel/profile-page').nil? || @webfinger.link('magic-public-key').nil? || canonical_uri.nil? || - hub_url.nil? + hub_url.nil?) end def webfinger_update_due? @account.nil? || @account.last_webfingered_at.nil? || @account.last_webfingered_at <= 1.day.ago end + def activitypub_ready? + !@webfinger.link('self').nil? && + ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(@webfinger.link('self').type) && + actor_json['inbox'].present? + end + + def handle_ostatus + create_account if @account.nil? + update_account + update_account_profile if update_profile? + end + + def update_profile? + @update_profile + end + + def handle_activitypub + return if actor_json.nil? + + @account = ActivityPub::ProcessAccountService.new.call(@username, @domain, actor_json) + rescue Oj::ParseError + nil + end + def create_account Rails.logger.debug "Creating new remote account for #{@username}@#{@domain}" @@ -81,6 +112,7 @@ class ResolveRemoteAccountService < BaseService def update_account @account.last_webfingered_at = Time.now.utc + @account.protocol = :ostatus @account.remote_url = atom_url @account.salmon_url = salmon_url @account.url = url @@ -111,6 +143,10 @@ class ResolveRemoteAccountService < BaseService @salmon_url ||= @webfinger.link('salmon').href end + def actor_url + @actor_url ||= @webfinger.link('self').href + end + def url @url ||= @webfinger.link('http://webfinger.net/rel/profile-page').href end @@ -149,6 +185,13 @@ class ResolveRemoteAccountService < BaseService @atom_body = response.to_s end + def actor_json + return @actor_json if defined?(@actor_json) + + json = fetch_resource(actor_url) + @actor_json = supported_context?(json) && json['type'] == 'Person' ? json : nil + end + def atom return @atom if defined?(@atom) @atom = Nokogiri::XML(atom_body) diff --git a/app/services/subscribe_service.rb b/app/services/subscribe_service.rb index d3e41e691..bfa7ff8c8 100644 --- a/app/services/subscribe_service.rb +++ b/app/services/subscribe_service.rb @@ -2,7 +2,7 @@ class SubscribeService < BaseService def call(account) - return unless account.ostatus? + return if account.hub_url.blank? @account = account @account.secret = SecureRandom.hex @@ -42,7 +42,7 @@ class SubscribeService < BaseService end def some_local_account - @some_local_account ||= Account.local.first + @some_local_account ||= Account.local.where(suspended: false).first end # Any response in the 3xx or 4xx range, except for 429 (rate limit) diff --git a/app/services/unblock_service.rb b/app/services/unblock_service.rb index ff15c7275..869f62d1c 100644 --- a/app/services/unblock_service.rb +++ b/app/services/unblock_service.rb @@ -5,11 +5,28 @@ class UnblockService < BaseService return unless account.blocking?(target_account) unblock = account.unblock!(target_account) - NotificationWorker.perform_async(build_xml(unblock), account.id, target_account.id) unless target_account.local? + create_notification(unblock) unless target_account.local? + unblock end private + def create_notification(unblock) + if unblock.target_account.ostatus? + NotificationWorker.perform_async(build_xml(unblock), unblock.account_id, unblock.target_account_id) + elsif unblock.target_account.activitypub? + ActivityPub::DeliveryWorker.perform_async(build_json(unblock), unblock.account_id, unblock.target_account.inbox_url) + end + end + + def build_json(unblock) + Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new( + unblock, + serializer: ActivityPub::UndoBlockSerializer, + adapter: ActivityPub::Adapter + ).as_json).sign!(unblock.account)) + end + def build_xml(block) OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.unblock_salmon(block)) end diff --git a/app/services/unfavourite_service.rb b/app/services/unfavourite_service.rb index 564aaee46..2fda11bd6 100644 --- a/app/services/unfavourite_service.rb +++ b/app/services/unfavourite_service.rb @@ -4,14 +4,30 @@ class UnfavouriteService < BaseService def call(account, status) favourite = Favourite.find_by!(account: account, status: status) favourite.destroy! - - NotificationWorker.perform_async(build_xml(favourite), account.id, status.account_id) unless status.local? - + create_notification(favourite) unless status.local? favourite end private + def create_notification(favourite) + status = favourite.status + + if status.account.ostatus? + NotificationWorker.perform_async(build_xml(favourite), favourite.account_id, status.account_id) + elsif status.account.activitypub? + ActivityPub::DeliveryWorker.perform_async(build_json(favourite), favourite.account_id, status.account.inbox_url) + end + end + + def build_json(favourite) + Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new( + favourite, + serializer: ActivityPub::UndoLikeSerializer, + adapter: ActivityPub::Adapter + ).as_json).sign!(favourite.account)) + end + def build_xml(favourite) OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.unfavourite_salmon(favourite)) end diff --git a/app/services/unfollow_service.rb b/app/services/unfollow_service.rb index 388909586..73a64929f 100644 --- a/app/services/unfollow_service.rb +++ b/app/services/unfollow_service.rb @@ -5,14 +5,51 @@ class UnfollowService < BaseService # @param [Account] source_account Where to unfollow from # @param [Account] target_account Which to unfollow def call(source_account, target_account) - follow = source_account.unfollow!(target_account) - return unless follow - NotificationWorker.perform_async(build_xml(follow), source_account.id, target_account.id) unless target_account.local? - UnmergeWorker.perform_async(target_account.id, source_account.id) + @source_account = source_account + @target_account = target_account + + unfollow! || undo_follow_request! end private + def unfollow! + follow = Follow.find_by(account: @source_account, target_account: @target_account) + + return unless follow + + follow.destroy! + create_notification(follow) unless @target_account.local? + UnmergeWorker.perform_async(@target_account.id, @source_account.id) + follow + end + + def undo_follow_request! + follow_request = FollowRequest.find_by(account: @source_account, target_account: @target_account) + + return unless follow_request + + follow_request.destroy! + create_notification(follow_request) unless @target_account.local? + follow_request + end + + def create_notification(follow) + if follow.target_account.ostatus? + NotificationWorker.perform_async(build_xml(follow), follow.account_id, follow.target_account_id) + elsif follow.target_account.activitypub? + ActivityPub::DeliveryWorker.perform_async(build_json(follow), follow.account_id, follow.target_account.inbox_url) + end + end + + def build_json(follow) + Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new( + follow, + serializer: ActivityPub::UndoFollowSerializer, + adapter: ActivityPub::Adapter + ).as_json).sign!(follow.account)) + end + def build_xml(follow) OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.unfollow_salmon(follow)) end diff --git a/app/services/unsubscribe_service.rb b/app/services/unsubscribe_service.rb index c5e0e73fe..865f783bc 100644 --- a/app/services/unsubscribe_service.rb +++ b/app/services/unsubscribe_service.rb @@ -2,7 +2,7 @@ class UnsubscribeService < BaseService def call(account) - return unless account.ostatus? + return if account.hub_url.blank? @account = account @response = build_request.perform diff --git a/app/services/update_account_service.rb b/app/services/update_account_service.rb new file mode 100644 index 000000000..09ea377e7 --- /dev/null +++ b/app/services/update_account_service.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class UpdateAccountService < BaseService + def call(account, params, raise_error: false) + was_locked = account.locked + update_method = raise_error ? :update! : :update + account.send(update_method, params).tap do |ret| + next unless ret + authorize_all_follow_requests(account) if was_locked && !account.locked + end + end + + private + + def authorize_all_follow_requests(account) + follow_requests = FollowRequest.where(target_account: account) + AuthorizeFollowWorker.push_bulk(follow_requests) do |req| + [req.account_id, req.target_account_id] + end + end +end diff --git a/app/validators/status_pin_validator.rb b/app/validators/status_pin_validator.rb new file mode 100644 index 000000000..f557df6af --- /dev/null +++ b/app/validators/status_pin_validator.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class StatusPinValidator < ActiveModel::Validator + def validate(pin) + pin.errors.add(:status, I18n.t('statuses.pin_errors.reblog')) if pin.status.reblog? + pin.errors.add(:status, I18n.t('statuses.pin_errors.ownership')) if pin.account_id != pin.status.account_id + pin.errors.add(:status, I18n.t('statuses.pin_errors.private')) unless %w(public unlisted).include?(pin.status.visibility) + end +end diff --git a/app/views/about/more.html.haml b/app/views/about/more.html.haml index 6d621ce8b..99d7d2972 100644 --- a/app/views/about/more.html.haml +++ b/app/views/about/more.html.haml @@ -63,9 +63,9 @@ .footer-links .container %p - = link_to t('about.source_code'), 'https://github.com/glitch-soc/mastodon' + = link_to t('about.source_code'), @instance_presenter.source_url - if @instance_presenter.commit_hash == "" - %strong= @instance_presenter.version_number + %strong= " (#{@instance_presenter.version_number})" - else %strong= "#{@instance_presenter.version_number}, " %strong= "#{@instance_presenter.commit_hash}" diff --git a/app/views/about/show.html.haml b/app/views/about/show.html.haml index 3e04dd038..5962436fc 100644 --- a/app/views/about/show.html.haml +++ b/app/views/about/show.html.haml @@ -58,7 +58,7 @@ = @instance_presenter.closed_registrations_message.html_safe = link_to t('about.find_another_instance'), 'https://joinmastodon.org/', class: 'button button-alternative button--block' - .learn-more-cta + .about-short .container %h3= t('about.description_headline', domain: site_hostname) %p= @instance_presenter.site_description.html_safe.presence || t('about.generic_description', domain: site_hostname) @@ -76,7 +76,7 @@ .footer-links .container %p - = link_to t('about.source_code'), 'https://github.com/glitch-soc/mastodon' + = link_to t('about.source_code'), @instance_presenter.source_url - if @instance_presenter.commit_hash == "" %strong= " (#{@instance_presenter.version_number})" - else diff --git a/app/views/accounts/_grid_card.html.haml b/app/views/accounts/_grid_card.html.haml index 0571d1d5e..305eb2c44 100644 --- a/app/views/accounts/_grid_card.html.haml +++ b/app/views/accounts/_grid_card.html.haml @@ -1,8 +1,9 @@ .account-grid-card - .account-grid-card__header + .account-grid-card__header{ style: "background-image: url(#{account.header.url(:original)})" } + .account-grid-card__avatar .avatar= image_tag account.avatar.url(:original) - .name - = link_to TagManager.instance.url_for(account) do - %span.display_name.emojify= display_name(account) - %span.username @#{account.acct} + .name + = link_to TagManager.instance.url_for(account) do + %span.display_name.emojify= display_name(account) + %span.username @#{account.acct} %p.note.emojify= truncate(strip_tags(account.note), length: 150) diff --git a/app/views/accounts/_header.html.haml b/app/views/accounts/_header.html.haml index ed8a6f091..c16b7bf1f 100644 --- a/app/views/accounts/_header.html.haml +++ b/app/views/accounts/_header.html.haml @@ -1,41 +1,57 @@ - processed_bio = FrontmatterHandler.instance.process_bio Formatter.instance.simplified_format account .card.h-card.p-author{ style: "background-image: url(#{account.header.url(:original)})" } - .details + .card__illustration - if user_signed_in? && current_account.id != account.id && !current_account.requested?(account) .controls - if current_account.following?(account) - = link_to t('accounts.unfollow'), account_unfollow_path(account), data: { method: :post }, class: 'button' + = link_to account_unfollow_path(account), data: { method: :post }, class: 'icon-button' do + = fa_icon 'user-times' + = t('accounts.unfollow') - else - = link_to t('accounts.follow'), account_follow_path(account), data: { method: :post }, class: 'button' + = link_to account_follow_path(account), data: { method: :post }, class: 'icon-button' do + = fa_icon 'user-plus' + = t('accounts.follow') - elsif !user_signed_in? .controls .remote-follow - = link_to t('accounts.remote_follow'), account_remote_follow_path(account), class: 'button' + = link_to account_remote_follow_path(account), class: 'icon-button' do + = fa_icon 'user-plus' + = t('accounts.remote_follow') + .avatar= image_tag account.avatar.url(:original), class: 'u-photo' + + .card__bio %h1.name %span.p-name.emojify= display_name(account) %small - %span @#{account.username} + %span @#{account.local_username_and_domain} = fa_icon('lock') if account.locked? + + - if account.user_admin? + .roles + .account-role + = t 'accounts.roles.admin' .bio .account__header__content.p-note.emojify!=processed_bio[:text] + - if processed_bio[:metadata].length > 0 + .metadata< + - processed_bio[:metadata].each do |i| + .metadata-item>< + %b.emojify>!=i[0] + %span.emojify>!=i[1] .details-counters .counter{ class: active_nav_class(short_account_url(account)) } = link_to short_account_url(account), class: 'u-url u-uid' do + %span.counter-number= number_to_human account.statuses_count %span.counter-label= t('accounts.posts') - %span.counter-number= number_with_delimiter account.statuses_count + .counter{ class: active_nav_class(account_following_index_url(account)) } = link_to account_following_index_url(account) do + %span.counter-number= number_to_human account.following_count %span.counter-label= t('accounts.following') - %span.counter-number= number_with_delimiter account.following_count + .counter{ class: active_nav_class(account_followers_url(account)) } = link_to account_followers_url(account) do + %span.counter-number= number_to_human account.followers_count %span.counter-label= t('accounts.followers') - %span.counter-number= number_with_delimiter account.followers_count - - if processed_bio[:metadata].length > 0 - .metadata< - - processed_bio[:metadata].each do |i| - .metadata-item>< - %b.emojify>!=i[0] - %span.emojify>!=i[1] diff --git a/app/views/accounts/show.html.haml b/app/views/accounts/show.html.haml index 150c14791..e0f9f869a 100644 --- a/app/views/accounts/show.html.haml +++ b/app/views/accounts/show.html.haml @@ -7,6 +7,7 @@ %link{ rel: 'salmon', href: api_salmon_url(@account.id) }/ %link{ rel: 'alternate', type: 'application/atom+xml', href: account_url(@account, format: 'atom') }/ + %link{ rel: 'alternate', type: 'application/activity+json', href: ActivityPub::TagManager.instance.uri_for(@account) }/ %meta{ property: 'og:type', content: 'profile' }/ = render 'og', account: @account, url: short_account_url(@account, only_path: false) @@ -19,13 +20,21 @@ = render 'header', account: @account + .activity-stream-tabs + = active_link_to t('accounts.posts'), short_account_url(@account) + = active_link_to t('accounts.posts_with_replies'), short_account_with_replies_url(@account) + = active_link_to t('accounts.media'), short_account_media_url(@account) + - if @statuses.empty? .accounts-grid = render 'nothing_here' - else .activity-stream.with-header + - if params[:page].to_i.zero? + = render partial: 'stream_entries/status', collection: @pinned_statuses, as: :status, locals: { pinned: true } + = render partial: 'stream_entries/status', collection: @statuses, as: :status - if @statuses.size == 20 .pagination - = link_to safe_join([t('pagination.next'), fa_icon('chevron-right')], ' '), short_account_url(@account, max_id: @statuses.last.id), class: 'next', rel: 'next' + = link_to safe_join([t('pagination.next'), fa_icon('chevron-right')], ' '), @next_url, class: 'next', rel: 'next' diff --git a/app/views/admin/accounts/_account.html.haml b/app/views/admin/accounts/_account.html.haml index c513776b7..5265d77f6 100644 --- a/app/views/admin/accounts/_account.html.haml +++ b/app/views/admin/accounts/_account.html.haml @@ -4,6 +4,9 @@ %td.domain - unless account.local? = link_to account.domain, admin_accounts_path(by_domain: account.domain) + %td.protocol + - unless account.local? + = account.protocol.humanize %td.confirmed - if account.local? - if account.user_confirmed? diff --git a/app/views/admin/accounts/index.html.haml b/app/views/admin/accounts/index.html.haml index 07c8d1632..1f36aeb31 100644 --- a/app/views/admin/accounts/index.html.haml +++ b/app/views/admin/accounts/index.html.haml @@ -55,6 +55,7 @@ %tr %th= t('admin.accounts.username') %th= t('admin.accounts.domain') + %th= t('admin.accounts.protocol') %th= t('admin.accounts.confirmed') %th= fa_icon 'paper-plane-o' %th diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml index 5ad1fd6ee..dc2f16cc9 100644 --- a/app/views/admin/accounts/show.html.haml +++ b/app/views/admin/accounts/show.html.haml @@ -24,7 +24,8 @@ %th= t('admin.accounts.most_recent_activity') %td - if @account.user_current_sign_in_at - = l @account.user_current_sign_in_at + %time.formatted{ datetime: @account.user_current_sign_in_at.iso8601, title: l(@account.user_current_sign_in_at) } + = l @account.user_current_sign_in_at - else Never - else @@ -32,18 +33,31 @@ %th= t('admin.accounts.profile_url') %td= link_to @account.url, @account.url %tr - %th= t('admin.accounts.feed_url') - %td= link_to @account.remote_url, @account.remote_url - %tr - %th= t('admin.accounts.push_subscription_expires') - %td - - if @account.subscribed? - = l @account.subscription_expires_at - - else - = t('admin.accounts.not_subscribed') - %tr - %th= t('admin.accounts.salmon_url') - %td= link_to @account.salmon_url, @account.salmon_url + %th= t('admin.accounts.protocol') + %td= @account.protocol.humanize + + - if @account.ostatus? + %tr + %th= t('admin.accounts.feed_url') + %td= link_to @account.remote_url, @account.remote_url + %tr + %th= t('admin.accounts.push_subscription_expires') + %td + - if @account.subscribed? + %time.formatted{ datetime: @account.subscription_expires_at.iso8601, title: l(@account.subscription_expires_at) } + = l @account.subscription_expires_at + - else + = t('admin.accounts.not_subscribed') + %tr + %th= t('admin.accounts.salmon_url') + %td= link_to @account.salmon_url, @account.salmon_url + - elsif @account.activitypub? + %tr + %th= t('admin.accounts.inbox_url') + %td= link_to @account.inbox_url, @account.inbox_url + %tr + %th= t('admin.accounts.outbox_url') + %td= link_to @account.outbox_url, @account.outbox_url %tr %th= t('admin.accounts.follows') @@ -74,9 +88,10 @@ - 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' - else - = link_to @account.subscribed? ? t('admin.accounts.resubscribe') : t('admin.accounts.subscribe'), subscribe_admin_account_path(@account.id), method: :post, class: 'button' - - if @account.subscribed? - = link_to t('admin.accounts.unsubscribe'), unsubscribe_admin_account_path(@account.id), method: :post, class: 'button negative' + - if @account.ostatus? + = link_to @account.subscribed? ? t('admin.accounts.resubscribe') : t('admin.accounts.subscribe'), subscribe_admin_account_path(@account.id), method: :post, class: 'button' + - if @account.subscribed? + = link_to t('admin.accounts.unsubscribe'), unsubscribe_admin_account_path(@account.id), method: :post, class: 'button negative' = link_to t('admin.accounts.redownload'), redownload_admin_account_path(@account.id), method: :post, class: 'button' %div{ style: 'float: left' } diff --git a/app/views/auth/registrations/edit.html.haml b/app/views/auth/registrations/edit.html.haml index f016a4883..145f5cd9e 100644 --- a/app/views/auth/registrations/edit.html.haml +++ b/app/views/auth/registrations/edit.html.haml @@ -1,7 +1,7 @@ - content_for :page_title do = t('auth.change_password') -= simple_form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put }) do |f| += simple_form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put, class: 'auth_edit' }) do |f| = render 'shared/error_messages', object: resource = f.input :email, placeholder: t('simple_form.labels.defaults.email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.email') } diff --git a/app/views/home/index.html.haml b/app/views/home/index.html.haml index ec6e53461..a13d0702b 100644 --- a/app/views/home/index.html.haml +++ b/app/views/home/index.html.haml @@ -10,4 +10,4 @@ = image_tag asset_pack_path('logo.svg'), alt: 'Mastodon' %div - = t('errors.noscript') + = t('errors.noscript_html') diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 399d70bc0..6fd39c88e 100755 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -21,13 +21,13 @@ = stylesheet_pack_tag 'common', media: 'all' = javascript_pack_tag 'common', integrity: true, crossorigin: 'anonymous' - = javascript_pack_tag 'features/getting_started', integrity: true, crossorigin: 'anonymous', rel: 'preload', as: 'script' - = javascript_pack_tag 'features/compose', integrity: true, crossorigin: 'anonymous', rel: 'preload', as: 'script' - = javascript_pack_tag 'features/home_timeline', integrity: true, crossorigin: 'anonymous', rel: 'preload', as: 'script' - = javascript_pack_tag 'features/notifications', integrity: true, crossorigin: 'anonymous', rel: 'preload', as: 'script' - = javascript_pack_tag 'features/community_timeline', integrity: true, crossorigin: 'anonymous', rel: 'preload', as: 'script' - = javascript_pack_tag 'features/public_timeline', integrity: true, crossorigin: 'anonymous', rel: 'preload', as: 'script' - = javascript_pack_tag 'emojione_picker', integrity: true, crossorigin: 'anonymous', rel: 'preload', as: 'script' + %link{ href: asset_pack_path('features/getting_started.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/ + %link{ href: asset_pack_path('features/compose.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/ + %link{ href: asset_pack_path('features/home_timeline.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/ + %link{ href: asset_pack_path('features/notifications.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/ + %link{ href: asset_pack_path('features/community_timeline.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/ + %link{ href: asset_pack_path('features/public_timeline.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/ + %link{ href: asset_pack_path('emojione_picker.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/ = javascript_pack_tag "locale_#{I18n.locale}", integrity: true, crossorigin: 'anonymous' = csrf_meta_tags @@ -37,7 +37,8 @@ = yield :header_tags - - body_classes ||= @body_classes + - body_classes ||= @body_classes || '' + - body_classes += ' system-font' if current_account&.user&.setting_system_font_ui %body{ class: add_rtl_body_class(body_classes) } = content_for?(:content) ? yield(:content) : yield diff --git a/app/views/settings/applications/_fields.html.haml b/app/views/settings/applications/_fields.html.haml new file mode 100644 index 000000000..b21f3cca6 --- /dev/null +++ b/app/views/settings/applications/_fields.html.haml @@ -0,0 +1,21 @@ +.fields-group + = f.input :name, placeholder: t('activerecord.attributes.doorkeeper/application.name') + = f.input :website, placeholder: t('activerecord.attributes.doorkeeper/application.website') + +.fields-group + = f.input :redirect_uri, wrapper: :with_block_label, label: t('activerecord.attributes.doorkeeper/application.redirect_uri'), hint: t('doorkeeper.applications.help.redirect_uri') + + %p.hint= t('doorkeeper.applications.help.native_redirect_uri', native_redirect_uri: Doorkeeper.configuration.native_redirect_uri) + +.field-group + = f.input :scopes, + label: t('activerecord.attributes.doorkeeper/application.scopes'), + collection: Doorkeeper.configuration.scopes, + wrapper: :with_label, + include_blank: false, + label_method: lambda { |scope| safe_join([scope, content_tag(:span, t("doorkeeper.scopes.#{scope}"), class: 'hint')]) }, + selected: f.object.scopes.all, + required: false, + as: :check_boxes, + collection_wrapper_tag: 'ul', + item_wrapper_tag: 'li' diff --git a/app/views/settings/applications/index.html.haml b/app/views/settings/applications/index.html.haml new file mode 100644 index 000000000..eea550388 --- /dev/null +++ b/app/views/settings/applications/index.html.haml @@ -0,0 +1,19 @@ +- content_for :page_title do + = t('doorkeeper.applications.index.title') + +%table.table + %thead + %tr + %th= t('doorkeeper.applications.index.application') + %th= t('doorkeeper.applications.index.scopes') + %th + %tbody + - @applications.each do |application| + %tr + %td= link_to application.name, settings_application_path(application) + %th= application.scopes + %td + = table_link_to 'times', t('doorkeeper.applications.index.delete'), settings_application_path(application), method: :delete, data: { confirm: t('doorkeeper.applications.confirmations.destroy') } + += paginate @applications += link_to t('doorkeeper.applications.index.new'), new_settings_application_path, class: 'button' diff --git a/app/views/settings/applications/new.html.haml b/app/views/settings/applications/new.html.haml new file mode 100644 index 000000000..5274a430c --- /dev/null +++ b/app/views/settings/applications/new.html.haml @@ -0,0 +1,8 @@ +- content_for :page_title do + = t('doorkeeper.applications.new.title') + += simple_form_for @application, url: settings_applications_path do |f| + = render 'fields', f: f + + .actions + = f.button :button, t('doorkeeper.applications.buttons.submit'), type: :submit diff --git a/app/views/settings/applications/show.html.haml b/app/views/settings/applications/show.html.haml new file mode 100644 index 000000000..4d8555111 --- /dev/null +++ b/app/views/settings/applications/show.html.haml @@ -0,0 +1,30 @@ +- content_for :page_title do + = t('doorkeeper.applications.show.title', name: @application.name) + +%p.hint= t('applications.warning') + +%table.table + %tbody + %tr + %th= t('doorkeeper.applications.show.application_id') + %td + %code= @application.uid + %tr + %th= t('doorkeeper.applications.show.secret') + %td + %code= @application.secret + %tr + %th{ rowspan: 2}= t('applications.your_token') + %td + %code= current_user.token_for_app(@application).token + %tr + %td= table_link_to 'refresh', t('applications.regenerate_token'), regenerate_settings_application_path(@application), method: :post + +%hr/ + += simple_form_for @application, url: settings_application_path(@application), method: :put do |f| + = render 'fields', f: f + + .actions + = f.button :button, t('generic.save_changes'), type: :submit + diff --git a/app/views/shared/_landing_strip.html.haml b/app/views/shared/_landing_strip.html.haml index 35461a8cb..ae26fc1ff 100644 --- a/app/views/shared/_landing_strip.html.haml +++ b/app/views/shared/_landing_strip.html.haml @@ -1,5 +1,8 @@ .landing-strip - = t('landing_strip_html', name: content_tag(:span, display_name(account), class: :emojify), link_to_root_path: link_to(content_tag(:strong, site_hostname), root_path)) + = image_tag asset_pack_path('logo.svg'), class: 'logo' - - if open_registrations? - = t('landing_strip_signup_html', sign_up_path: new_user_registration_path) + %div + = t('landing_strip_html', name: content_tag(:span, display_name(account), class: :emojify), link_to_root_path: link_to(content_tag(:strong, site_hostname), root_path)) + + - if open_registrations? + = t('landing_strip_signup_html', sign_up_path: new_user_registration_path) diff --git a/app/views/shares/show.html.haml b/app/views/shares/show.html.haml new file mode 100644 index 000000000..44b6f145f --- /dev/null +++ b/app/views/shares/show.html.haml @@ -0,0 +1,5 @@ +- content_for :header_tags do + %script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json) + = javascript_pack_tag 'share', integrity: true, crossorigin: 'anonymous' + +#mastodon-compose{ data: { props: Oj.dump(default_props) } } diff --git a/app/views/stream_entries/_detailed_status.html.haml b/app/views/stream_entries/_detailed_status.html.haml index 157a7e7fb..1d943a2ca 100644 --- a/app/views/stream_entries/_detailed_status.html.haml +++ b/app/views/stream_entries/_detailed_status.html.haml @@ -7,6 +7,11 @@ %strong.p-name.emojify= display_name(status.account) %span= acct(status.account) + - if embedded_view? + = link_to "web+mastodon://follow?uri=#{status.account.local_username_and_domain}", class: 'button button-secondary logo-button', target: '_new' do + = render file: Rails.root.join('app', 'javascript', 'images', 'logo.svg') + = t('accounts.follow') + .status__content.p-name.emojify< - if status.spoiler_text? %p{ style: 'margin-bottom: 0' }< diff --git a/app/views/stream_entries/_status.html.haml b/app/views/stream_entries/_status.html.haml index 50a373743..e2e1fdd12 100644 --- a/app/views/stream_entries/_status.html.haml +++ b/app/views/stream_entries/_status.html.haml @@ -1,4 +1,5 @@ :ruby + pinned ||= false include_threads ||= false is_predecessor ||= false is_successor ||= false @@ -25,6 +26,12 @@ = link_to TagManager.instance.url_for(status.account), class: 'status__display-name muted' do %strong.emojify= display_name(status.account) = t('stream_entries.reblogged') + - elsif pinned + .pre-header + .pre-header__icon + = fa_icon('thumb-tack fw') + %span + = t('stream_entries.pinned') = render (centered ? 'stream_entries/detailed_status' : 'stream_entries/simple_status'), status: status.proper diff --git a/app/views/stream_entries/embed.html.haml b/app/views/stream_entries/embed.html.haml index 5df82528b..b703c15d2 100644 --- a/app/views/stream_entries/embed.html.haml +++ b/app/views/stream_entries/embed.html.haml @@ -1,2 +1,3 @@ -.activity-stream.activity-stream-headless - = render @type, @type.to_sym => @stream_entry.activity, centered: true +- cache @stream_entry.activity do + .activity-stream.activity-stream-headless + = render "stream_entries/#{@type}", @type.to_sym => @stream_entry.activity, centered: true diff --git a/app/views/stream_entries/show.html.haml b/app/views/stream_entries/show.html.haml index 80ea30eb1..5ef72f804 100644 --- a/app/views/stream_entries/show.html.haml +++ b/app/views/stream_entries/show.html.haml @@ -4,6 +4,7 @@ %link{ rel: 'alternate', type: 'application/atom+xml', href: account_stream_entry_url(@account, @stream_entry, format: 'atom') }/ %link{ rel: 'alternate', type: 'application/json+oembed', href: api_oembed_url(url: account_stream_entry_url(@account, @stream_entry), format: 'json') }/ + %link{ rel: 'alternate', type: 'application/activity+json', href: ActivityPub::TagManager.instance.uri_for(@stream_entry.activity) }/ %meta{ property: 'og:site_name', content: site_title }/ %meta{ property: 'og:type', content: 'article' }/ diff --git a/app/workers/activitypub/delivery_worker.rb b/app/workers/activitypub/delivery_worker.rb new file mode 100644 index 000000000..cd67b6710 --- /dev/null +++ b/app/workers/activitypub/delivery_worker.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class ActivityPub::DeliveryWorker + include Sidekiq::Worker + + sidekiq_options queue: 'push', retry: 5, dead: false + + HEADERS = { 'Content-Type' => 'application/activity+json' }.freeze + + def perform(json, source_account_id, inbox_url) + @json = json + @source_account = Account.find(source_account_id) + @inbox_url = inbox_url + + perform_request + + raise Mastodon::UnexpectedResponseError, @response unless response_successful? + rescue => e + raise e.class, "Delivery failed for #{inbox_url}: #{e.message}" + end + + private + + def build_request + request = Request.new(:post, @inbox_url, body: @json) + request.on_behalf_of(@source_account, :uri) + request.add_headers(HEADERS) + end + + def perform_request + @response = build_request.perform + end + + def response_successful? + @response.code > 199 && @response.code < 300 + end +end diff --git a/app/workers/activitypub/distribution_worker.rb b/app/workers/activitypub/distribution_worker.rb new file mode 100644 index 000000000..14bb933c0 --- /dev/null +++ b/app/workers/activitypub/distribution_worker.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +class ActivityPub::DistributionWorker + include Sidekiq::Worker + + sidekiq_options queue: 'push' + + def perform(status_id) + @status = Status.find(status_id) + @account = @status.account + + return if skip_distribution? + + ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url| + [signed_payload, @account.id, inbox_url] + end + rescue ActiveRecord::RecordNotFound + true + end + + private + + def skip_distribution? + @status.direct_visibility? + end + + def inboxes + @inboxes ||= @account.followers.inboxes + end + + def signed_payload + @signed_payload ||= Oj.dump(ActivityPub::LinkedDataSignature.new(payload).sign!(@account)) + end + + def payload + @payload ||= ActiveModelSerializers::SerializableResource.new( + @status, + serializer: ActivityPub::ActivitySerializer, + adapter: ActivityPub::Adapter + ).as_json + end +end diff --git a/app/workers/activitypub/post_upgrade_worker.rb b/app/workers/activitypub/post_upgrade_worker.rb new file mode 100644 index 000000000..4154b8582 --- /dev/null +++ b/app/workers/activitypub/post_upgrade_worker.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class ActivityPub::PostUpgradeWorker + include Sidekiq::Worker + + sidekiq_options queue: 'pull' + + def perform(domain) + Account.where(domain: domain) + .where(protocol: :ostatus) + .where.not(last_webfingered_at: nil) + .in_batches + .update_all(last_webfingered_at: nil) + end +end diff --git a/app/workers/activitypub/processing_worker.rb b/app/workers/activitypub/processing_worker.rb new file mode 100644 index 000000000..bb9adf64b --- /dev/null +++ b/app/workers/activitypub/processing_worker.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class ActivityPub::ProcessingWorker + include Sidekiq::Worker + + sidekiq_options backtrace: true + + def perform(account_id, body) + ActivityPub::ProcessCollectionService.new.call(body, Account.find(account_id)) + end +end diff --git a/app/workers/activitypub/raw_distribution_worker.rb b/app/workers/activitypub/raw_distribution_worker.rb new file mode 100644 index 000000000..d73466f6e --- /dev/null +++ b/app/workers/activitypub/raw_distribution_worker.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class ActivityPub::RawDistributionWorker + include Sidekiq::Worker + + sidekiq_options queue: 'push' + + def perform(json, source_account_id) + @account = Account.find(source_account_id) + + ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url| + [json, @account.id, inbox_url] + end + rescue ActiveRecord::RecordNotFound + true + end + + private + + def inboxes + @inboxes ||= @account.followers.inboxes + end +end diff --git a/app/workers/activitypub/reply_distribution_worker.rb b/app/workers/activitypub/reply_distribution_worker.rb new file mode 100644 index 000000000..f9127340f --- /dev/null +++ b/app/workers/activitypub/reply_distribution_worker.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +class ActivityPub::ReplyDistributionWorker + include Sidekiq::Worker + + sidekiq_options queue: 'push' + + def perform(status_id) + @status = Status.find(status_id) + @account = @status.thread.account + + return if skip_distribution? + + ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url| + [signed_payload, @status.account_id, inbox_url] + end + rescue ActiveRecord::RecordNotFound + true + end + + private + + def skip_distribution? + @status.private_visibility? || @status.direct_visibility? + end + + def inboxes + @inboxes ||= @account.followers.inboxes + end + + def signed_payload + @signed_payload ||= Oj.dump(ActivityPub::LinkedDataSignature.new(payload).sign!(@status.account)) + end + + def payload + @payload ||= ActiveModelSerializers::SerializableResource.new( + @status, + serializer: ActivityPub::ActivitySerializer, + adapter: ActivityPub::Adapter + ).as_json + end +end diff --git a/app/workers/activitypub/update_distribution_worker.rb b/app/workers/activitypub/update_distribution_worker.rb new file mode 100644 index 000000000..f3377dcec --- /dev/null +++ b/app/workers/activitypub/update_distribution_worker.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class ActivityPub::UpdateDistributionWorker + include Sidekiq::Worker + + sidekiq_options queue: 'push' + + def perform(account_id) + @account = Account.find(account_id) + + ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url| + [payload, @account.id, inbox_url] + end + rescue ActiveRecord::RecordNotFound + true + end + + private + + def inboxes + @inboxes ||= @account.followers.inboxes + end + + def payload + @payload ||= ActiveModelSerializers::SerializableResource.new( + @account, + serializer: ActivityPub::UpdateSerializer, + adapter: ActivityPub::Adapter + ).to_json + end +end diff --git a/app/workers/authorize_follow_worker.rb b/app/workers/authorize_follow_worker.rb new file mode 100644 index 000000000..0d5014624 --- /dev/null +++ b/app/workers/authorize_follow_worker.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class AuthorizeFollowWorker + include Sidekiq::Worker + + def perform(source_account_id, target_account_id) + source_account = Account.find(source_account_id) + target_account = Account.find(target_account_id) + + AuthorizeFollowService.new.call(source_account, target_account) + rescue ActiveRecord::RecordNotFound + true + end +end diff --git a/app/workers/pubsubhubbub/distribution_worker.rb b/app/workers/pubsubhubbub/distribution_worker.rb index ea246128d..524f6849f 100644 --- a/app/workers/pubsubhubbub/distribution_worker.rb +++ b/app/workers/pubsubhubbub/distribution_worker.rb @@ -6,7 +6,7 @@ class Pubsubhubbub::DistributionWorker sidekiq_options queue: 'push' def perform(stream_entry_ids) - stream_entries = StreamEntry.where(id: stream_entry_ids).includes(:status).reject { |e| e.status&.direct_visibility? } + stream_entries = StreamEntry.where(id: stream_entry_ids).includes(:status).reject { |e| e.status.nil? || e.status.direct_visibility? } return if stream_entries.empty? @@ -14,7 +14,7 @@ class Pubsubhubbub::DistributionWorker @subscriptions = active_subscriptions.to_a distribute_public!(stream_entries.reject(&:hidden?)) - distribute_hidden!(stream_entries.select(&:hidden?)) + distribute_hidden!(stream_entries.select(&:hidden?)) if Rails.configuration.x.use_ostatus_privacy end private diff --git a/app/workers/pubsubhubbub/raw_distribution_worker.rb b/app/workers/pubsubhubbub/raw_distribution_worker.rb new file mode 100644 index 000000000..16962a623 --- /dev/null +++ b/app/workers/pubsubhubbub/raw_distribution_worker.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class Pubsubhubbub::RawDistributionWorker + include Sidekiq::Worker + + sidekiq_options queue: 'push' + + def perform(xml, source_account_id) + @account = Account.find(source_account_id) + @subscriptions = active_subscriptions.to_a + + Pubsubhubbub::DeliveryWorker.push_bulk(@subscriptions) do |subscription| + [subscription.id, xml] + end + end + + private + + def active_subscriptions + Subscription.where(account: @account).active.select('id, callback_url, domain') + end +end diff --git a/app/workers/pubsubhubbub/subscribe_worker.rb b/app/workers/pubsubhubbub/subscribe_worker.rb index 7560c2671..130c967e0 100644 --- a/app/workers/pubsubhubbub/subscribe_worker.rb +++ b/app/workers/pubsubhubbub/subscribe_worker.rb @@ -3,7 +3,7 @@ class Pubsubhubbub::SubscribeWorker include Sidekiq::Worker - sidekiq_options queue: 'push', retry: 10, unique: :until_executed, dead: false + sidekiq_options queue: 'push', retry: 10, unique: :until_executed, dead: false, unique_retry: true sidekiq_retry_in do |count| case count diff --git a/app/workers/pubsubhubbub/unsubscribe_worker.rb b/app/workers/pubsubhubbub/unsubscribe_worker.rb new file mode 100644 index 000000000..a271715b7 --- /dev/null +++ b/app/workers/pubsubhubbub/unsubscribe_worker.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class Pubsubhubbub::UnsubscribeWorker + include Sidekiq::Worker + + sidekiq_options queue: 'push', retry: false, unique: :until_executed, dead: false + + def perform(account_id) + account = Account.find(account_id) + logger.debug "PuSH unsubscribing from #{account.acct}" + ::UnsubscribeService.new.call(account) + rescue ActiveRecord::RecordNotFound + true + end +end diff --git a/app/workers/resolve_remote_account_worker.rb b/app/workers/resolve_remote_account_worker.rb new file mode 100644 index 000000000..5dd84ccb6 --- /dev/null +++ b/app/workers/resolve_remote_account_worker.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class ResolveRemoteAccountWorker + include Sidekiq::Worker + + sidekiq_options queue: 'pull', unique: :until_executed + + def perform(uri) + ResolveRemoteAccountService.new.call(uri) + end +end diff --git a/app/workers/scheduler/feed_cleanup_scheduler.rb b/app/workers/scheduler/feed_cleanup_scheduler.rb index 402eed7c6..dbebaa2c3 100644 --- a/app/workers/scheduler/feed_cleanup_scheduler.rb +++ b/app/workers/scheduler/feed_cleanup_scheduler.rb @@ -5,8 +5,6 @@ class Scheduler::FeedCleanupScheduler include Sidekiq::Worker def perform - logger.info 'Cleaning out home feeds of inactive users' - redis.pipelined do inactive_users.pluck(:account_id).each do |account_id| redis.del(FeedManager.instance.key(:home, account_id)) diff --git a/app/workers/scheduler/media_cleanup_scheduler.rb b/app/workers/scheduler/media_cleanup_scheduler.rb index a95f512be..ce32ce314 100644 --- a/app/workers/scheduler/media_cleanup_scheduler.rb +++ b/app/workers/scheduler/media_cleanup_scheduler.rb @@ -5,7 +5,6 @@ class Scheduler::MediaCleanupScheduler include Sidekiq::Worker def perform - logger.info 'Cleaning out unattached media attachments' unattached_media.find_each(&:destroy) end diff --git a/app/workers/scheduler/subscriptions_cleanup_scheduler.rb b/app/workers/scheduler/subscriptions_cleanup_scheduler.rb new file mode 100644 index 000000000..3b9211e81 --- /dev/null +++ b/app/workers/scheduler/subscriptions_cleanup_scheduler.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'sidekiq-scheduler' + +class Scheduler::SubscriptionsCleanupScheduler + include Sidekiq::Worker + + def perform + Subscription.expired.in_batches.delete_all + end +end diff --git a/app/workers/scheduler/subscriptions_scheduler.rb b/app/workers/scheduler/subscriptions_scheduler.rb index 7bfd002f4..469a3d2a6 100644 --- a/app/workers/scheduler/subscriptions_scheduler.rb +++ b/app/workers/scheduler/subscriptions_scheduler.rb @@ -7,8 +7,6 @@ class Scheduler::SubscriptionsScheduler include Sidekiq::Worker def perform - logger.info 'Queueing PuSH re-subscriptions' - Pubsubhubbub::SubscribeWorker.push_bulk(expiring_accounts.pluck(:id)) end diff --git a/config/application.rb b/config/application.rb index eb89f0a10..49382c2b9 100644 --- a/config/application.rb +++ b/config/application.rb @@ -10,6 +10,7 @@ require_relative '../app/lib/exceptions' require_relative '../lib/paperclip/gif_transcoder' require_relative '../lib/paperclip/video_transcoder' require_relative '../lib/mastodon/version' +require_relative '../lib/mastodon/unique_retry_job_middleware' Dotenv::Railtie.load diff --git a/config/brakeman.ignore b/config/brakeman.ignore index f9bc77069..dbb59dd07 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -3,14 +3,33 @@ { "warning_type": "Dynamic Render Path", "warning_code": 15, + "fingerprint": "44d3f14e05d8fbb5b23e13ac02f15aa38b2a2f0f03b9ba76bab7f98e155a4a4e", + "check_name": "Render", + "message": "Render path contains parameter value", + "file": "app/views/stream_entries/embed.html.haml", + "line": 3, + "link": "http://brakemanscanner.org/docs/warning_types/dynamic_render_path/", + "code": "render(action => \"stream_entries/#{Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity_type.downcase}\", { Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity_type.downcase.to_sym => Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity, :centered => true })", + "render_path": [{"type":"controller","class":"StatusesController","method":"embed","line":35,"file":"app/controllers/statuses_controller.rb"}], + "location": { + "type": "template", + "template": "stream_entries/embed" + }, + "user_input": "params[:id]", + "confidence": "Weak", + "note": "" + }, + { + "warning_type": "Dynamic Render Path", + "warning_code": 15, "fingerprint": "9f31d941f3910dba2e9bfcd81aef4513249bd24c02d0f98e13ad44fdeeccd0e8", "check_name": "Render", "message": "Render path contains parameter value", "file": "app/views/admin/accounts/index.html.haml", - "line": 32, + "line": 63, "link": "http://brakemanscanner.org/docs/warning_types/dynamic_render_path/", "code": "render(action => filtered_accounts.page(params[:page]), {})", - "render_path": [{"type":"controller","class":"Admin::AccountsController","method":"index","line":7,"file":"app/controllers/admin/accounts_controller.rb"}], + "render_path": [{"type":"controller","class":"Admin::AccountsController","method":"index","line":10,"file":"app/controllers/admin/accounts_controller.rb"}], "location": { "type": "template", "template": "admin/accounts/index" @@ -42,25 +61,6 @@ { "warning_type": "Dynamic Render Path", "warning_code": 15, - "fingerprint": "c417f9d44ab05dd9cf3d5ec9df2324a5036774c151181787b32c4c940623191b", - "check_name": "Render", - "message": "Render path contains parameter value", - "file": "app/views/stream_entries/embed.html.haml", - "line": 2, - "link": "http://brakemanscanner.org/docs/warning_types/dynamic_render_path/", - "code": "render(action => Account.find_local!(params[:account_username]).stream_entries.where(:activity_type => \"Status\").find(params[:id]).activity_type.downcase, { Account.find_local!(params[:account_username]).stream_entries.where(:activity_type => \"Status\").find(params[:id]).activity_type.downcase.to_sym => Account.find_local!(params[:account_username]).stream_entries.where(:activity_type => \"Status\").find(params[:id]).activity, :centered => true })", - "render_path": [{"type":"controller","class":"StreamEntriesController","method":"embed","line":32,"file":"app/controllers/stream_entries_controller.rb"}], - "location": { - "type": "template", - "template": "stream_entries/embed" - }, - "user_input": "params[:id]", - "confidence": "Weak", - "note": "" - }, - { - "warning_type": "Dynamic Render Path", - "warning_code": 15, "fingerprint": "c5d6945d63264af106d49367228d206aa2f176699ecdce2b98fac101bc6a96cf", "check_name": "Render", "message": "Render path contains parameter value", @@ -84,10 +84,10 @@ "check_name": "Render", "message": "Render path contains parameter value", "file": "app/views/stream_entries/show.html.haml", - "line": 19, + "line": 23, "link": "http://brakemanscanner.org/docs/warning_types/dynamic_render_path/", "code": "render(partial => \"stream_entries/#{Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity_type.downcase}\", { :locals => ({ Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity_type.downcase.to_sym => Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity, :include_threads => true }) })", - "render_path": [{"type":"controller","class":"StatusesController","method":"show","line":15,"file":"app/controllers/statuses_controller.rb"}], + "render_path": [{"type":"controller","class":"StatusesController","method":"show","line":20,"file":"app/controllers/statuses_controller.rb"}], "location": { "type": "template", "template": "stream_entries/show" @@ -97,6 +97,6 @@ "note": "" } ], - "updated": "2017-05-07 08:26:06 +0900", - "brakeman_version": "3.6.1" + "updated": "2017-08-30 05:14:04 +0200", + "brakeman_version": "3.7.2" } diff --git a/config/environment.rb b/config/environment.rb index 426333bb4..caae5f1a0 100644 --- a/config/environment.rb +++ b/config/environment.rb @@ -3,3 +3,5 @@ require_relative 'application' # Initialize the Rails application. Rails.application.initialize! + +ActiveRecord::SchemaDumper.ignore_tables = ['deprecated_preview_cards'] diff --git a/config/environments/development.rb b/config/environments/development.rb index 4c60965c8..59bc2c3e2 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -16,9 +16,10 @@ Rails.application.configure do if Rails.root.join('tmp/caching-dev.txt').exist? config.action_controller.perform_caching = true - config.cache_store = :memory_store + config.cache_store = :redis_store, ENV['REDIS_URL'], REDIS_CACHE_PARAMS + config.public_file_server.headers = { - 'Cache-Control' => "public, max-age=#{2.days.seconds.to_i}" + 'Cache-Control' => "public, max-age=#{2.days.seconds.to_i}", } else config.action_controller.perform_caching = false diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index 849e8116a..b51cf46df 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -36,7 +36,7 @@ ignore_missing: - 'activerecord.attributes.*' - 'activerecord.errors.*' - '{devise,pagination,doorkeeper}.*' - - '{date,datetime,time}.*' + - '{date,datetime,time,number}.*' - 'simple_form.{yes,no}' - 'simple_form.{placeholders,hints,labels}.*' - 'simple_form.{error_notification,required}.:' @@ -50,7 +50,7 @@ ignore_unused: - 'activerecord.attributes.*' - 'activerecord.errors.*' - '{devise,pagination,doorkeeper}.*' - - '{date,datetime,time}.*' + - '{date,datetime,time,number}.*' - 'simple_form.{yes,no}' - 'simple_form.{placeholders,hints,labels}.*' - 'simple_form.{error_notification,required}.:' diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index e6b0e90cb..64c4e12ff 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -154,7 +154,7 @@ Devise.setup do |config| # their account can't be confirmed with the token any more. # Default is nil, meaning there is no restriction on how long a user can take # before confirming their account. - # config.confirm_within = 3.days + config.confirm_within = 2.days # If true, requires any email changes to be confirmed (exactly the same way as # initial account confirmation) to be applied. Requires additional unconfirmed_email @@ -167,7 +167,7 @@ Devise.setup do |config| # ==> Configuration for :rememberable # The time the user will be remembered without asking for credentials again. - # config.remember_for = 2.weeks + config.remember_for = 1.year # Invalidates all the remember me tokens when the user signs out. config.expire_all_remember_me_on_sign_out = true @@ -177,7 +177,7 @@ Devise.setup do |config| # Options to be passed to the created cookie. For instance, you can set # secure: true in order to force SSL only cookies. - # config.rememberable_options = {} + config.rememberable_options = { secure: true } # ==> Configuration for :validatable # Range for password length. diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb index 056a3651a..689e2ac4a 100644 --- a/config/initializers/doorkeeper.rb +++ b/config/initializers/doorkeeper.rb @@ -50,7 +50,7 @@ Doorkeeper.configure do # Optional parameter :confirmation => true (default false) if you want to enforce ownership of # a registered application # Note: you must also run the rails g doorkeeper:application_owner generator to provide the necessary support - # enable_application_owner :confirmation => true + enable_application_owner # Define access token scopes for your provider # For more information go to diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index 44e54c9f3..bf0cb52a3 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -17,4 +17,5 @@ ActiveSupport::Inflector.inflections(:en) do |inflect| inflect.acronym 'ActivityPub' inflect.acronym 'PubSubHubbub' inflect.acronym 'ActivityStreams' + inflect.acronym 'JsonLd' end diff --git a/config/initializers/json_ld.rb b/config/initializers/json_ld.rb new file mode 100644 index 000000000..2ddc7352d --- /dev/null +++ b/config/initializers/json_ld.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +require_relative '../../lib/json_ld/identity' +require_relative '../../lib/json_ld/security' +require_relative '../../lib/json_ld/activitystreams' diff --git a/config/initializers/mime_types.rb b/config/initializers/mime_types.rb index 30e91ad63..58a6c0063 100644 --- a/config/initializers/mime_types.rb +++ b/config/initializers/mime_types.rb @@ -1,4 +1,4 @@ # Be sure to restart your server when you modify this file. -Mime::Type.register 'application/json', :json, %w(text/x-json application/jsonrequest application/jrd+json application/activity+json) +Mime::Type.register 'application/json', :json, %w(text/x-json application/jsonrequest application/jrd+json application/activity+json application/ld+json) Mime::Type.register 'text/xml', :xml, %w(application/xml application/atom+xml application/xrd+xml) diff --git a/config/initializers/ostatus.rb b/config/initializers/ostatus.rb index 342996dcd..c00aba0de 100644 --- a/config/initializers/ostatus.rb +++ b/config/initializers/ostatus.rb @@ -5,18 +5,20 @@ host = ENV.fetch('LOCAL_DOMAIN') { "localhost:#{port}" } web_host = ENV.fetch('WEB_DOMAIN') { host } https = ENV['LOCAL_HTTPS'] == 'true' -alternate_domains = ENV.fetch('ALTERNATE_DOMAINS') { "" } +alternate_domains = ENV.fetch('ALTERNATE_DOMAINS') { '' } Rails.application.configure do config.x.local_domain = host config.x.web_domain = web_host config.x.use_https = https config.x.use_s3 = ENV['S3_ENABLED'] == 'true' + config.x.use_swift = ENV['SWIFT_ENABLED'] == 'true' config.x.alternate_domains = alternate_domains.split(/\s*,\s*/) config.action_mailer.default_url_options = { host: web_host, protocol: https ? 'https://' : 'http://', trailing_slash: false } config.x.streaming_api_base_url = 'ws://localhost:4000' + config.x.use_ostatus_privacy = true if Rails.env.production? config.x.streaming_api_base_url = ENV.fetch('STREAMING_API_BASE_URL') { "ws#{https ? 's' : ''}://#{web_host}" } diff --git a/config/initializers/paperclip.rb b/config/initializers/paperclip.rb index 740c1a953..e9f455251 100644 --- a/config/initializers/paperclip.rb +++ b/config/initializers/paperclip.rb @@ -40,6 +40,21 @@ if ENV['S3_ENABLED'] == 'true' Paperclip::Attachment.default_options[:url] = ':s3_alias_url' Paperclip::Attachment.default_options[:s3_host_alias] = ENV['S3_CLOUDFRONT_HOST'] end +elsif ENV['SWIFT_ENABLED'] == 'true' + Paperclip::Attachment.default_options.merge!( + path: ':class/:attachment/:id_partition/:style/:filename', + storage: :fog, + fog_credentials: { + provider: 'OpenStack', + openstack_username: ENV.fetch('SWIFT_USERNAME'), + openstack_tenant: ENV.fetch('SWIFT_TENANT'), + openstack_api_key: ENV.fetch('SWIFT_PASSWORD'), + openstack_auth_url: ENV.fetch('SWIFT_AUTH_URL'), + }, + fog_directory: ENV.fetch('SWIFT_CONTAINER'), + fog_host: ENV.fetch('SWIFT_OBJECT_URL'), + fog_public: true + ) else Paperclip::Attachment.default_options[:path] = (ENV['PAPERCLIP_ROOT_PATH'] || ':rails_root/public/system') + '/:class/:attachment/:id_partition/:style/:filename' Paperclip::Attachment.default_options[:url] = (ENV['PAPERCLIP_ROOT_URL'] || '/system') + '/:class/:attachment/:id_partition/:style/:filename' diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb index d5cd77b34..53cb106ca 100644 --- a/config/initializers/rack_attack.rb +++ b/config/initializers/rack_attack.rb @@ -33,6 +33,7 @@ class Rack::Attack match_data = env['rack.attack.match_data'] headers = { + 'Content-Type' => 'application/json', 'X-RateLimit-Limit' => match_data[:limit].to_s, 'X-RateLimit-Remaining' => '0', 'X-RateLimit-Reset' => (now + (match_data[:period] - now.to_i % match_data[:period])).iso8601(6), diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index b70784d79..61e131336 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -13,4 +13,7 @@ end Sidekiq.configure_client do |config| config.redis = redis_params + config.client_middleware do |chain| + chain.add Mastodon::UniqueRetryJobMiddleware + end end diff --git a/config/locales/ar.yml b/config/locales/ar.yml index 575c5114c..604b09600 100644 --- a/config/locales/ar.yml +++ b/config/locales/ar.yml @@ -47,16 +47,16 @@ ar: datetime: distance_in_words: about_x_hours: "%{count}سا" - about_x_months: "%{count}شهر" - about_x_years: "%{count}سنة" - almost_x_years: "%{count}سنوات" - half_a_minute: Just now - less_than_x_minutes: "%{count}د" + about_x_months: "%{count} شهر" + about_x_years: "%{count} سنة" + almost_x_years: "%{count} سنوات" + half_a_minute: الآن + less_than_x_minutes: "%{count} د" less_than_x_seconds: الآن - over_x_years: "%{count}سنين" - x_days: "%{count}أيام" + over_x_years: "%{count} سنين" + x_days: "%{count} أيام" x_minutes: "%{count}د" - x_months: "%{count}شه" + x_months: "%{count} شه" x_seconds: "%{count}ث" exports: blocks: قمت بحظر @@ -94,7 +94,7 @@ ar: one: "إشعار واحد منذ زيارتك الأخيرة \U0001F418" other: "%{count} إشعارات جديدة منذ زيارتك الأخيرة \U0001F418" favourite: - body: 'Your status was favourited by %{name}:' + body: 'أُعجب %{name} بمنشورك' subject: "%{name} favourited your status" follow: body: "%{name} من متتبعيك الآن !" @@ -108,6 +108,17 @@ ar: reblog: body: 'Your status was boosted by %{name}:' subject: "%{name} boosted your status" + number: + human: + decimal_units: + format: "%n%u" + units: + billion: B + million: M + quadrillion: Q + thousand: K + trillion: T + unit: '' pagination: next: التالي prev: السابق @@ -148,7 +159,7 @@ ar: enabled_success: تم تفعيل إثبات الهوية المزدوج بنجاح instructions_html: "<strong>Scan this QR code into Google Authenticator or a similiar TOTP app on your phone</strong>. From now on, that app will generate tokens that you will have to enter when logging in." manual_instructions: 'If you can''t scan the QR code and need to enter it manually, here is the plain-text secret:' - setup: Set up + setup: تنشيط wrong_code: الرمز الذي أدخلته غير صالح. تحقق من صحة الوقت على الخادم و الجهاز. users: invalid_email: عنوان البريد الإلكتروني غير صالح diff --git a/config/locales/bg.yml b/config/locales/bg.yml index e7c3e1ef6..13d0394a3 100644 --- a/config/locales/bg.yml +++ b/config/locales/bg.yml @@ -108,6 +108,17 @@ bg: reblog: body: 'Твоята публикация беше споделена от %{name}:' subject: "%{name} сподели публикацията ти" + number: + human: + decimal_units: + format: "%n%u" + units: + billion: B + million: M + quadrillion: Q + thousand: K + trillion: T + unit: '' pagination: next: Напред prev: Назад diff --git a/config/locales/ca.yml b/config/locales/ca.yml index a9f9e4c93..6a92b7f1b 100644 --- a/config/locales/ca.yml +++ b/config/locales/ca.yml @@ -5,15 +5,34 @@ ca: about_this: Sobre aquesta instància closed_registrations: Els registres estan actualment tancats en aquesta instància. contact: Contacte + contact_missing: No configurat + contact_unavailable: N/A description_headline: Què es %{domain}? domain_count_after: altres instàncies domain_count_before: Connectat a + extended_description_html: | + <h3>Un bon lloc per les regles</h3> + <p>Encara no s'ha configurat la descripció ampliada.</p> + features: + humane_approach_body: Aprenent dels errors d'altres xarxes, Mastodon té com a objectiu fer ètiques eleccions de disseny per combatre el mal ús de les xarxes socials. + humane_approach_title: Un enfocament més humà + not_a_product_body: Mastodon no és una xarxa comercial. Sense publicitat, sense mineria de dades, sense jardins amurallats. No hi ha autoritat central. + not_a_product_title: Ets una persona, no un producte + real_conversation_body: Amb 500 caràcters a la teva disposició i suport per a continguts granulars i avisos multimèdia, pots expressar-te de la manera que vulguis. + real_conversation_title: Construït per a converses reals + within_reach_body: Diverses aplicacions per a iOS, Android i altres plataformes gràcies a un ecosistema API amable amb el desenvolupador, et permet mantenir-te al dia amb els teus amics en qualsevol lloc.. + within_reach_title: Sempre a l'abast + find_another_instance: Troba altres instàncies + generic_description: "%{domain} és un servidor a la xarxa" + hosted_on: Mastodon allotjat a %{domain} + learn_more: Aprèn més other_instances: Altres instàncies source_code: Codi font status_count_after: estats status_count_before: Que han escrit user_count_after: usuaris registrats user_count_before: Tenim + what_is_mastodon: Què és Mastodon? accounts: follow: Seguir followers: Seguidors @@ -90,12 +109,14 @@ ca: hint: El bloqueig de domini no impedirà la creació de nous comptes en la base de dades, però s´aplicaran mètodes de moderació específics sobre aquests comptes severity: desc_html: "<strong>Silenci</strong> farà les publicacions del compte invisibles a tothom que no l'estigui seguint. Suspendre eliminarà tots els continguts, multimèdia i les dades del perfil del compte." + noop: Cap silence: Silenci suspend: Suspendre title: Nou bloqueig de domini reject_media: Rebutjar arxius multimèdia reject_media_hint: Elimina arxius multimèdia emmagatzamats localment i impideix descarregar cap en el futur. Irrellevant per suspensions severities: + noop: Cap silence: Silenci suspend: Suspendre severity: Severitat @@ -146,16 +167,41 @@ ca: closed_message: desc_html: Apareix en la primera pàgina quan es tanquen els registres<br>Pot utilitzar etiquetes HTML title: Missatge de registre tancat + deletion: + desc_html: Permet a qualsevol esborrar el seu compte + title: Obrir la supressió del compte open: + desc_html: Permet que qualsevol pugui crear un compte title: Registre obert site_description: desc_html: Es mostra com un paràgraf a la pàgina principal i s'utilitza com una etiqueta meta.<br>Pots utilitzar etiquetes HTML, en particular <code><a></code> i <code><em></code>. title: Descripció del lloc site_description_extended: - desc_html: Apareix a la pàgina d'informació estesa<br>Pot utilitzar etiquetes HTML + desc_html: Un bon lloc per al vostre codi de conducta, regles, directrius i altres coses que distingeixen la vostra instància. Podeu utilitzar etiquetes HTML title: Descripció estesa del lloc + site_terms: + desc_html: Pots escriure la teva pròpia política de privadesa, els termes del servei o d'altres normes legals. Pots utilitzar etiquetes HTML + title: Termes del servei personalitzats site_title: Títol del lloc + timeline_preview: + desc_html: Mostra la línia de temps pública a la pàgina inicial + title: Vista prèvia de la línia de temps title: Configuració del lloc + statuses: + back_to_account: Torna a la pàgina del compte + batch: + delete: Esborra + nsfw_off: NSFW OFF + nsfw_on: NSFW ON + execute: Executa + failed_to_execute: No s'ha pogut executar + media: + hide: Amaga multimèdia + show: Mostra multimèdia + title: Multimèdia + no_media: Sense multimèdia + title: Estats del compte + with_media: Amb multimèdia subscriptions: callback_url: Callback URL confirmed: Confirmat @@ -164,18 +210,25 @@ ca: title: WebSub topic: Tòpic title: Administració + admin_mailer: + new_report: + body: "%{reporter} ha informat de %{target}" + subject: Nou informe per a %{instance} (#%{id}) application_mailer: + salutation: "%{name}," settings: 'Canviar preferències de correu: %{link}' signature: Notificacions de Mastodon desde %{instance} view: 'Vista:' applications: invalid_url: La URL proporcionada es incorrecte auth: + agreement_html: En inscriure't, acceptes <a href="%{rules_path}">les nostres termes del servei</a> i <a href="%{terms_path}">la nostra política de privadesa</a>. change_password: Canviar contrasenya delete_account: Esborrar el compte delete_account_html: Si vols esborrar el teu compte pots <a href="%{path}">fer-ho aquí</a>. S'et demanarà confirmació. didnt_get_confirmation: No vas rebre el correu de confirmació? forgot_password: Has oblidat la contrasenya? + invalid_reset_password_token: L'enllaç de restabliment de la contrasenya no és vàlid o caducat. Siusplau torna-ho a provar.. login: Iniciar sessió logout: Tancar sessió register: Enregistrarse @@ -185,6 +238,12 @@ ca: authorize_follow: error: Malauradament, ha ocorregut un error buscant el compte remot follow: Seguir + follow_request: 'Heu enviat una sol·licitud de seguiment a:' + following: 'Èxit! Ara segueixes:' + post_follow: + close: O bé, pots tancar aquesta finestra. + return: Torna al perfil de l'usuari + web: Anar a la web title: Seguir %{acct} datetime: distance_in_words: @@ -216,7 +275,7 @@ ca: content: La verificació de seguretat ha fallat. Bloquejes les galetes? title: La verificació de seguretat ha fallat '429': Estrangulat - noscript: Per utilitzar Mastodon si us plau activa JavaScript. + noscript_html: Per utilitzar Mastodon si us plau activa JavaScript. exports: blocks: Persones que has bloquejat csv: CSV @@ -254,7 +313,7 @@ ca: landing_strip_signup_html: Si no en tens, pots <a href="%{sign_up_path}">registrar-te aquí</a>. media_attachments: validations: - images_and_video: No es pot adjuntar un vídeo a un estat que ja contingui imatges + images_and_video: No es pot adjuntar un vídeo a una publicació que ja contingui imatges too_many: No es poden adjuntar més de 4 arxius notification_mailer: digest: @@ -281,15 +340,79 @@ ca: reblog: body: "%{name} ha retootejat el teu estat" subject: "%{name} ha retootejat el teu estat" + number: + human: + decimal_units: + format: "%n%u" + units: + billion: B + million: M + quadrillion: Q + thousand: K + trillion: T + unit: '' pagination: next: Pròxim prev: Anterior truncate: "…" + push_notifications: + favourite: + title: "%{name} favourited your status" + follow: + title: "%{name} is now following you" + group: + title: "%{count} notifications" + mention: + action_boost: Boost + action_expand: Mostra més + action_favourite: Favorit + title: "%{name} t'ha mencionat" + reblog: + title: "%{name} t'ha retootejat" remote_follow: acct: Escriu el usuari@domini de la persona que vols seguir missing_resource: No s'ha pogut trobar la URL de redirecció necessaria per el compte. proceed: Procedir a seguir prompt: 'Seguiràs a:' + sessions: + activity: Última activitat + browser: Navegador + browsers: + alipay: Alipay + blackberry: Blackberry + chrome: Chrome + edge: Microsoft Edge + firefox: Firefox + generic: Navegador desconegut + ie: Internet Explorer + micro_messenger: MicroMessenger + nokia: Nokia S40 Ovi Browser + opera: Opera + phantom_js: PhantomJS + qq: QQ Browser + safari: Safari + uc_browser: UCBrowser + weibo: Weibo + current_session: Sessió actual + description: "%{browser} de %{platform}" + explanation: Aquests són els navegadors web que actualment han iniciat la sessió al teu compte de Mastodon. + ip: IP + platforms: + adobe_air: Adobe Air + android: Android + blackberry: Blackberry + chrome_os: ChromeOS + firefox_os: Firefox OS + ios: iOS + linux: Linux + mac: Mac + other: plataforma desconeguda + windows: Windows + windows_mobile: Windows Mobile + windows_phone: Windows Phone + revoke: Revoca + revoke_success: S'ha revocat la sessió amb èxit + title: Sessions settings: authorized_apps: Aplicacions autoritzades back: Tornar al inici @@ -316,19 +439,24 @@ ca: click_to_show: Clic per mostrar reblogged: retooteado sensitive_content: Contingut sensible + terms: + body_html: "<h2>Política de privacitat</h2>\n\n<h3 id=\"collect\">Quina informació recollim?</h3>\n\n<p>Recopilem informació teva quan et registres en aquesta instància i recopilem dades quan participes en el fòrum llegint, escrivint i avaluant el contingut aquí compartit.</p>\n\n<p>En registrar-te en aquesta instància, se't pot demanar que introduexisu el teu nom i l'adreça de correu electrònic. També pots visitar el nostre lloc sense registrar-te. La teva adreça de correu electrònic es verificarà mitjançant un correu electrònic que conté un enllaç únic. Si es visita aquest enllaç, sabem que controles l'adreça de correu electrònic.</p>\n\n<p>Quan es registra i publica, registrem l'adreça IP de la qual es va originar la publicació. També podrem conservar els registres del servidor que inclouen l'adreça IP de cada sol·licitud al nostre servidor.</p>\n\n<h3 id=\"use\">Per a què utilitzem la teva informació?</h3>\n\n<p>Qualsevol de la informació que recopilem de tu pot utilitzar-se d'una de les maneres següents:</p>\n\n<ul>\n <li>Per a personalitzar la teva experiència — la teva informació ens ajuda a respondre millor a les teves necessitats individuals.</li>\n <li>Per millorar el nostre lloc — ens esforcem contínuament per millorar les nostres ofertes de llocs basats en la informació i els comentaris que rebem de tu.</li>\n <li>Per millorar el servei al client — la teva informació ens ajuda a respondre més eficaçment a les teves sol·licituds de servei al client i a les necessitats de suport.</li>\n <li>Per enviar correus electrònics periòdics — l'adreça electrònica que proporcionis es pot utilitzar per enviar-te informació, notificacions que sol·licitis sobre canvis en temes o en resposta al teu nom d'usuari, respondre a les consultes i/o altres sol·licituds o preguntes.</li>\n</ul>\n\n<h3 id=\"protect\">Com protegim la teva informació?</h3>\n\n<p>Implementem diverses mesures de seguretat per mantenir la seguretat de la teva informació personal quan introdueixes, envies o accedeixes a la teva informació personal.</p>\n\n<h3 id=\"data-retention\">Quina és la nostre política de retenció de dades?</h3>\n\n<p>Farem un esforç de bona fe per a:</p>\n\n<ul>\n <li>Conserva els registres de servidor que continguin l'adreça IP de totes les sol·licituds a aquest servidor no més de 90 dies.</li>\n <li>Conserva les adreces IP associades als usuaris registrats i les seves publicacions no més de 5 anys.</li>\n</ul>\n\n<h3 id=\"cookies\">Utilitzem galetes?</h3>\n\n<p>Sí. Les cookies són fitxers petits que un lloc o el proveïdor de serveis transfereix al disc dur del vostre ordinador a través del navegador web (si ho permet). Aquestes galetes permeten al lloc reconèixer el vostre navegador i, si teniu un compte registrat, associar-lo al vostre compte registrat.</p>\n\n<p>Utilitzem cookies per comprendre i desar les vostres preferències per a futures visites i compilar dades agregades sobre el trànsit del lloc i la interacció del lloc, de manera que podrem oferir millors experiències i eines del lloc en el futur. Podem contractar amb proveïdors de serveis de tercers per ajudar-nos a comprendre millor els visitants del nostre lloc. Aquests proveïdors de serveis no estan autoritzats a utilitzar la informació recollida en nom nostre, excepte per ajudar-nos a dur a terme i millorar el nostre negoci.</p>\n\n<h3 id=\"disclose\">Publiquem informació al exterior?</h3>\n\n<p>No venem, comercialitzem ni transmetem a tercers la vostra informació d'identificació personal. Això no inclou tercers de confiança que ens ajudin a operar el nostre lloc, a dur a terme el nostre negoci o a fer-ho, sempre que aquestes parts acceptin mantenir confidencial aquesta informació. També podem publicar la vostra informació quan creiem que l'alliberament és apropiat per complir amb la llei, fer complir les polítiques del nostre lloc o protegir els nostres drets o altres drets, propietat o seguretat. No obstant això, la informació de visitant que no sigui personalment identificable es pot proporcionar a altres parts per a la comercialització, la publicitat o altres usos.</p> \n\n<h3 id=\"third-party\">Vincles de tercers</h3>\n\n<p>De tant en tant, segons el nostre criteri, podem incloure o oferir productes o serveis de tercers al nostre lloc. Aquests llocs de tercers tenen polítiques de privadesa separades i independents. Per tant, no tenim responsabilitat ni responsabilitat civil pel contingut i les activitats d'aquests llocs enllaçats. No obstant això, busquem protegir la integritat del nostre lloc i donem la benvinguda a qualsevol comentari sobre aquests llocs.</p>\n\n<h3 id=\"coppa\">Compliment de la Llei de protecció de la privacitat en línia dels nens</h3>\n\n<p>El nostre lloc, productes i serveis estan dirigits a persones que tenen almenys 13 anys. Si aquest servidor es troba als EUA, i teniu menys de 13 anys, segons els requisits de COPPA (<a href=\"https://en.wikipedia.org/wiki/Children%27s_Online_Privacy_Protection_Act\">Children's Online Privacy Protection Act</a>) no feu servir aquest lloc.</p>\n\n<h3 id=\"online\">Només la política de privacitat en línia</h3>\n\n<p>Aquesta política de privacitat en línia només s'aplica a la informació recopilada a través del nostre lloc i no a la informació recopilada fora de línia.</p>\n\n<h3 id=\"consent\">El vostre consentiment</h3>\n\n<p>En utilitzar el nostre lloc, accepta la política de privadesa del nostre lloc web.</p>\n\n<h3 id=\"changes\">Canvis a la nostra política de privacitat</h3>\n\n<p>Si decidim canviar la nostra política de privadesa, publicarem aquests canvis en aquesta pàgina.</p>\n\n<p>Aquest document és CC-BY-SA. Es va actualitzar per última vegada el 31 de maig de 2013.</p>\n\n<p>Originalment adaptat a la <a href=\"https://github.com/discourse/discourse\">política de privadesa del Discurs</a>.</p>\n" + title: "%{instance} Condicions del servei i política de privadesa" time: formats: default: "%b %d, %Y, %H:%M" two_factor_authentication: code_hint: Introdueix el codi generat per l'aplicació autenticadora per a confirmar description_html: Si habilites la <strong>autenticació de dos factors</strong>, et caldrà tenir el teu telèfon, que generarà tokens per a que puguis iniciar sessió. - disable: Deshabilitar + disable: Deshabilitarr enable: Habilitar + enabled: Two-factor authentication is enabled enabled_success: Autenticació de dos factors activada amb èxit generate_recovery_codes: Generar codis de recuperació instructions_html: "<strong>Escaneja aquest codi QR desde Google Authenticator o una aplicació similar del teu telèfon</strong>. Desde ara, aquesta aplicació generarà tokens que tens que ingresar quan volguis iniciar sessió." lost_recovery_codes: Els codis de recuperació et permeten recuperar l'accés al teu compte si perds el telèfon. Si has perdut els teus codis de recuperació els pots regenerar aquí. Els codis de recuperació anteriors seran anul·lats. manual_instructions: 'Si no pots escanejar el codi QR code i necessites introduir-lo manualment, aquí tens el secret en text plà:' + recovery_codes: Backup recovery codes recovery_codes_regenerated: Codis de recuperació regenerats amb èxit recovery_instructions_html: Si alguna vegada perds l'accéss al telèfon pots utilitzar un dels codis de recuperació a continuació per recuperar l'accés al teu compte. Cal mantenir els codis de recuperació en lloc segur, per exemple imprimint-los i guardar-los amb altres documents importants. setup: Establir @@ -336,3 +464,4 @@ ca: users: invalid_email: La direcció de correu es incorrecte invalid_otp_token: Codi de dos factors incorrecte + signed_in_as: 'Sessió iniciada com a:' diff --git a/config/locales/de.yml b/config/locales/de.yml index 1f3675f47..de6c86737 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -12,15 +12,15 @@ de: source_code: Quellcode status_count_after: Beiträge verfassten status_count_before: die - user_count_after: Benutzer + user_count_after: Profile user_count_before: Heimat für accounts: follow: Folgen followers: Folgende following: Folgt nothing_here: Hier gibt es nichts! - people_followed_by: Nutzer, denen %{name} folgt - people_who_follow: Nutzer, die %{name} folgen + people_followed_by: Profile, denen %{name} folgt + people_who_follow: Profile, die %{name} folgen posts: Beiträge remote_follow: Folgen unfollow: Entfolgen @@ -67,7 +67,7 @@ de: title: Konten undo_silenced: Stummschaltung zurücknehmen undo_suspension: Sperre zurücknehmen - username: Benutzername + username: Profilname web: Web domain_blocks: add_new: Neu hinzufügen @@ -124,7 +124,7 @@ de: settings: contact_information: email: Eine öffentliche E-Mail-Adresse angeben - username: Einen Benutzernamen angeben + username: Einen Profilnamen angeben registrations: closed_message: desc_html: Wird auf der Frontseite angezeigt, wenn die Registrierung geschlossen ist<br>Du kannst HTML-Tags benutzen @@ -208,7 +208,7 @@ de: following: Folgeliste muting: Stummschaltungsliste upload: Hochladen - landing_strip_html: "<strong>%{name}</strong> ist ein Benutzer auf %{link_to_root_path}. Du kannst ihm folgen oder mit ihm interagieren, sofern du ein Konto irgendwo in der Fediverse hast." + landing_strip_html: "<strong>%{name}</strong> hat ein Profil auf %{link_to_root_path}. Du kannst folgen oder interagieren, sofern du ein Konto irgendwo im Fediversum hast." landing_strip_signup_html: Wenn nicht, kannst du dich <a href="%{sign_up_path}">hier anmelden</a>. media_attachments: validations: @@ -239,12 +239,23 @@ de: reblog: body: 'Dein Beitrag wurde von %{name} geteilt:' subject: "%{name} teilte deinen Beitrag." + number: + human: + decimal_units: + format: "%n%u" + units: + billion: B + million: M + quadrillion: Q + thousand: K + trillion: T + unit: '' pagination: next: Vorwärts prev: Zurück truncate: "…" remote_follow: - acct: Dein Nutzername@Domain, von dem aus du dieser Person folgen möchtest. + acct: Dein Profilname@Domain, von dem aus du dieser Person folgen möchtest. missing_resource: Die erforderliche Weiterleitungs-URL konnte leider in deinem Profil nicht gefunden werden. proceed: Weiter prompt: 'Du wirst dieser Person folgen:' diff --git a/config/locales/devise.oc.yml b/config/locales/devise.oc.yml index 77740f230..99e62a10e 100644 --- a/config/locales/devise.oc.yml +++ b/config/locales/devise.oc.yml @@ -19,11 +19,11 @@ oc: confirmation_instructions: subject: "Mercés de confirmar vòstra inscripcion sus %{instance}" password_change: - subject: 'Mastodon : senhal cambiat' + subject: 'Mastodon : senhal cambiat' reset_password_instructions: - subject: 'Mastodon : instruccions per reïnicializar lo senhal' + subject: 'Mastodon : instruccions per reïnicializar lo senhal' unlock_instructions: - subject: 'Mastodon : instuccions de desblocatge' + subject: 'Mastodon : instuccions de desblocatge' omniauth_callbacks: failure: Fracàs al moment de vos autentificar de %{kind} perque "%{reason}". success: Sètz ben autentificat dempuèi lo compte %{kind}. @@ -34,8 +34,8 @@ oc: updated: Vòstre senhal es ben estat cambiat. Sètz ara connectat. updated_not_active: Vòstre senhal es ben estat cambiat. registrations: - destroyed: Adiu ! Vòstra inscripcion es estada anullada amb succès. Esperem vos tornar veire lèu. - signed_up: La benvenguda ! Sètz ben marcat al malhum. + destroyed: Adiu ! Vòstra inscripcion es estada anullada amb succès. Esperem vos tornar veire lèu. + signed_up: La benvenguda ! Sètz ben marcat al malhum. signed_up_but_inactive: Sètz ben marcat. Pasmens, avèm pas pogut vos connectar perque vòstre compte es pas encara validat. signed_up_but_locked: Sètz ben marcat. Pasmens, avèm pas pogut vos connectar perque vòstre compte es pas encara blocat. signed_up_but_unconfirmed: Un messatge amb un ligam de confirmacion es estat enviat a vòstra adreça de corrièl. Clicatz sul ligam per activar vòstre compte. Mercés de verificar tanben vòstre dorsièr de corrièls indesirables. @@ -57,5 +57,5 @@ oc: not_found: pas trobat not_locked: èra pas blocat not_saved: - one: '1 error defend aquesta %{resource} d’èsser salvagardada :' - other: "%{count} errors defendon aquesta %{resource} d’èsser salvagardadas :" + one: '1 error defend aquesta %{resource} d’èsser salvagardada :' + other: "%{count} errors defendon aquesta %{resource} d’èsser salvagardadas :" diff --git a/config/locales/doorkeeper.de.yml b/config/locales/doorkeeper.de.yml index b37ba1dbe..b0ba2fb98 100644 --- a/config/locales/doorkeeper.de.yml +++ b/config/locales/doorkeeper.de.yml @@ -77,7 +77,7 @@ de: invalid_grant: Die bereitgestellte Autorisierung ist inkorrekt, abgelaufen, widerrufen, ist mit einem anderen Client verknüpft oder der Redirection URI stimmt nicht mit der Autorisierungs-Anfrage überein. invalid_redirect_uri: Der Redirect-URI in der Anfrage ist ungültig. invalid_request: Die Anfrage enthält einen nicht-unterstützten Parameter, ein Parameter fehlt oder sie ist anderweitig fehlerhaft. - invalid_resource_owner: Die angegebenen Zugangsdaten für den "Resource Owner" sind inkorrekt oder dieser Benutzer existiert nicht. + invalid_resource_owner: Die angegebenen Zugangsdaten für den "Resource Owner" sind inkorrekt oder dieses Profil existiert nicht. invalid_scope: Der angeforderte Scope ist inkorrekt, unbekannt oder fehlerhaft. invalid_token: expired: Der Zugriffstoken ist abgelaufen @@ -108,6 +108,6 @@ de: application: title: OAuth-Autorisierung nötig scopes: - follow: Nutzer folgen, blocken, entblocken und entfolgen + follow: Profil folgen, blocken, entblocken und entfolgen read: deine Daten lesen write: Beiträge von deinem Konto aus veröffentlichen diff --git a/config/locales/doorkeeper.en.yml b/config/locales/doorkeeper.en.yml index 6412b8b48..efbd81d43 100644 --- a/config/locales/doorkeeper.en.yml +++ b/config/locales/doorkeeper.en.yml @@ -3,8 +3,10 @@ en: activerecord: attributes: doorkeeper/application: - name: Name + name: Application name redirect_uri: Redirect URI + scopes: Scopes + website: Application website errors: models: doorkeeper/application: @@ -33,18 +35,22 @@ en: redirect_uri: Use one line per URI scopes: Separate scopes with spaces. Leave blank to use the default scopes. index: + application: Application callback_url: Callback URL + delete: Delete name: Name - new: New Application + new: New application + scopes: Scopes + show: Show title: Your applications new: - title: New Application + title: New application show: actions: Actions - application_id: Application Id - callback_urls: Callback urls + application_id: Client key + callback_urls: Callback URLs scopes: Scopes - secret: Secret + secret: Client secret title: 'Application: %{name}' authorizations: buttons: diff --git a/config/locales/doorkeeper.fa.yml b/config/locales/doorkeeper.fa.yml index 33f453a3f..343580530 100644 --- a/config/locales/doorkeeper.fa.yml +++ b/config/locales/doorkeeper.fa.yml @@ -3,8 +3,10 @@ fa: activerecord: attributes: doorkeeper/application: - name: Name + name: Application name redirect_uri: Redirect URI + scopes: Scopes + website: Application website errors: models: doorkeeper/application: @@ -33,18 +35,22 @@ fa: redirect_uri: Use one line per URI scopes: Separate scopes with spaces. Leave blank to use the default scopes. index: + application: Application callback_url: Callback URL + delete: Delete name: Name - new: New Application + new: New application + scopes: Scopes + show: Show title: Your applications new: - title: New Application + title: New application show: actions: Actions - application_id: Application Id - callback_urls: Callback urls + application_id: Client key + callback_urls: Callback URLs scopes: Scopes - secret: Secret + secret: Client secret title: 'Application: %{name}' authorizations: buttons: diff --git a/config/locales/doorkeeper.fr.yml b/config/locales/doorkeeper.fr.yml index 0e74532c1..487018209 100644 --- a/config/locales/doorkeeper.fr.yml +++ b/config/locales/doorkeeper.fr.yml @@ -1,28 +1,14 @@ --- fr: - activemodel: - errors: - models: - remote_follow: - attributes: - acct: - blank: Le nom d’utilisateur ne doit pas être vide activerecord: attributes: doorkeeper/application: name: Nom redirect_uri: L’URL de redirection + scope: Portée + website: Site Web de l'application errors: - messages: - record_invalid: Données invalides models: - account: - attributes: - note: - too_long: Description trop longue - username: - blank: Identifiant vide - taken: Identifiant déjà pris doorkeeper/application: attributes: redirect_uri: @@ -30,17 +16,6 @@ fr: invalid_uri: doit être une URL valide. relative_uri: doit être une URL absolue. secured_uri: doit être une URL HTTP/SSL. - user: - attributes: - email: - blank: Courriel vide - invalid: Courriel invalide - taken: Courriel pris - password: - blank: Mot de passe vide - too_short: Mot de passe trop court - password_confirmation: - confirmation: Le mot de passe ne correspond pas doorkeeper: applications: buttons: diff --git a/config/locales/doorkeeper.ja.yml b/config/locales/doorkeeper.ja.yml index d3ea93789..9e3b72761 100644 --- a/config/locales/doorkeeper.ja.yml +++ b/config/locales/doorkeeper.ja.yml @@ -3,8 +3,10 @@ ja: activerecord: attributes: doorkeeper/application: - name: 名前 + name: アプリの名前 redirect_uri: リダイレクトURI + scopes: アクセス権 + website: アプリのウェブサイト errors: models: doorkeeper/application: @@ -33,18 +35,22 @@ ja: redirect_uri: 一行に一つのURLを入力してください scopes: アクセス権は半角スペースで区切ることができます。 空白のままにするとデフォルトを使用します。 index: + application: アプリ callback_url: コールバックURL + delete: 削除 name: 名前 new: 新規アプリ + scopes: アクセス権 + show: 見る title: アプリ new: title: 新規アプリ show: actions: アクション - application_id: アクションId - callback_urls: コールバックurl + application_id: クライアントキー + callback_urls: コールバックURL scopes: アクセス権 - secret: 非公開 + secret: クライアントシークレット title: 'アプリ: %{name}' authorizations: buttons: diff --git a/config/locales/doorkeeper.oc.yml b/config/locales/doorkeeper.oc.yml index 9f5d3fe55..b6aebea48 100644 --- a/config/locales/doorkeeper.oc.yml +++ b/config/locales/doorkeeper.oc.yml @@ -5,6 +5,8 @@ oc: doorkeeper/application: name: Nom redirect_uri: URL de redireccion + scopes: Encastres + website: Aplicacion web errors: models: doorkeeper/application: @@ -23,19 +25,23 @@ oc: edit: Modificar submit: Mandar confirmations: - destroy: Sètz segur ? + destroy: Sètz segur ? edit: title: Modificar l’aplicacion form: - error: Ops ! Verificatz vòstre formulari + error: Ops ! Verificatz vòstre formulari help: native_redirect_uri: Emplegatz %{native_redirect_uri} per d’ensages locales redirect_uri: Utilizatz una linha per URI scopes: Separatz los encastres amb d’espacis. Daissatz void per utilizar l’encastre per defaut. index: + application: Aplicacion callback_url: URL de rapèl + delete: Suprimir name: Nom new: Nòva aplicacion + scopes: Encastres + show: Veire title: Vòstras aplicacions new: title: Nòva aplicacion @@ -45,7 +51,7 @@ oc: callback_urls: urls de rapèls scopes: Encastres secret: Secret - title: 'Aplicacion : %{name}' + title: 'Aplicacion : %{name}' authorizations: buttons: authorize: Autorizar @@ -62,7 +68,7 @@ oc: buttons: revoke: Revocar confirmations: - revoke: Ne sètz segur ? + revoke: Ne sètz segur ? index: application: Aplicacion created_at: Creada lo diff --git a/config/locales/doorkeeper.pl.yml b/config/locales/doorkeeper.pl.yml index 72b967e35..ee3f4cad8 100644 --- a/config/locales/doorkeeper.pl.yml +++ b/config/locales/doorkeeper.pl.yml @@ -3,8 +3,10 @@ pl: activerecord: attributes: doorkeeper/application: - name: Nazwa + name: Nazwa aplikacji redirect_uri: URI przekierowania + scopes: Zakres + website: Strona aplikacji errors: models: doorkeeper/application: @@ -33,9 +35,13 @@ pl: redirect_uri: Jeden adres na linię tekstu scopes: Rozdziel zakresy (scopes) spacjami. Zostaw puste, aby użyć domyślnych zakresów. index: + application: Aplikacja callback_url: URL wywołania zwrotnego (callback) + delete: Usuń name: Nazwa new: Nowa aplikacja + scopes: Zakres + show: Pokaż title: Twoje aplikacje new: title: Nowa aplikacja @@ -72,7 +78,7 @@ pl: errors: messages: access_denied: Właściciel zasobu lub serwer autoryzujący odrzuciły żądanie. - credential_flow_not_configured: Ścieżka "Resource Owner Password Credentials" zakończyła się błędem, ponieważ Doorkeeper.configure.resource_owner_from_credentials nie jest skonfigurowany. + credential_flow_not_configured: Ścieżka "Resource Owner Password Credentials" zakończyła się błędem, ponieważ Doorkeeper.configure.resource_owner_from_credentials nie został skonfigurowany. invalid_client: Autoryzacja klienta nie powiodła się z powodu nieznanego klienta, braku uwierzytelnienia klienta, lub niewspieranej metody uwierzytelniania. invalid_grant: Grant uwierzytelnienia jest niepoprawny, przeterminowany, unieważniony, nie pasuje do URI przekierowwania użytego w żądaniu uwierzytelnienia, lub został wystawiony przez innego klienta. invalid_redirect_uri: URI przekierowania jest nieprawidłowy. @@ -83,8 +89,8 @@ pl: expired: Token dostępowy wygasł revoked: Token dostępowy został unieważniony unknown: Token dostępowy jest błędny - resource_owner_authenticator_not_configured: Wyszukiwanie właściciela zasobu nie powiodło się, ponieważ Doorkeeper.configure.resource_owner_authenticator jest nieskonfigurowany. - server_error: Serwer uwierzytelniający napotkał niespodziewane warunki, które uniemożliwiły obsłużenie żądania. + resource_owner_authenticator_not_configured: Wyszukiwanie właściciela zasobu nie powiodło się, ponieważ Doorkeeper.configure.resource_owner_authenticator nie został skonfigurowany. + server_error: Serwer uwierzytelniający napotkał nieoczekiwand warunki, które uniemożliwiły obsłużenie żądania. temporarily_unavailable: Serwer uwierzytelniający nie jest obecnie w stanie obsłużyć żądania z powodu tymczasowego przeciążenia lub prac konserwacyjnych. unauthorized_client: Klient nie jest uprawniony do wykonania tego żądania przy pomocy tej metody. unsupported_grant_type: Ten typ grantu uwierzytelniającego nie jest wspierany przez serwer uwierzytelniający. @@ -92,14 +98,14 @@ pl: flash: applications: create: - notice: Aplikacja utworzona. + notice: Utworzono aplikację. destroy: - notice: Aplikacja usunięta. + notice: Usunięto aplikację. update: - notice: Aplikacja zaktualizowana. + notice: Zaktualizowano aplikację. authorized_applications: destroy: - notice: Aplikacja unieważniona. + notice: Unieważniono aplikację. layouts: admin: nav: @@ -110,4 +116,4 @@ pl: scopes: follow: śledzenie, blokowanie, usuwanie blokady, anulowanie śledzenia kont read: dostęp do odczytu danych konta - write: publikowanie postów w Twoim imieniu + write: publikowanie wpisów w Twoim imieniu diff --git a/config/locales/en.yml b/config/locales/en.yml index 1fa0de90b..4160745f8 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -37,12 +37,16 @@ en: follow: Follow followers: Followers following: Following + media: Media nothing_here: There is nothing here! people_followed_by: People whom %{name} follows people_who_follow: People who follow %{name} - posts: Posts + posts: Toots + posts_with_replies: Toots with replies remote_follow: Remote follow reserved_username: The username is reserved + roles: + admin: Admin unfollow: Unfollow admin: accounts: @@ -57,6 +61,7 @@ en: feed_url: Feed URL followers: Followers follows: Follows + inbox_url: Inbox URL ip: IP location: all: All @@ -76,8 +81,10 @@ en: alphabetic: Alphabetic most_recent: Most recent title: Order + outbox_url: Outbox URL perform_full_suspension: Perform full suspension profile_url: Profile URL + protocol: Protocol public: Public push_subscription_expires: PuSH subscription expires redownload: Refresh avatar @@ -220,7 +227,13 @@ en: signature: Mastodon notifications from %{instance} view: 'View:' applications: + created: Application successfully created + destroyed: Application successfully deleted invalid_url: The provided URL is invalid + regenerate_token: Regenerate access token + token_regenerated: Access token successfully regenerated + warning: Be very careful with this data. Never share it with anyone! + your_token: Your access token auth: agreement_html: By signing up you agree to <a href="%{rules_path}">our terms of service</a> and <a href="%{terms_path}">privacy policy</a>. change_password: Security @@ -275,7 +288,7 @@ en: content: Security verification failed. Are you blocking cookies? title: Security verification failed '429': Throttled - noscript: To use the Mastodon web application, please enable JavaScript. Alternatively, find a native app for Mastodon for your platform. + noscript_html: To use the Mastodon web application, please enable JavaScript. Alternatively, try one of the <a href="https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md">native apps</a> for Mastodon for your platform. exports: blocks: You block csv: CSV @@ -340,6 +353,17 @@ en: reblog: body: 'Your status was boosted by %{name}:' subject: "%{name} boosted your status" + number: + human: + decimal_units: + format: "%n%u" + units: + billion: B + million: M + quadrillion: Q + thousand: K + trillion: T + unit: '' pagination: next: Next prev: Prev @@ -358,9 +382,6 @@ en: title: "%{name} mentioned you" reblog: title: "%{name} boosted your status" - subscribed: - body: You can now receive push notifications. - title: Subscription registered! remote_follow: acct: Enter your username@domain you want to follow from missing_resource: Could not find the required redirect URL for your account @@ -409,6 +430,7 @@ en: authorized_apps: Authorized apps back: Back to Mastodon delete: Account deletion + development: Development edit_profile: Edit profile export: Data export followers: Authorized followers @@ -416,9 +438,14 @@ en: preferences: Preferences settings: Settings two_factor_authentication: Two-factor Authentication + your_apps: Your applications statuses: open_in_web: Open in web over_character_limit: character limit of %{max} exceeded + pin_errors: + ownership: Someone else's toot cannot be pinned + private: Non-public toot cannot be pinned + reblog: A boost cannot be pinned show_more: Show more visibilities: private: Followers-only @@ -429,6 +456,7 @@ en: unlisted_long: Everyone can see, but not listed on public timelines stream_entries: click_to_show: Click to show + pinned: Pinned toot reblogged: boosted sensitive_content: Sensitive content terms: diff --git a/config/locales/eo.yml b/config/locales/eo.yml index f8b5ec0ac..21def0c5f 100644 --- a/config/locales/eo.yml +++ b/config/locales/eo.yml @@ -103,6 +103,17 @@ eo: reblog: body: "%{name} diskonigis vian mesaĝon:" subject: "%{name} diskonigis vian mesaĝon" + number: + human: + decimal_units: + format: "%n%u" + units: + billion: B + million: M + quadrillion: Q + thousand: K + trillion: T + unit: '' pagination: next: Sekva prev: Malsekva diff --git a/config/locales/es.yml b/config/locales/es.yml index d2d1de14f..a02330521 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -108,6 +108,17 @@ es: reblog: body: "%{name} ha retooteado tu estado" subject: "%{name} ha retooteado tu estado" + number: + human: + decimal_units: + format: "%n%u" + units: + billion: B + million: M + quadrillion: Q + thousand: K + trillion: T + unit: '' pagination: next: Próximo prev: Anterior diff --git a/config/locales/fa.yml b/config/locales/fa.yml index 0c575e23e..ba726fc75 100644 --- a/config/locales/fa.yml +++ b/config/locales/fa.yml @@ -274,7 +274,7 @@ fa: content: تأیید امنیتی انجام نشد. آیا مرورگر شما کوکیها را مسدود میکند؟ title: تأیید امنیتی شکست خورد '429': درخواستهای بیش از حد - noscript: برای استفاده از نسخهٔ تحت وب ماستدون، لطفاً جاوااسکریپت را فعال کنید. یا به جایش میتوانید یک اپ ماستدون را بهکار ببرید. + noscript_html: برای استفاده از نسخهٔ تحت وب ماستدون، لطفاً جاوااسکریپت را فعال کنید. یا به جایش میتوانید یک اپ ماستدون را بهکار ببرید. exports: blocks: حسابهای مسدودشده csv: CSV @@ -339,6 +339,17 @@ fa: reblog: body: "%{name} نوشتهٔ شما را بازبوقید:" subject: "%{name} نوشتهٔ شما را بازبوقید" + number: + human: + decimal_units: + format: "%n%u" + units: + billion: B + million: M + quadrillion: Q + thousand: K + trillion: T + unit: '' pagination: next: بعدی prev: قبلی @@ -357,9 +368,6 @@ fa: title: "%{name} از شما نام برد" reblog: title: "%{name} نوشتهٔ شما را بازبوقید" - subscribed: - body: از این به بعد سرور میتواندبه شما اعلانهای تازه بفرستد . - title: عضویت ثبت شد! remote_follow: acct: نشانی حساب username@domain خود را اینجا بنویسید missing_resource: نشانی اینترنتی برای رسیدن به حساب شما پیدا نشد diff --git a/config/locales/fi.yml b/config/locales/fi.yml index b748f7184..08ae90447 100644 --- a/config/locales/fi.yml +++ b/config/locales/fi.yml @@ -103,6 +103,17 @@ fi: reblog: body: 'Sinun statustasi boostasi %{name}:' subject: "%{name} boostasi statustasi" + number: + human: + decimal_units: + format: "%n%u" + units: + billion: B + million: M + quadrillion: Q + thousand: K + trillion: T + unit: '' pagination: next: Seuraava prev: Edellinen diff --git a/config/locales/fr.yml b/config/locales/fr.yml index d7aa41497..6198a5454 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -5,9 +5,14 @@ fr: about_this: À propos closed_registrations: Les inscriptions sont actuellement fermées sur cette instance. Cependant, vous pouvez trouver une autre instance sur laquelle vous créer un compte et à partir de laquelle vous pourrez accéder au même réseau. contact: Contact + contact_missing: Manquant + contact_unavailable: Non disponible description_headline: Qu’est-ce que %{domain} ? domain_count_after: autres instances domain_count_before: Connectés à + extended_description_html: | + <h3>Un bon endroit pour les règles</h3> + <p>La description étendue n'a pas été remplie.</p> features: humane_approach_body: Ayant appris des échecs d’autres réseaux, Mastodon à l’ambition de combattre l’abus des médias sociaux en effectuant des choix de conception éthiques. humane_approach_title: Une approche plus humaine @@ -23,7 +28,7 @@ fr: learn_more: En savoir plus other_instances: Liste des instances source_code: Code source - status_count_after: posts + status_count_after: statuts status_count_before: Ayant publié user_count_after: utilisateur⋅ice⋅s user_count_before: Abrite @@ -32,12 +37,16 @@ fr: follow: Suivre followers: Abonné⋅es following: Abonnements + media: Médias nothing_here: Rien à voir ici ! people_followed_by: Personnes suivies par %{name} people_who_follow: Personnes qui suivent %{name} posts: Statuts + posts_with_replies: Statuts & réponses remote_follow: Suivre à distance reserved_username: Ce nom d’utilisateur⋅ice est réservé + roles: + admin: Admin unfollow: Ne plus suivre admin: accounts: @@ -52,6 +61,7 @@ fr: feed_url: URL du flux followers: Abonné⋅es follows: Abonnements + inbox_url: URL d'entrée ip: Adresse IP location: all: Tous @@ -71,8 +81,10 @@ fr: alphabetic: Alphabétique most_recent: Plus récent title: Tri + outbox_url: URL de sortie perform_full_suspension: Effectuer une suspension complète profile_url: URL du profil + protocol: Protocole public: Public push_subscription_expires: Expiration de l’abonnement PuSH redownload: Rafraîchir les avatars @@ -104,12 +116,14 @@ fr: hint: Le blocage de domaine n’empêchera pas la création de comptes dans la base de données, mais il appliquera automatiquement et rétrospectivement des méthodes de modération spécifiques sur ces comptes. severity: desc_html: "<strong>Silence</strong> rendra les messages des comptes concernés invisibles à ceux qui ne les suivent pas. <strong>Suspend</strong> supprimera tout le contenu des comptes concernés, les médias, et les données du profil." + noop: Aucune silence: Masqué suspend: Suspendre title: Nouveau blocage de domaine reject_media: Fichiers média rejetés reject_media_hint: Supprime localement les fichiers média stockés et refuse d’en télécharger ultérieurement. Ne concerne pas les suspensions. severities: + noop: Aucune silence: Masquer suspend: Suspendre severity: Séverité @@ -187,7 +201,7 @@ fr: nsfw_off: NSFW OFF nsfw_on: NSFW ON execute: Exécuter - failed_to_execute: Erreur d'exécution + failed_to_execute: Erreur d’exécution media: hide: Masquer les médias show: Montrer les médias @@ -212,12 +226,18 @@ fr: signature: Notifications de Mastodon depuis %{instance} view: 'Voir :' applications: + created: Application créée avec succès + destroyed: Application supprimée avec succès invalid_url: L’URL fournie est invalide + regenerate_token: Regénérer le jeton d'accès + token_regenerated: Jeton d'accès regénéré avec succès + warning: Soyez prudent⋅e avec ces données. Ne les partagez pas ! + your_token: Votre jeton d'accès auth: agreement_html: En vous inscrivant, vous souscrivez à <a href="%{rules_path}">nos conditions d’utilisation</a> ainsi qu’à <a href="%{terms_path}">notre politique de confidentialité</a>. change_password: Sécurité delete_account: Supprimer le compte - delete_account_html: Si vous désirez supprimer votre compte, vous pouvez cliquer ici. Il vous sera demandé de confirmer cette action. + delete_account_html: Si vous désirez supprimer votre compte, vous pouvez <a href="%{path}">cliquer ici</a>. Il vous sera demandé de confirmer cette action. didnt_get_confirmation: Vous n’avez pas reçu les consignes de confirmation ? forgot_password: Mot de passe oublié ? invalid_reset_password_token: Le lien de réinitialisation du mot de passe est invalide ou a expiré. Merci de réessayer. @@ -231,11 +251,11 @@ fr: error: Malheureusement, il y a eu une erreur en cherchant les détails du compte distant follow: Suivre follow_request: 'Vous avez demandé à suivre:' - following: 'Youpi! Vous suivez :' + following: 'Youpi ! Vous suivez :' post_follow: close: Ou bien, vous pouvez fermer cette fenêtre. - return: Retour au profil de l'utilisateur⋅trice - web: Retour à l'interface web + return: Retour au profil de l’utilisateur⋅trice + web: Retour à l’interface web title: Suivre %{acct} datetime: distance_in_words: @@ -273,7 +293,7 @@ fr: content: Vérification de sécurité échouée. Bloquez-vous les cookies ? title: Vérification de sécurité échouée '429': Trop de requêtes émises dans un délai donné. - noscript: Pour utiliser Mastodon, veuillez activer JavaScript + noscript_html: Pour utiliser Mastodon, veuillez activer JavaScript exports: blocks: Vous bloquez csv: CSV @@ -282,7 +302,7 @@ fr: storage: Médias stockés followers: domain: Domaine - explanation_html: Si vous voulez être sûr⋅e que vos status restent privés, vous devez savoir qui vous suit. <strong>Vos status privés seront diffusés à toutes les instances des utilisateur⋅ice⋅s qui vous suivent</strong>. Vous voudrez peut-être les passer en revue et les supprimer si vous n’êtes pas sûr⋅e que votre vie privée sera respectée par l’administration ou le logiciel de ces instances. + explanation_html: Si vous voulez être sûr⋅e que vos statuts restent privés, vous devez savoir qui vous suit. <strong>Vos statuts privés seront diffusés à toutes les instances des utilisateur⋅ice⋅s qui vous suivent</strong>. Vous voudrez peut-être les passer en revue et les supprimer si vous n’êtes pas sûr⋅e que votre vie privée sera respectée par l’administration ou le logiciel de ces instances. followers_count: Nombre d’abonné⋅es lock_link: Rendez votre compte privé purge: Retirer de la liste d’abonné⋅es @@ -290,7 +310,7 @@ fr: one: Suppression des abonné⋅es venant d’un domaine en cours… other: Suppression des abonné⋅es venant de %{count} domaines en cours… true_privacy_html: Soyez conscient⋅es <strong>qu’une vraie confidentialité ne peut être atteinte que par un chiffrement de bout-en-bout</strong>. - unlocked_warning_html: N’importe qui peut vous suivre et voir vos status privés. %{lock_link} afin de pouvoir vérifier et rejeter des abonné⋅es. + unlocked_warning_html: N’importe qui peut vous suivre et voir vos statuts privés. %{lock_link} afin de pouvoir vérifier et rejeter des abonné⋅es. unlocked_warning_title: Votre compte n’est pas privé generic: changes_saved_msg: Les modifications ont été enregistrées avec succès ! @@ -311,7 +331,7 @@ fr: landing_strip_signup_html: Si ce n’est pas le cas, vous pouvez <a href="%{sign_up_path}">en créer un ici</a>. media_attachments: validations: - images_and_video: Impossible de joindre une vidéo à un status contenant déjà des images + images_and_video: Impossible de joindre une vidéo à un statut contenant déjà des images too_many: Impossible de joindre plus de 4 fichiers notification_mailer: digest: @@ -334,30 +354,40 @@ fr: subject: 'Abonné⋅es en attente : %{name}' mention: body: "%{name} vous a mentionné⋅e dans :" - subject: "%{name} vous a mentionné" + subject: "%{name} vous a mentionné·e" reblog: - body: "%{name} a partagé votre status :" - subject: "%{name} a partagé votre status" + body: "%{name} a partagé votre statut :" + subject: "%{name} a partagé votre statut" + number: + human: + decimal_units: + format: "%n%u" + units: + billion: B + million: M + quadrillion: Q + thousand: K + trillion: T + unit: '' pagination: next: Suivant prev: Précédent push_notifications: favourite: - title: "%{name} à mis votre status en favori" + title: "%{name} à mis votre statut en favori" follow: title: "%{name} vous suit" + group: + title: "%{count} notifications" mention: action_boost: Partager action_expand: Montrer plus action_favourite: Ajouter aux favoris - title: "%{name} vous a mentionné" + title: "%{name} vous a mentionné·e" reblog: - title: "%{name} a partagé⋅e votre status" - subscribed: - body: Vous pouvez désormais recevoir des notifications push. - title: Abonnements aux notifications push + title: "%{name} a partagé⋅e votre statut" remote_follow: - acct: Entrez votre pseudo@instance depuis lequel vous voulez suivre ce⋅tte utilisateur⋅trice + acct: Entrez votre pseudo@instance depuis lequel vous voulez suivre ce⋅tte utilisateur⋅rice missing_resource: L’URL de redirection n’a pas pu être trouvée proceed: Continuez pour suivre prompt: 'Vous allez suivre :' @@ -404,6 +434,7 @@ fr: authorized_apps: Applications autorisées back: Retour vers Mastodon delete: Suppression de compte + development: Développement edit_profile: Modifier le profil export: Export de données followers: Abonné⋅es autorisé⋅es @@ -411,24 +442,30 @@ fr: preferences: Préférences settings: Réglages two_factor_authentication: Identification à deux facteurs + your_apps: Vos applications statuses: open_in_web: Ouvrir sur le web over_character_limit: limite de caractères dépassée de %{max} caractères + pin_errors: + ownership: Vous ne pouvez pas épingler un statut ne vous appartenant pas + private: Les statuts non-publics ne peuvent pas être épinglés + reblog: Un partage ne peut pas être épinglé show_more: Afficher plus visibilities: private: Abonné⋅es uniquement - private_long: Seul⋅es vos abonné⋅es verront vos status + private_long: Seul⋅es vos abonné⋅es verront vos statuts public: Public - public_long: Tout le monde peut voir vos status + public_long: Tout le monde peut voir vos statuts unlisted: Public sans être affiché sur le fil public - unlisted_long: Tout le monde peut voir vos status mais ils ne seront pas sur listés sur les fils publics + unlisted_long: Tout le monde peut voir vos statuts mais ils ne seront pas sur listés sur les fils publics stream_entries: click_to_show: Cliquer pour afficher + pinned: Statut épinglé reblogged: partagé sensitive_content: Contenu sensible terms: - body_html: "<h2>Politique de confidentialité</h2>\n\n<h3 id=\"collect\">Quelles données collectons-nous?</h3>\n\n<p>Nous collectons des données lorsque vous vous enregistrez sur notre site et les récoltons lorsque vous participez dans le forum en lisant, écrivant, et évaluant le contenu partagé ici.</p>\n\n<p>Lors de l'enregistrement sur notre site, il peut vous être demandé de renseigner votre nom et adresse e-mail. Vous pouvez, cependant, visiter notre site sans inscription. Votre adresse e-mail devra être vérifiée grâce à un e-mail contenant un lien unique. Si ce lien est visité, nous savons que vous contrôlez cette adresse e-mail.</p>\n\n<p>Lors de l'inscription et de la publication de statuts, nous enregistrons l'adresse IP de laquelle le(s) status viennent. Nous pouvons également conserver des historiques serveurs qui contiendront l'adresse IP de chaque requête adressée à notre serveur.</p>\n\n<h3 id=\"use\">Que faisons-nous avec vos données?</h3>\n\n<p>Toute information que nous collectons pourra être utilisée d'une des manières suivantes :</p>\n\n<ul>\n <li>Pour personnaliser votre expérience — vos données nous aident à mieux répondre à vos besoins individuels.</li>\n <li>Pour améliorer notre site — nous faisons tout notre possible pour améliorer notre site en fonction des données, retours et suggestions que nous recevons.</li>\n <li>Afin d'améliorer le support client — vos données nous aident à mieux répondre à vos requêtes et demandes de support.</li>\n <li>Afin d'envoyer des e-mails à intervalles réguliers — l'adresse e-mail que vous renseignez peut être utilisée pour vous envoyer des données et notifications concernant des changements ou en réponse à votre nom d'utilisateur⋅trice, en réponse à vos demandes et/ou autres requêtes ou questions</li>\n</ul>\n\n<h3 id=\"protect\">Comment protégeons-nous vos données?</h3>\n \n<p>Nous appliquons une multitude de mesures afin de maintenir la sécurité de vos données personnelles lorsque vous entrez, soumettez, ou accédez à ces dernières.</p>\n\n<h3 id=\"data-retention\">Quelle est notre politique de conservation des données?</h3>\n\n<p>Nous nous efforçons de:</p>\n\n<ul>\n <li>Ne pas garder les historiques serveurs contenant l'adresse IP de chaque requête adressée à ce serveur plus de 90 jours.</li>\n <li>Ne pas conserver les adresses IP associées aux utilisateur⋅trices et leur contenu plus de 5 ans.</li>\n</ul>\n\n<h3 id=\"cookies\">Utilisons nous des \"cookies\"?</h3>\n\n<p>Oui. Les cookies sont de petits fichiers qu'un site ou prestataires de services transfèrent sur le disque dur de votre ordinateur par le biais de votre navigateur Web (si ce dernier le permet). Ces cookies permettent au site de reconnaître votre navigateur et, si vous disposez d'un compte, l'associer à votre compte.</p>\n\n<p>Nous utilisons les cookies pour enregistrer vos préférences pour de futures visites, compiler des données agrégées à propos du trafic et des interactions effectuées sur le site afin de proposer une meilleure expérience dans le futur. Nous pouvons contracter les services d'acteurs tiers afin de nous aider à mieux comprendre les visiteurs de notre site. Ces acteurs ont l'autorisation d'utiliser ces données seulement à des fins d'améliorations.</p>\n\n<h3 id=\"disclose\">Divulguons-nous des données à des acteurs tiers ?</h3>\n\n<p>Nous n'échangeons pas, ne vendons pas ni effectuons de quelconques transferts avec des acteurs tiers d'informations permettant de vous identifier personnellement. Cela n'inclut pas les acteurs de confiance qui nous aident à gérer notre entreprise et à vous servir tant que ces acteurs s'accordent à garder lesdites informations confidentielles. Nous pouvons être amenés à délivrer vos informations lorsque jugé adéquat afin de respecter la loi, d'appliquer la politique de notre site, ou afin de protéger nos droits, ceux des autres, notre propriété ou sécurité. Cependant, aucune information permettant l'identification de nos visiteurs ne sera divulguée à des fins publicitaires, commerciales ou tout autre usage.</p>\n\n<h3 id=\"third-party\">Liens vers des acteurs tiers</h3>\n\n<p>Nous pouvons être amenés à inclure ou offrir les services ou produits d'acteurs tiers sur notre site. Ces acteurs tiers possèdent leur propre politique de confidentialité. Nous ne sommes donc pas responsables du contenu ou activités desdits acteurs. Néanmoins, nous cherchons à protéger l'intégrité de notre site et sommes ouverts à toute remarque concernant ces acteurs.</p>\n\n<h3 id=\"coppa\">Children's Online Privacy Protection Act</h3>\n\n<p>Notre site, nos produits et services sont tous dirigés à l'usage de personnes étant âgés de 13 ans ou plus. Si ce serveur est hébergé aux États-Unis et que vous êtes âgé⋅e de moins de 13 ans, au vu du COPPA (<a href=\"https://fr.wikipedia.org/wiki/Children%27s_Online_Privacy_Protection_Act\">Children's Online Privacy Protection Act</a>) n'utilisez pas ce site.</p>\n\n<h3 id=\"consent\">Votre consentement</h3>\n\n<p>En utilisant notre site, vous consentez à la politique de confiedentialité de notre site Web.</p>\n\n<h3 id=\"changes\">Changements de notre politique de confidentialité</h3>\n\n<p>Si nous décidons d'apporter des changements à notre politique de confidentialité, nous les mettrons à disposition sur cette page.</p>\n\n<p>Ce document est distribué sous licence CC-BY-SA. Il a été mis à jour pour la dernière fois le 31 Mai 2013. Il a été traduit en français en Juillet 2017.</p>\n\n<p>Originellement adapté à partir de la politique de confidentialité de <a href=\"https://github.com/discourse/discourse\">Discourse</a></p>.\n" - title: "%{instance} Conditions d'utilisations et Politique de confidentialité" + body_html: "<h2>Politique de confidentialité</h2>\n\n<h3 id=\"collect\">Quelles données collectons-nous ?</h3>\n\n<p>Nous collectons des données lorsque vous vous enregistrez sur notre site et les récoltons lorsque vous participez dans le forum en lisant, écrivant, et évaluant le contenu partagé ici.</p>\n\n<p>Lors de l’enregistrement sur notre site, il peut vous être demandé de renseigner votre nom et adresse électronique. Vous pouvez, cependant, visiter notre site sans inscription. Votre adresse électronique devra être vérifiée grâce à un courriel contenant un lien unique. Si ce lien est visité, nous savons que vous contrôlez cette adresse.</p>\n\n<p>Lors de l’inscription et de la publication de statuts, nous enregistrons l’adresse IP de laquelle les statuts proviennent. Nous pouvons également conserver des historiques serveurs qui contiendront l’adresse IP de chaque requête adressée à notre serveur.</p>\n\n<h3 id=\"use\">Que faisons-nous avec vos données ?</h3>\n\n<p>Toute information que nous collectons pourra être utilisée d’une des manières suivantes :</p>\n\n<ul>\n <li>Pour personnaliser votre expérience — vos données nous aident à mieux répondre à vos besoins individuels.</li>\n <li>Pour améliorer notre site — nous faisons tout notre possible pour améliorer notre site en fonction des données, retours et suggestions que nous recevons.</li>\n <li>Afin d’améliorer le support client — vos données nous aident à mieux répondre à vos requêtes et demandes de support.</li>\n <li>Afin d’envoyer des courriels à intervalles réguliers — l’adresse électronique que vous renseignez peut être utilisée pour vous envoyer des données et notifications concernant des changements ou en réponse à votre nom d’utilisateur⋅trice, en réponse à vos demandes et/ou autres requêtes ou questions</li>\n</ul>\n\n<h3 id=\"protect\">Comment protégeons-nous vos données ?</h3>\n \n<p>Nous appliquons une multitude de mesures afin de maintenir la sécurité de vos données personnelles lorsque vous entrez, soumettez, ou accédez à ces dernières.</p>\n\n<h3 id=\"data-retention\">Quelle est notre politique de conservation des données ?</h3>\n\n<p>Nous nous efforçons de :</p>\n\n<ul>\n <li>ne pas garder les historiques serveurs contenant l’adresse IP de chaque requête adressée à ce serveur plus de 90 jours ;</li>\n <li>ne pas conserver les adresses IP associées aux utilisateur⋅trices et leur contenu plus de 5 ans.</li>\n</ul>\n\n<h3 id=\"cookies\">Utilisons nous des « cookies » ?</h3>\n\n<p>Oui. Les cookies sont de petits fichiers qu’un site ou prestataires de services transfèrent sur le disque dur de votre ordinateur par le biais de votre navigateur Web (si ce dernier le permet). Ces cookies permettent au site de reconnaître votre navigateur et, si vous disposez d’un compte, de l’associer à celui-ci.</p>\n\n<p>Nous utilisons les cookies pour enregistrer vos préférences pour de futures visites, compiler des données agrégées à propos du trafic et des interactions effectuées sur le site afin de proposer une meilleure expérience dans le futur. Nous pouvons contracter les services d’acteurs tiers afin de nous aider à mieux comprendre les visiteurs de notre site. Ces acteurs ont l’autorisation d’utiliser ces données seulement à des fins d’améliorations.</p>\n\n<h3 id=\"disclose\">Divulguons-nous des données à des acteurs tiers ?</h3>\n\n<p>Nous n’échangeons pas, ne vendons pas ni effectuons de quelconques transferts avec des acteurs tiers d’informations permettant de vous identifier personnellement. Cela n’inclut pas les acteurs de confiance qui nous aident à gérer notre entreprise et à vous servir tant que ces acteurs s’accordent à garder lesdites informations confidentielles. Nous pouvons être amenés à délivrer vos informations lorsque jugé adéquat afin de respecter la loi, d’appliquer la politique de notre site, ou afin de protéger nos droits, ceux des autres, notre propriété ou sécurité. Cependant, aucune information permettant l’identification de nos visiteurs ne sera divulguée à des fins publicitaires, commerciales ou tout autre usage.</p>\n\n<h3 id=\"third-party\">Liens vers des acteurs tiers</h3>\n\n<p>Nous pouvons être amenés à inclure ou offrir les services ou produits d’acteurs tiers sur notre site. Ces acteurs tiers possèdent leur propre politique de confidentialité. Nous ne sommes donc pas responsables du contenu ou activités desdits acteurs. Néanmoins, nous cherchons à protéger l’intégrité de notre site et sommes ouverts à toute remarque concernant ces acteurs.</p>\n\n<h3 id=\"coppa\" lang=\"en\">Children's Online Privacy Protection Act</h3>\n\n<p>Notre site, nos produits et services sont tous destinés à l’usage de personnes âgées de 13 ans ou plus. Si ce serveur est hébergé aux États-Unis et que vous êtes âgé⋅e de moins de 13 ans, au vu du COPPA (<a href=\"https://fr.wikipedia.org/wiki/Children%27s_Online_Privacy_Protection_Act\"><i lang=\"en\">Children's Online Privacy Protection Act</i></a>) n’utilisez pas ce site.</p>\n\n<h3 id=\"consent\">Votre consentement</h3>\n\n<p>En utilisant notre site, vous consentez à la présente politique de confidentialité.</p>\n\n<h3 id=\"changes\">Changements de notre politique de confidentialité</h3>\n\n<p>Si nous décidons d’apporter des changements à notre politique de confidentialité, nous les publierons sur cette page.</p>\n\n<p>Ce document est distribué sous licence CC-BY-SA. Il a été mis à jour pour la dernière fois le 31 mai 2013. Il a été traduit en français en juillet 2017.</p>\n\n<p>Originellement adapté à partir de la politique de confidentialité de <a href=\"https://github.com/discourse/discourse\">Discourse</a>.</p>\n" + title: "%{instance} Conditions d’utilisations et politique de confidentialité" time: formats: default: "%d %b %Y, %H:%M" @@ -451,4 +488,4 @@ fr: users: invalid_email: L’adresse courriel est invalide invalid_otp_token: Le code d’authentification à deux facteurs est invalide - signed_in_as: 'Connecté·e en tant que :' + signed_in_as: 'Connecté·e en tant que :' diff --git a/config/locales/he.yml b/config/locales/he.yml index f04e8ad62..84d6d8468 100644 --- a/config/locales/he.yml +++ b/config/locales/he.yml @@ -264,6 +264,17 @@ he: reblog: body: 'חצרוצך הודהד על ידי %{name}:' subject: חצרוצך הודהד על ידי%{name} + number: + human: + decimal_units: + format: "%n%u" + units: + billion: B + million: M + quadrillion: Q + thousand: K + trillion: T + unit: '' pagination: next: הבא prev: הקודם diff --git a/config/locales/hr.yml b/config/locales/hr.yml index 52a8bd35f..581912420 100644 --- a/config/locales/hr.yml +++ b/config/locales/hr.yml @@ -105,6 +105,17 @@ hr: reblog: body: 'Tvoj status je potaknut od %{name}:' subject: "%{name} je potakao tvoj status" + number: + human: + decimal_units: + format: "%n%u" + units: + billion: B + million: M + quadrillion: Q + thousand: K + trillion: T + unit: '' pagination: next: Sljedeći prev: Prošli diff --git a/config/locales/hu.yml b/config/locales/hu.yml index 53319a673..77551223f 100644 --- a/config/locales/hu.yml +++ b/config/locales/hu.yml @@ -45,6 +45,17 @@ hu: reblog: body: 'Az állapotod reblogolta %{name}:' subject: "%{name} reblogolta az állapotod" + number: + human: + decimal_units: + format: "%n%u" + units: + billion: B + million: M + quadrillion: Q + thousand: K + trillion: T + unit: '' pagination: next: Következő prev: Előző diff --git a/config/locales/id.yml b/config/locales/id.yml index c76b3d6bb..f3a6649d1 100644 --- a/config/locales/id.yml +++ b/config/locales/id.yml @@ -254,6 +254,17 @@ id: reblog: body: 'Status anda di-boost oleh %{name}:' subject: "%{name} mem-boost status anda" + number: + human: + decimal_units: + format: "%n%u" + units: + billion: B + million: M + quadrillion: Q + thousand: K + trillion: T + unit: '' pagination: next: Selanjutnya prev: Sebelumnya diff --git a/config/locales/io.yml b/config/locales/io.yml index 112771ee4..4114e5231 100644 --- a/config/locales/io.yml +++ b/config/locales/io.yml @@ -239,6 +239,17 @@ io: reblog: body: "%{name} diskonocigis tua mesajo:" subject: "%{name} diskonocigis tua mesajo" + number: + human: + decimal_units: + format: "%n%u" + units: + billion: B + million: M + quadrillion: Q + thousand: K + trillion: T + unit: '' pagination: next: Sequanta prev: Preiranta diff --git a/config/locales/it.yml b/config/locales/it.yml index 75d56362a..ec0209bc1 100644 --- a/config/locales/it.yml +++ b/config/locales/it.yml @@ -108,6 +108,17 @@ it: reblog: body: 'Il tuo status è stato condiviso da %{name}:' subject: "%{name} ha condiviso il tuo status" + number: + human: + decimal_units: + format: "%n%u" + units: + billion: B + million: M + quadrillion: Q + thousand: K + trillion: T + unit: '' pagination: next: Avanti prev: Indietro diff --git a/config/locales/ja.yml b/config/locales/ja.yml index 05c712234..fa9e1d112 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -37,12 +37,16 @@ ja: follow: フォロー followers: フォロワー following: フォロー中 + media: メディア nothing_here: 何もありません people_followed_by: "%{name} さんがフォロー中のアカウント" people_who_follow: "%{name} さんをフォロー中のアカウント" - posts: 投稿 + posts: トゥート + posts_with_replies: トゥートと返信 remote_follow: リモートフォロー reserved_username: このユーザー名は予約されています。 + roles: + admin: Admin unfollow: フォロー解除 admin: accounts: @@ -57,6 +61,7 @@ ja: feed_url: フィードURL followers: フォロワー数 follows: フォロー数 + inbox_url: Inbox URL ip: IP location: all: すべて @@ -76,10 +81,12 @@ ja: alphabetic: アルファベット順 most_recent: 直近の活動順 title: 順序 + outbox_url: Outbox URL perform_full_suspension: 完全に活動停止させる profile_url: プロフィールURL + protocol: プロトコル public: パブリック - push_subscription_expires: PuSH購読期限切れ + push_subscription_expires: PuSH購読期限 redownload: アバターの更新 reset: リセット reset_password: パスワード再設定 @@ -219,7 +226,13 @@ ja: signature: Mastodon %{instance} インスタンスからの通知 view: 'View:' applications: + created: アプリが作成されました + destroyed: アプリが削除されました invalid_url: URLが無効です + regenerate_token: アクセストークンの再生成 + token_regenerated: アクセストークンが再生成されました + warning: このデータは気をつけて取り扱ってください。不特定多数の人と共有しないでください! + your_token: アクセストークン auth: agreement_html: 登録すると <a href="%{rules_path}">利用規約</a> と <a href="%{terms_path}">プライバシーポリシー</a> に同意したことになります。 change_password: セキュリティ @@ -227,6 +240,7 @@ ja: delete_account_html: アカウントを削除したい場合、<a href="%{path}">こちら</a> から手続きが行えます。削除する前に、確認画面があります。 didnt_get_confirmation: 確認メールを受信できませんか? forgot_password: パスワードをお忘れですか? + invalid_reset_password_token: パスワードリセットトークンが正しくないか期限切れです。もう一度リクエストしてください。 login: ログイン logout: ログアウト register: 登録する @@ -273,7 +287,7 @@ ja: content: セキュリティ認証に失敗しました。Cookieをブロックしていませんか? title: セキュリティ認証に失敗 '429': リクエストの制限に達しました。 - noscript: Mastodonのウェブアプリケーションを利用する場合はJavaScriptを有効にしてください。またはあなたのプラットフォーム向けのMastodonネイティブアプリを探すことができます。 + noscript_html: Mastodonのウェブアプリケーションを利用する場合はJavaScriptを有効にしてください。またはあなたのプラットフォーム向けの<a href="https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md">Mastodonネイティブアプリ</a>を探すことができます。 exports: blocks: ブロック csv: CSV @@ -338,6 +352,17 @@ ja: reblog: body: 'あなたのトゥートが %{name} さんにブーストされました:' subject: あなたのトゥートが %{name} さんにブーストされました + number: + human: + decimal_units: + format: "%n%u" + units: + billion: B + million: M + quadrillion: Q + thousand: K + trillion: T + unit: '' pagination: next: 次 prev: 前 @@ -356,9 +381,6 @@ ja: title: "%{name} さんから返信がありました" reblog: title: あなたのトゥートが %{name} さんにブーストされました - subscribed: - body: あなたはプッシュ通知を受け取ることが出来ます - title: Subscription が登録されました remote_follow: acct: あなたの ユーザー名@ドメイン を入力してください missing_resource: リダイレクト先が見つかりませんでした @@ -407,6 +429,7 @@ ja: authorized_apps: 認証済みアプリ back: Mastodon に戻る delete: アカウントの削除 + development: 開発 edit_profile: プロフィールを編集 export: データのエクスポート followers: 信頼済みのインスタンス @@ -414,9 +437,14 @@ ja: preferences: ユーザー設定 settings: 設定 two_factor_authentication: 二段階認証 + your_apps: アプリ statuses: open_in_web: Webで開く over_character_limit: 上限は %{max}文字までです + pin_errors: + ownership: 他人のトゥートを固定することはできません + private: 非公開のトゥートを固定することはできません + reblog: ブーストされたトゥートを固定することはできません show_more: もっと見る visibilities: private: 非公開 @@ -427,6 +455,7 @@ ja: unlisted_long: 誰でも見ることができますが、公開タイムラインには表示されません stream_entries: click_to_show: クリックして表示 + pinned: 固定されたトゥート reblogged: さんにブーストされました sensitive_content: 閲覧注意 terms: diff --git a/config/locales/ko.yml b/config/locales/ko.yml index f3bde5bbb..6fdc3b985 100644 --- a/config/locales/ko.yml +++ b/config/locales/ko.yml @@ -1,29 +1,52 @@ --- ko: about: - about_mastodon_html: Mastodon 은<em>자유로운 오픈 소스</em>소셜 네트워크입니다. 상용 플랫폼의 대체로써 <em>분산형 구조</em>를 채택해, 여러분의 대화가 한 회사에 독점되는 것을 방지합니다. 신뢰할 수 있는 인스턴스를 선택하세요 — 어떤 인스턴스를 고르더라도, 누구와도 대화할 수 있습니다. 누구나 자신만의 Mastodon 인스턴스를 만들 수 있으며, Seamless하게 <em>소셜 네트워크</em>에 참가할 수 있습니다. + about_mastodon_html: Mastodon은 <em>오픈 소스 기반의</em> 소셜 네트워크 서비스 입니다. 상용 플랫폼의 대체로서 <em>분산형 구조</em>를 채택해, 여러분의 대화가 한 회사에 독점되는 것을 방지합니다. 신뢰할 수 있는 인스턴스를 선택하세요 — 어떤 인스턴스를 고르더라도, 누구와도 대화할 수 있습니다. 누구나 자신만의 Mastodon 인스턴스를 만들 수 있으며, Seamless하게 <em>소셜 네트워크</em>에 참가할 수 있습니다. about_this: 이 인스턴스에 대해서 closed_registrations: 현재 이 인스턴스에서는 신규 등록을 받고 있지 않습니다. contact: 연락처 - description_headline: "%{domain} 는 무엇인가요?" + contact_missing: 미설정 + contact_unavailable: N/A + description_headline: "%{domain} (은)는 무엇인가요?" domain_count_after: 개의 인스턴스 - domain_count_before: 연결됨 + domain_count_before: 연결된 + extended_description_html: | + <h3>룰을 작성하는 장소</h3> + <p>아직 설명이 작성되지 않았습니다.</p> + features: + humane_approach_body: 다른 SNS의 실패를 교훈삼아, Mastodon은 소셜미디어가 잘못 사용되는 것을 막기 위하여 윤리적인 설계를 추구합니다. + humane_approach_title: 보다 배려를 의식한 설계를 추구 + not_a_product_body: Mastodon은 이익을 추구하는 SNS가 아닙니다. 그러므로 광고와 데이터의 수집 및 분석이 존재하지 않고, 유저를 구속하지도 않습니다. + not_a_product_title: 여러분은 사람이며, 상품이 아닙니다. + real_conversation_body: 자유롭게 사용할 수 있는 500문자의 메세지와 미디어 경고 내용을 바탕으로, 자기자신을 자유롭게 표현할 수 있습니다. + real_conversation_title: 진정한 커뮤니케이션을 위하여 + within_reach_body: 개발자 친화적인 API에 의해서 실현된 iOS나 Android, 그 외의 여러 Platform들 덕분에 어디서든 친구들과 자유롭게 메세지를 주고 받을 수 있습니다. + within_reach_title: 언제나 유저의 곁에서 + find_another_instance: 다른 인스턴스 찾기 + generic_description: "%{domain} 은 Mastodon의 인스턴스 입니다." + hosted_on: Mastodon hosted on %{domain} + learn_more: 자세히 other_instances: 다른 인스턴스 source_code: 소스 코드 status_count_after: Toot status_count_before: Toot 수 user_count_after: 명 user_count_before: 사용자 수 + what_is_mastodon: Mastodon이란? accounts: follow: 팔로우 followers: 팔로워 following: 팔로잉 + media: 미디어 nothing_here: 아무 것도 없습니다. people_followed_by: "%{name} 님이 팔로우 중인 계정" people_who_follow: "%{name} 님을 팔로우 중인 계정" - posts: 포스트 + posts: Toot + posts_with_replies: Toot와 답장 remote_follow: 리모트 팔로우 reserved_username: 이 아이디는 예약되어 있습니다. + roles: + admin: Admin unfollow: 팔로우 해제 admin: accounts: @@ -38,6 +61,7 @@ ko: feed_url: 피드 URL followers: 팔로워 수 follows: 팔로잉 수 + inbox_url: Inbox URL ip: IP location: all: 전체 @@ -57,8 +81,10 @@ ko: alphabetic: 알파벳 순 most_recent: 최근 활동 순 title: 순서 + outbox_url: Outbox URL perform_full_suspension: 완전히 정지시키기 profile_url: 프로필 URL + protocol: Protocol public: 전체 공개 push_subscription_expires: PuSH 구독 기간 만료 redownload: 아바타 업데이트 @@ -90,12 +116,14 @@ ko: hint: 도메인 차단은 내부 데이터베이스에 계정이 생성되는 것까지는 막을 수 없지만, 그 도메인에서 생성된 계정에 자동적으로 특정한 모더레이션을 적용하게 할 수 있습니다. severity: desc_html: "<strong>침묵</strong>은 계정을 팔로우 하지 않고 있는 사람들에겐 계정의 Toot을 보이지 않게 합니다. <strong>정지</strong>는 계정의 컨텐츠, 미디어, 프로필 데이터를 삭제합니다." + noop: 없음 silence: 침묵 suspend: 정지 title: 새로운 도메인 차단 reject_media: 미디어 파일 거부하기 reject_media_hint: 로컬에 저장된 미디어 파일을 삭제하고, 이후로도 다운로드를 거부합니다. 정지하고는 관계 없습니다. severities: + noop: 없음 silence: 침묵 suspend: 정지 severity: 심각도 @@ -146,16 +174,41 @@ ko: closed_message: desc_html: 신규 등록을 받지 않을 때 프론트 페이지에 표시됩니다. <br>HTML 태그를 사용할 수 있습니다. title: 신규 등록 정지 시 메시지 + deletion: + desc_html: 유저가 자신의 계정을 삭제할 수 있도록 설정합니다. + title: 계정 삭제를 허가함 open: - title: 신규 등록을 받음 + desc_html: 유저가 자신의 계정을 생성할 수 있도록 설정합니다. + title: 신규 계정 등록을 받음 site_description: desc_html: 탑 페이지와 meta 태그에 사용됩니다.<br>HTML 태그, 예를 들어<code><a></code> 태그와 <code><em></code> 태그를 사용할 수 있습니다. title: 사이트 설명 site_description_extended: desc_html: 인스턴스 정보 페이지에 표시됩니다.<br>HTML 태그를 사용할 수 있습니다. title: 사이트 상세 설명 + site_terms: + desc_html: 당신은 독자적인 개인정보 취급 방침이나 이용약관, 그 외의 법적 근거를 작성할 수 있습니다. 또한 HTML태그를 사용할 수 있습니다. + title: 커스텀 서비스 이용 약관 site_title: 사이트 이름 + timeline_preview: + desc_html: Landing page에 공개 타임라인을 표시합니다. + title: 타임라인 프리뷰 title: 사이트 설정 + statuses: + back_to_account: 계정으로 돌아가기 + batch: + delete: 삭제 + nsfw_off: NSFW 끄기 + nsfw_on: NSFW 켜기 + execute: 실행 + failed_to_execute: 실행이 실패하였습니다. + media: + hide: 미디어 숨기기 + show: 미디어 보여주기 + title: 미디어 + no_media: 미디어 없음 + title: 계정 Toot + with_media: 미디어 있음 subscriptions: callback_url: 콜백 URL confirmed: 확인됨 @@ -173,13 +226,21 @@ ko: signature: Mastodon %{instance} 인스턴스로에서 알림 view: 'View:' applications: + created: 어플리케이션이 작성되었습니다. + destroyed: 어플리케이션이 삭제되었습니다. invalid_url: 올바르지 않은 URL입니다 + regenerate_token: 토큰 재생성 + token_regenerated: 액세스 토큰이 재생성되었습니다. + warning: 이 데이터는 다른 사람들과 절대로 공유하지 마세요. + your_token: 액세스 토큰 auth: + agreement_html: 이 등록으로 <a href="%{rules_path}">이용규약</a> 과 <a href="%{terms_path}">개인정보 취급 방침</a>에 동의하는 것으로 간주됩니다. change_password: 보안 delete_account: 계정 삭제 delete_account_html: 계정을 삭제하고 싶은 경우, <a href="%{path}">여기서</a> 삭제할 수 있습니다. 삭제 전 확인 화면이 표시됩니다. didnt_get_confirmation: 확인 메일을 받지 못하셨습니까? forgot_password: 비밀번호를 잊어버리셨습니까? + invalid_reset_password_token: 비밀번호 리셋 토큰이 올바르지 못하거나 기간이 만료되었습니다. 다시 요청해주세요. login: 로그인 logout: 로그아웃 register: 등록하기 @@ -189,6 +250,12 @@ ko: authorize_follow: error: 리모트 팔로우 도중 오류가 발생했습니다. follow: 팔로우 + follow_request: '당신은 다음 계정에 팔로우 신청을 했습니다:' + following: '성공! 당신은 다음 계정을 팔로우 하고 있습니다:' + post_follow: + close: 혹은, 당신은 이 윈도우를 닫을 수 있습니다 + return: 유저 프로필로 돌아가기 + web: 웹으로 가기 title: "%{acct} 를 팔로우" datetime: distance_in_words: @@ -220,7 +287,7 @@ ko: content: 보안 인증에 실패했습니다. Cookie를 차단하고 있진 않습니까? title: 보안 인증 실패 '429': 요청 횟수 제한에 도달했습니다. - noscript: Mastodon을 사용하기 위해서는 JavaScript를 켜 주십시오. + noscript_html: Mastodon을 사용하기 위해서는 JavaScript를 켜 주십시오. exports: blocks: 차단 csv: CSV @@ -271,8 +338,8 @@ ko: one: "1건의 새로운 알림 \U0001F418" other: "%{count}건의 새로운 알림 \U0001F418" favourite: - body: "%{name} 님이 내 Toot을 즐겨찾기에 등록했습니다." - subject: "%{name} 님이 내 Toot을 즐겨찾기에 등록했습니다" + body: "%{name} 님이 내 Toot를 즐겨찾기에 등록했습니다." + subject: "%{name} 님이 내 Toot를 즐겨찾기에 등록했습니다" follow: body: "%{name} 님이 나를 팔로우 했습니다" subject: "%{name} 님이 나를 팔로우 했습니다" @@ -285,10 +352,35 @@ ko: reblog: body: "%{name} 님이 내 Toot을 부스트 했습니다:" subject: "%{name} 님이 내 Toot을 부스트 했습니다" + number: + human: + decimal_units: + format: "%n%u" + units: + billion: B + million: M + quadrillion: Q + thousand: K + trillion: T + unit: '' pagination: next: 다음 prev: 이전 truncate: "…" + push_notifications: + favourite: + title: "%{name} 님이 당신의 Toot를 즐겨찾기에 등록했습니다." + follow: + title: "%{name} 님이 나를 팔로우 하고 있습니다." + group: + title: "%{count} 건의 알림" + mention: + action_boost: 부스트 + action_expand: 더보기 + action_favourite: 즐겨찾기 + title: "%{name} 님이 답장을 보냈습니다" + reblog: + title: "%{name} 님이 당신의 Toot를 부스트 했습니다." remote_follow: acct: 아이디@도메인을 입력해 주십시오 missing_resource: 리디렉션 대상을 찾을 수 없습니다 @@ -330,11 +422,14 @@ ko: windows: Windows windows_mobile: Windows Mobile windows_phone: Windows Phone + revoke: 삭제 + revoke_success: 세션이 삭제되었습니다. title: 세션 settings: authorized_apps: 인증된 어플리케이션 back: 돌아가기 delete: 계정 삭제 + development: 개발 edit_profile: 프로필 편집 export: 데이터 내보내기 followers: 신뢰 중인 인스턴스 @@ -342,9 +437,14 @@ ko: preferences: 사용자 설정 settings: 설정 two_factor_authentication: 2단계 인증 + your_apps: 애플리케이션 statuses: open_in_web: Web으로 열기 over_character_limit: 최대 %{max}자까지 입력할 수 있습니다 + pin_errors: + ownership: 다른 사람의 Toot는 고정될 수 없습니다. + private: 비공개 Toot는 고정될 수 없습니다. + reblog: 부스트는 고정될 수 없습니다. show_more: 더 보기 visibilities: private: 비공개 @@ -355,8 +455,11 @@ ko: unlisted_long: 누구나 볼 수 있지만, 공개 타임라인에는 표시되지 않습니다 stream_entries: click_to_show: 클릭해서 표시 + pinned: 고정된 Toot reblogged: 님이 부스트 했습니다 sensitive_content: 민감한 컨텐츠 + terms: + title: "%{instance} 이용약관과 개인정보 취급 방침" time: formats: default: "%Y년 %m월 %d일 %H:%M" @@ -379,3 +482,4 @@ ko: users: invalid_email: 메일 주소가 올바르지 않습니다 invalid_otp_token: 2단계 인증 코드가 올바르지 않습니다 + signed_in_as: '다음과 같이 로그인 중:' diff --git a/config/locales/nl.yml b/config/locales/nl.yml index 6562767a9..2b7a1a511 100644 --- a/config/locales/nl.yml +++ b/config/locales/nl.yml @@ -272,7 +272,7 @@ nl: content: Veiligheidsverificatie mislukt. Blokkeer je toevallig cookies? title: Veiligheidsverificatie mislukt '429': Te veel verbindingsaanvragen. - noscript: Schakel JavaScript in om de webapplicatie van Mastodon te gebruiken. Als alternatief kan je een Mastodon-app zoeken voor jouw platform. + noscript_html: Schakel JavaScript in om de webapplicatie van Mastodon te gebruiken. Als alternatief kan je een <a href="https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md">Mastodon-app</a> zoeken voor jouw platform. exports: blocks: Jij blokkeert csv: CSV @@ -337,6 +337,17 @@ nl: reblog: body: 'Jouw toot werd door %{name} geboost:' subject: "%{name} boostte jouw toot" + number: + human: + decimal_units: + format: "%n%u" + units: + billion: B + million: M + quadrillion: Q + thousand: K + trillion: T + unit: '' pagination: next: Volgende prev: Vorige @@ -353,9 +364,6 @@ nl: title: "%{name} vermeldde jou" reblog: title: "%{name} boostte jouw toot" - subscribed: - body: Je kan nu pushmeldingen ontvangen. - title: Aanmelding bevestigd! remote_follow: acct: Geef jouw account@domein.tld op waarvandaan je wilt volgen missing_resource: Kon vereiste doorverwijzings-URL voor jouw account niet vinden diff --git a/config/locales/no.yml b/config/locales/no.yml index 996ea1d97..207f86afc 100644 --- a/config/locales/no.yml +++ b/config/locales/no.yml @@ -257,6 +257,17 @@ reblog: body: 'Din status ble fremhevd av %{name}:' subject: "%{name} fremhevde din status" + number: + human: + decimal_units: + format: "%n%u" + units: + billion: B + million: M + quadrillion: Q + thousand: K + trillion: T + unit: '' pagination: next: Neste prev: Forrige diff --git a/config/locales/oc.yml b/config/locales/oc.yml index 6c3f95823..c3807428b 100644 --- a/config/locales/oc.yml +++ b/config/locales/oc.yml @@ -7,7 +7,7 @@ oc: contact: Contacte contact_missing: Pas parametrat contact_unavailable: Pas disponible - description_headline: Qué es %{domain} ? + description_headline: Qué es %{domain} ? domain_count_after: autras instàncias domain_count_before: Connectat a extended_description_html: | @@ -32,21 +32,25 @@ oc: status_count_before: qu’an escrich user_count_after: personas user_count_before: Ostal de - what_is_mastodon: Qu’es Mastodon ? + what_is_mastodon: Qu’es Mastodon ? accounts: follow: Sègre followers: Seguidors following: Abonaments - nothing_here: I a pas res aquí ! + media: Mèdias + nothing_here: I a pas res aquí ! people_followed_by: Lo mond que %{name} sèc people_who_follow: Lo mond que sègon %{name} - posts: Estatuts + posts: Tuts + posts_with_replies: Tuts amb responsas remote_follow: Sègre a distància reserved_username: Aqueste nom d’utilizaire es reservat + roles: + admin: Admin unfollow: Quitar de sègre admin: accounts: - are_you_sure: Sètz segur ? + are_you_sure: Sètz segur ? confirm: Confirmar confirmed: Confirmat disable_two_factor_authentication: Desactivar 2FA @@ -57,6 +61,7 @@ oc: feed_url: Flux URL followers: Seguidors follows: Abonaments + inbox_url: URL de recepcion ip: IP location: all: Tot @@ -76,8 +81,10 @@ oc: alphabetic: Alfabetic most_recent: Mai recent title: Ordre + outbox_url: URL Outbox perform_full_suspension: Botar en tren la suspension complèta profile_url: URL del perfil + protocol: Protocòl public: Public push_subscription_expires: Fin de l’abonament PuSH redownload: Actualizar los avatars @@ -96,6 +103,7 @@ oc: title: Comptes undo_silenced: Levar lo silenci undo_suspension: Levar la suspension + unsubscribe: Se desabonar username: Nom d’utilizaire web: Web domain_blocks: @@ -135,8 +143,8 @@ oc: domain_name: Domeni title: Instàncias conegudas reports: - action_taken_by: Accion menada per - are_you_sure: Es segur ? + action_taken_by: Mesura menada per + are_you_sure: Es segur ? comment: label: Comentari none: Pas cap @@ -214,31 +222,37 @@ oc: body: "%{reporter} a senhalat %{target}" subject: Novèl senhalament per %{instance} (#%{id}) application_mailer: - salutation: '%{name},' - settings: 'Cambiar las preferéncias de corrièl : %{link}' + salutation: "%{name}," + settings: 'Cambiar las preferéncias de corrièl : %{link}' signature: Notificacion de Mastodon sus %{instance} - view: 'Veire :' + view: 'Veire :' applications: + created: Aplicacion ben creada + destroyed: Aplication ben suprimida invalid_url: L’URL donada es invalida + regenerate_token: Tornar generar lo geton d’accès + token_regenerated: Geton d’accès ben regenerat + warning: Mèfi ! Agachatz de partejar aquela donada amb degun ! + your_token: Vòstre geton d’accès auth: agreement_html: En vos marcar acceptatz <a href="%{rules_path}">nòstres tèrmes de servici</a> e <a href="%{terms_path}">politica de confidencialitat</a>. change_password: Seguretat delete_account: Suprimir lo compte delete_account_html: Se volètz suprimir vòstre compte, podètz <a href="%{path}">o far aquí</a>. Vos demandarem que confirmetz. - didnt_get_confirmation: Avètz pas recebut las instruccions de confirmacion ? - forgot_password: Senhal oblidat ? + didnt_get_confirmation: Avètz pas recebut las instruccions de confirmacion ? + forgot_password: Senhal oblidat ? + invalid_reset_password_token: Lo geton de reïnicializacion es invalid o acabat. Tornatz demandar un geton se vos plai. login: Se connectar logout: Se desconnectar register: Se marcar resend_confirmation: Tornar mandar las instruccions de confirmacion reset_password: Reïnicializar lo senhal set_new_password: Picar un nòu senhal - invalid_reset_password_token: Ligam de reïnicializacion invalid o acabat. Tornatz ensajar se vos plai. authorize_follow: error: O planhèm, i a agut una error al moment de cercar lo compte follow: Sègre - follow_request: 'Avètz demandat de sègre :' - following: 'Felicitacion ! Seguètz ara :' + follow_request: 'Avètz demandat de sègre :' + following: 'Felicitacion ! Seguètz ara :' post_follow: close: O podètz tampar aquesta fenèstra. return: Tornar al perfil @@ -258,11 +272,11 @@ oc: - gen - feb - mar - - mai + - abr - mai - jun - jul - - ag + - ago - set - oct - nov @@ -299,26 +313,45 @@ oc: - :year datetime: distance_in_words: - about_x_hours: Fa %{count} oras - about_x_months: Fa %{count} meses + about_x_hours: + one: Fa una ora + other: Fa %{count} oras + about_x_months: + one: Fa un mes + other: Fa %{count} meses about_x_years: one: Fa un an other: Fa %{count} ans almost_x_years: - one: Fa un an - other: Fa %{count} ans + one: Fa quasi un an + other: Fa quasi %{count} ans half_a_minute: Ara - less_than_x_minutes: Fa %{count} minutas - less_than_x_seconds: Ara + less_than_x_minutes: + one: Fa mens d’una minuta + other: Fa mens de %{count} minutas + less_than_x_seconds: + one: Fa mens d’una segonda + other: Fa mens de %{count} segondas over_x_years: + one: Fa mai d’un an + other: Fa mai de %{count} ans + x_days: + one: Fa un jorn + other: Fa %{count} jorns + x_minutes: + one: Fa una minuta + other: Fa %{count} minutas + x_months: + one: Fa un mes + other: Fa %{count} meses + x_seconds: + one: Fa una segonda + other: Fa %{count} segondas + x_years: one: Fa un an other: Fa %{count} ans - x_days: Fa %{count} jorns - x_minutes: Fa %{count} minutas - x_months: Fa %{count} meses - x_seconds: Fa %{count} segondas deletes: - bad_password_msg: Ben ensajat pirata ! Senhal incorrècte + bad_password_msg: Ben ensajat pirata ! Senhal incorrècte confirm_password: Picatz vòstre senhal actual per verificar vòstra identitat description_html: Aquò suprimirà <strong>definitivament e sens possibilitat de retorn</strong> lo contengut de vòstre compte e lo desactivarà. Lo nom d’utilizaire serà gardat per evitar una futura impostura. proceed: Suprimir lo compte @@ -330,10 +363,10 @@ oc: '404': La pagina que recercatz existís pas. '410': La pagina que cercatz existís pas mai. '422': - content: Verificacion de seguretat fracassada. Blocatz los cookies ? + content: Verificacion de seguretat fracassada. Blocatz los cookies ? title: Verificacion de seguretat fracassada '429': Lo servidor mòla (subrecargada) - noscript: Per utilizar l’aplicacion web de Mastodon, mercés d’activar JavaScript. O podètz utilizar una aplicacion per vòstra plataforma coma alernativa. + noscript_html: Per utilizar l’aplicacion web de Mastodon, mercés d’activar JavaScript. O podètz utilizar <a href="https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md">una aplicacion</a> per vòstra plataforma coma alernativa. exports: blocks: Personas que blocatz csv: CSV @@ -347,18 +380,18 @@ oc: lock_link: Clavar vòstre compte purge: Tirar dels seguidors success: - one: Soi a blocar los seguidors d’un domeni... - other: Soi a blocar los seguidors de %{count} domenis... + one: Soi a blocar los seguidors d’un domeni… + other: Soi a blocar los seguidors de %{count} domenis… true_privacy_html: Mèfi que la <strong>vertadièra confidencialitat pòt solament èsser amb un chiframent del cap a la fin (end-to-end)</strong>. unlocked_warning_html: Tot lo mond pòt vos sègre e veire sulpic vòstres estatuts privats. %{lock_link} per poder repassar e regetar los seguidors. unlocked_warning_title: Vòstre compte es pas clavat generic: - changes_saved_msg: Cambiaments ben realizats ! + changes_saved_msg: Cambiaments ben realizats ! powered_by: propulsat per %{link} save_changes: Salvagardar los cambiaments validation_errors: - one: I a quicòm que truca ! Mercés de corregir l’error çai-jos - other: I a quicòm que truca ! Mercés de corregir las %{count} errors çai-jos + one: I a quicòm que truca ! Mercés de corregir l’error çai-jos + other: I a quicòm que truca ! Mercés de corregir las %{count} errors çai-jos imports: preface: Podètz importar qualques donadas coma lo mond que seguètz o blocatz a-n aquesta instància d’un fichièr creat d’una autra instància. success: Vòstras donadas son ben estadas mandadas e seràn tractadas tre que possible @@ -376,28 +409,39 @@ oc: notification_mailer: digest: body: 'Trobatz aquí un resumit de çò qu’avètz mancat dempuèi vòstra darrièra visita lo %{since}:' - mention: "%{name} vos a mencionat dins :" + mention: "%{name} vos a mencionat dins :" new_followers_summary: - one: Avètz un nòu seguidor ! Ouà ! - other: Avètz %{count} nòus seguidors ! Qué crane ! + one: Avètz un nòu seguidor ! Ouà + other: Avètz %{count} nòus seguidors ! Qué crane ! subject: one: "Una nòva notificacion dempuèi vòstra darrièra visita \U0001F418" other: "%{count} nòvas notificacions dempuèi vòstra darrièra visita \U0001F418" favourite: - body: "%{name} a mes vòstre estatut en favorit :" + body: "%{name} a mes vòstre estatut en favorit :" subject: "%{name} a mes vòstre estatut en favorit" follow: - body: "%{name} vos sèc ara !" + body: "%{name} vos sèc ara !" subject: "%{name} vos sèc ara" follow_request: body: "%{name} a demandat a vos sègre" - subject: 'Demanda d’abonament : %{name}' + subject: 'Demanda d’abonament : %{name}' mention: - body: "%{name} vos a mencionat dins :" + body: "%{name} vos a mencionat dins :" subject: "%{name} vos a mencionat" reblog: - body: "%{name} a tornat partejar vòstre estatut :" + body: "%{name} a tornat partejar vòstre estatut :" subject: "%{name} a tornat partejar vòstre estatut" + number: + human: + decimal_units: + format: "%n%u" + units: + billion: B + million: M + quadrillion: Q + thousand: K + trillion: T + unit: '' pagination: next: Seguent prev: Precedent @@ -416,14 +460,11 @@ oc: title: "%{name} vos a mencionat" reblog: title: "%{name} a partejat vòstre estatut" - subscribed: - body: Podètz ara recebre las notificacions push. - title: Abonament enregistrat ! remote_follow: acct: Picatz vòstre utilizaire@instància que cal utilizar per sègre aqueste utilizaire missing_resource: URL de redireccion pas trobada proceed: Contunhatz per sègre - prompt: 'Sètz per sègre :' + prompt: 'Sètz per sègre :' sessions: activity: Darrièra activitat browser: Navigator @@ -467,6 +508,7 @@ oc: authorized_apps: Aplicacions autorizadas back: Tornar a Mastodon delete: Supression de compte + development: Desvolopament edit_profile: Modificar lo perfil export: Export donadas followers: Seguidors autorizats @@ -474,9 +516,14 @@ oc: preferences: Preferéncias settings: Paramètres two_factor_authentication: Autentificacion en dos temps + your_apps: Vòstras aplicacions statuses: open_in_web: Dobrir sul web over_character_limit: limit de %{max} caractèrs passat + pin_errors: + ownership: Se pòt pas penjar lo tut de qualqu’un mai + private: Se pòt pas penjar los tuts pas publics + reblog: Se pòt pas penjar un tut partejat show_more: Ne veire mai visibilities: private: Seguidors solament @@ -486,22 +533,23 @@ oc: unlisted: Pas listat unlisted_long: Tot lo mond pòt veire mai serà pas visible sul flux public stream_entries: - click_to_show: Clicatz per afichar + click_to_show: Clicatz per veire + pinned: Tut penjat reblogged: a partejat sensitive_content: Contengut sensible terms: body_html: | <h2>Politica de confidencialitat</h2> - <h3 id="collect">Quinas informacions collectem ?</h3> + <h3 id="collect">Quinas informacions reculhèm ?</h3> - <p>Collectem informacions sus vos quand vos marcatz sus nòstre site e juntem las donadas quand participatz a nòstre forum en legissent, escrivent e notant lo contengut partejat aquí.</p> + <p>Collectem informacions sus vos quand vos marcatz sus nòstre site e juntem las donadas quand participatz a nòstre forum en legir, escriure e notar lo contengut partejat aquí.</p> <p>Pendent l’inscripcion podèm vos demandar vòstre nom e adreça de corrièl. Podètz çaquelà visitar nòstre site sens vos marcar. Verificarem vòstra adreça amb un messatge donant un ligam unic. Se clicatz sul ligam sauprem qu’avètz lo contraròtle de l’adreça.</p> <p>Quand sètz marcat e que publicatz quicòm, enregistrem l’adreça IP d’origina. Podèm tanben salvagardar los jornals del servidor que tenon l’adreça IP de totas las demandas fachas al nòstre servidor.</p> - <h3 id="use">Qué fasèm de vòstras informacions ?</h3> + <h3 id="use">Qué fasèm de vòstras informacions ?</h3> <p>Totas las informacions que collectem de vos pòdon servir dins los cases seguents :</p> @@ -512,26 +560,26 @@ oc: <li>Per enviar periodicament de corrièls — Podèm utilizar l’adreça qu’avètz donada per vos enviar d’informacions e de notificacions que demandatz tocant de cambiaments dins los subjèctes del forum o en responsa a vòstre nom d’utilizaire, en responsa a una demanda, e/o tota autra question.</li> </ul> - <h3 id="protect">Cossí protegèm vòstras informacions ?</h3> + <h3 id="protect">Cossí protegèm vòstras informacions ?</h3> <p>Apliquem tota una mena de mesuras de seguretat per manténer la fisança de vòstras informacions personalas quand las picatz, mandatz, o i accedètz.</p> - <h3 id="data-retention">Quala es vòstra politica de conservacion de donadas ?</h3> + <h3 id="data-retention">Quala es vòstra politica de conservacion de donadas ?</h3> - <p>Farem esfòrces per :</p> + <p>Farem esfòrces per :</p> <ul> <li>Gardar los jornals del servidor que contenon las adreças IP de totas las demandas al servidor pas mai de 90 jorns.</li> <li>Gardar las adreças IP ligadas als utilizaires e lors publicacions pas mai de 5 ans.</li> </ul> - <h3 id="cookies">Empleguem de cookies ?</h3> + <h3 id="cookies">Empleguem de cookies ?</h3> - <p>Òc-ben. Los cookies son de pichons fichièrs qu’un site o sos forneires de servicis plaçan dins lo disc dur de vòstre ordenador via lo navigator Web (Se los acceptatz). Aqueles cookies permeton al site de reconéisser vòstre navigator e se tenètz un compte enregistrat de l’associar a vòstre compte.</p> + <p>Òc-ben. Los cookies son de pichons fichièrs qu’un site o sos provesidors de servicis plaçan dins lo disc dur de vòstre ordenador via lo navigator Web (Se los acceptatz). Aqueles cookies permeton al site de reconéisser vòstre navigator e se tenètz un compte enregistrat de l’associar a vòstre compte.</p> - <p>Empleguem de cookies per comprendre e enregistrar vòstras preferéncias per vòstras visitas venentas, per recampar de donadas sul trafic del site e las interaccions per dire que posquem ofrir una melhora experiéncia del site e de las aisinas pel futur. Pòt arribar que contractèssem amb de forneires de servicis tèrces per nos ajudar a comprendre melhor nòstres visitors. Aqueles forneires an pas lo drech que d’utilizar las donadas collectadas per nos ajudar a menar e melhorar nòstre afar.</p> + <p>Empleguem de cookies per comprendre e enregistrar vòstras preferéncias per vòstras visitas venentas, per recampar de donadas sul trafic del site e las interaccions per dire que posquem ofrir una melhora experiéncia del site e de las aisinas pel futur. Pòt arribar que contractèssem amb de provesidors de servicis tèrces per nos ajudar a comprendre melhor nòstres visitors. Aqueles provesidors an pas lo drech que d’utilizar las donadas collectadas per nos ajudar a menar e melhorar nòstre afar.</p> - <h3 id="disclose">Divulguem d’informacions a de tèrces ?</h3> + <h3 id="disclose">Divulguem d’informacions a de tèrces ?</h3> <p>Vendèm pas, comercem o qualque transferiment que siasque a de tèrces vòstras informacions personalas identificablas. Aquò inclutz pas los tèrces partits de confisança que nos assiston a menar nòstre site, menar nòstre afar o vos servir, baste que son d’acòrd per gardar aquelas informacions confidencialas. Pòt tanben arribar que liberèssem vòstras informacions quand cresèm qu’es apropriat d’o far per se sometre a la lei, per refortir nòstras politicas, o per protegir los dreches, proprietats o seguritat de qualqu’un o de nosautres. Pasmens es possible que mandèssem d’informacions non-personalas e identificablas de nòstres visitors a d’autres partits per d’utilizacion en marketing, publicitat o un emplec mai.</p> @@ -567,17 +615,18 @@ oc: description_html: S’activatz <strong> l’autentificacion two-factor</strong>, vos caldrà vòstre mobil per vos connectar perque generarà un geton per vos daissar dintrar. disable: Desactivar enable: Activar - enabled_success: Autentificacion en dos temps Two-factor ben activada + enabled: Autentificacion en dos temps activada + enabled_success: L’autentificacion en dos temps es ben activada generate_recovery_codes: Generar los còdis de recuperacion instructions_html: "<strong>Escanatz aqueste còdi QR amb Google Authenticator o una aplicacion similària sus vòstre mobil</strong>. A partir d’ara, aquesta aplicacion generarà un geton que vos caldrà picar per vos connectar." lost_recovery_codes: Los còdi de recuperacion vos permeton d’accedir a vòstre compte se perdètz vòstre mobil. S’avètz perdut vòstres còdis de recuperacion los podètz tornar generar aquí. Los ancians còdis seràn pas mai valides. - manual_instructions: 'Se podètz pas numerizar lo còdi QR e que vos cal picar lo còdi a la man, vaquí lo còdi en clar :' + manual_instructions: 'Se podètz pas numerizar lo còdi QR e que vos cal picar lo còdi a la man, vaquí lo còdi en clar :' recovery_codes: Salvar los còdis de recuperacion recovery_codes_regenerated: Los còdis de recuperacion son ben estats tornats generar recovery_instructions_html: Se vos arriba de perdre vòstre mobil, podètz utilizar un dels còdis de recuperacion cai-jos per poder tornar accedir a vòstre compte. Gardatz los còdis en seguretat, per exemple, imprimissètz los e gardatz los amb vòstres documents importants. setup: Paramètres - wrong_code: Lo còdi picat es invalid ! L’ora es la bona sul servidor e lo mobil ? + wrong_code: Lo còdi picat es invalid ! L’ora es la bona sul servidor e lo mobil ? users: invalid_email: L’adreça de corrièl es invalida invalid_otp_token: Còdi d’autentificacion en dos temps invalid - signed_in_as: 'Session a' + signed_in_as: Session a diff --git a/config/locales/pl.yml b/config/locales/pl.yml index 415c3b993..842baef45 100644 --- a/config/locales/pl.yml +++ b/config/locales/pl.yml @@ -18,7 +18,7 @@ pl: humane_approach_title: Bardziej ludzkie podejście not_a_product_body: Mastodon nie jest komercyjną siecią. Nie doświadczysz tu reklam, zbierania danych, ani centralnego ośrodka, tak jak w przypadku wielu rozwiązań. not_a_product_title: Jesteś człowiekiem, nie produktem - real_conversation_body: Mając do dyspozycji 500 znaków na post, rozdrobnienie zawartości i ostrzeżenia o multimediach, możesz wyrażać siebie na wszystkie możliwe sposoby. + real_conversation_body: Mając do dyspozycji 500 znaków na wpis, rozdrobnienie zawartości i ostrzeżenia o multimediach, możesz wyrażać siebie na wszystkie możliwe sposoby. real_conversation_title: Zaprojektowany do prawdziwych rozmów within_reach_body: Wiele aplikacji dla Androida, iOS i innych platform dzięki przyjaznemu programistom API sprawia, że możesz utrzymywać kontakt ze znajomymi praktycznie wszędzie. within_reach_title: Zawsze w Twoim zasięgu @@ -37,12 +37,16 @@ pl: follow: Śledź followers: Śledzących following: Śledzi + media: Zawartość multimedialna nothing_here: Niczego tu nie ma! people_followed_by: Konta śledzone przez %{name} people_who_follow: Osoby, które śledzą konto %{name} posts: Wpisy - remote_follow: Zdalne śledzenie + posts_with_replies: Wpisy z odpowiedziami + remote_follow: Śledź zdalnie reserved_username: Ta nazwa użytkownika jest zarezerwowana. + roles: + admin: Administrator unfollow: Przestań śledzić admin: accounts: @@ -65,12 +69,12 @@ pl: title: Położenie media_attachments: Załączniki multimedialne moderation: - all: Wszystko + all: Wszystkie silenced: Wyciszone suspended: Zawieszone title: Moderacja most_recent_activity: Najnowsza aktywność - most_recent_ip: Najnowsze IP + most_recent_ip: Ostatnie IP not_subscribed: Nie zasubskrybowano order: alphabetic: Alfabetycznie @@ -88,9 +92,9 @@ pl: search: Szukaj show: created_reports: Zgłoszenia tego użytkownika - report: zgłoszenie + report: zgłoszeń targeted_reports: Zgłoszenia dotyczące tego użytkownika - silence: Cisza + silence: Wycisz statuses: Statusy subscribe: Subskrybuj title: Konta @@ -122,6 +126,7 @@ pl: severity: Priorytet show: affected_accounts: + many: Dotyczy %{count} kont w bazie danych one: Dotyczy jednego konta w bazie danych other: Dotyczy %{count} kont w bazie danych retroactive: @@ -129,7 +134,7 @@ pl: suspend: Odwołaj zawieszenie wszystkich kont w tej domenie title: Odwołaj blokadę dla domeny %{domain} undo: Cofnij - title: Blokady domen + title: Zablokowane domeny undo: Cofnij instances: account_count: Znane konta @@ -142,7 +147,7 @@ pl: label: Komentarz none: Brak delete: Usuń - id: Identyfikator + id: ID mark_as_resolved: Oznacz jako rozwiązane nsfw: 'false': Nie oznaczaj jako NSFW @@ -150,8 +155,8 @@ pl: report: 'Zgłoszenie #%{id}' report_contents: Zawartość reported_account: Zgłoszone konto - reported_by: Zgłoszone przez - resolved: Rozwiązano + reported_by: Zgłaszający + resolved: Rozwiązane silence_account: Wycisz konto status: Status suspend_account: Zawieś konto @@ -180,7 +185,7 @@ pl: desc_html: Dobre miejsce na zasady użytkowania, wprowadzenie i inne rzeczy, które wyróżniają tę instancję. Możesz korzystać z tagów HTML title: Niestandardowy opis strony site_terms: - desc_html: Miejsce na własną politykę prywatności, zasady użytkowania i inne unormowania prawne. Możesz używać tagów HTML + desc_html: Miejsce na własną politykę prywatności, zasady użytkowania i inne unormowania prawne. Możesz korzystać z tagów HTML title: Niestandardowe zasady użytkowania site_title: Nazwa instancji timeline_preview: @@ -204,7 +209,7 @@ pl: with_media: Z zawartością multimedialną subscriptions: callback_url: URL zwrotny - confirmed: Potwierdzono + confirmed: Potwierdzone expires_in: Wygasa last_delivery: Ostatnio doręczono title: WebSub @@ -220,7 +225,13 @@ pl: signature: Powiadomienie Mastodona z instancji %{instance} view: 'Zobacz:' applications: - invalid_url: Ten URL jest nieprawidłowy + created: Pomyślnie utworzono aplikację + destroyed: Pomyślnie usunięto aplikację + invalid_url: Wprowadzony adres URL jest nieprawidłowy + regenerate_token: Wygeneruj nowy token dostępu + token_regenerated: Pomyślnie wygenerowano nowy token dostępu + warning: Przechowuj te dane ostrożnie. Nie udostępniaj ich nikomu! + your_token: Twój token dostępu auth: agreement_html: Rejestrując się, oświadczasz, że zapoznałeś się z <a href="%{rules_path}">naszymi zasadami użytkowania</a> i <a href="%{terms_path}">polityką prywatności</a>. change_password: Bezpieczeństwo @@ -275,12 +286,12 @@ pl: content: Sprawdzanie bezpieczeństwa nie powiodło się. Czy blokujesz pliki cookie? title: Sprawdzanie bezpieczeństwa nie powiodło się '429': Uduszono - noscript: Aby korzystać z aplikacji Mastodon, włącz JavaScript. Możesz też skorzystać z natywnej aplikacji obsługującej Twoje urządzenie. + noscript_html: Aby korzystać z aplikacji Mastodon, włącz JavaScript. Możesz też skorzystać z jednej z <a href="https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md">natywnych aplikacji</a> obsługującej Twoje urządzenie. exports: - blocks: Blokujesz + blocks: Zablokowani csv: CSV - follows: Śledzisz - mutes: Wyciszyłeś + follows: Śledzeni + mutes: Wyciszeni storage: Urządzenie przechowujące dane followers: domain: Domena @@ -344,6 +355,17 @@ pl: reblog: body: 'Twój wpis został podbity przez %{name}:' subject: Twój wpis został podbity przez %{name} + number: + human: + decimal_units: + format: "%n%u" + units: + billion: B + million: M + quadrillion: Q + thousand: K + trillion: T + unit: '' pagination: next: Następna prev: Poprzednia @@ -362,9 +384,6 @@ pl: title: "%{name} wspomniał o Tobie" reblog: title: "%{name} podbił Twój status" - subscribed: - body: Otrzymujesz teraz powiadomienia push. - title: Zarejestrowano subskrypcję! remote_follow: acct: Podaj swój adres (nazwa@domena), z którego chcesz śledzić missing_resource: Nie udało się znaleźć adresu przekierowania z Twojej domeny @@ -413,26 +432,33 @@ pl: authorized_apps: Uwierzytelnione aplikacje back: Powrót do Mastodona delete: Usuń konto + development: Tworzenie aplikacji edit_profile: Edytuj profil - export: Eksportuj dane + export: Eksportowanie danych followers: Autoryzowani śledzący - import: Importuj dane + import: Importowanie danych preferences: Preferencje settings: Ustawienia two_factor_authentication: Uwierzytelnianie dwuetapowe + your_apps: Twoje aplikacje statuses: open_in_web: Otwórz w przeglądarce over_character_limit: limit %{max} znaków przekroczony + pin_errors: + ownership: Nie możesz przypiąć cudzego wpisu + private: Nie możesz przypiąć niepublicznego wpisu + reblog: Nie możesz przypiąć podbicia wpisu show_more: Pokaż więcej visibilities: private: Tylko dla śledzących - private_long: Widoczne tylko dla śledzących + private_long: Widoczny tylko dla osób, które Cię śledzą public: Publiczny - public_long: Widoczne dla wszystkich + public_long: Widoczny dla wszystkich użytkowników unlisted: Niewypisany - unlisted_long: Widoczne dla wszystkich, ale nie wyświetlane na publicznych osiach czasu + unlisted_long: Widoczny dla wszystkich, ale nie wyświetlany na publicznych osiach czasu stream_entries: click_to_show: Naciśnij aby wyświetlić + pinned: Przypięty wpis reblogged: podbił sensitive_content: Wrażliwa zawartość terms: @@ -445,7 +471,7 @@ pl: <p>Podczas rejestracji, możesz otrzymać prośbę o podanie adresu e-mail. Możesz jednak odwiedzać stronę bez rejestracji. Adres zostanie zweryfikowany przez kliknięcie w link wysłany w wiadomości. Dzięki temu wiemy, że jesteś właścicielem tego adresu.</p> - <p>Podczas rejestracji i tworzenia postów, Twój adres IP jest zapisywany na naszych serwerach. Możemy też przechowywać adres IP użyty przy każdej operacji w serwisie.</p> + <p>Podczas rejestracji i tworzenia wpisów, Twój adres IP jest zapisywany na naszych serwerach. Możemy też przechowywać adres IP użyty przy każdej operacji w serwisie.</p> <h3 id="use">Jak wykorzystujemy zebrane informacje?</h3> diff --git a/config/locales/pt-BR.yml b/config/locales/pt-BR.yml index 6dec2b50a..750120299 100644 --- a/config/locales/pt-BR.yml +++ b/config/locales/pt-BR.yml @@ -255,6 +255,17 @@ pt-BR: reblog: body: 'O seu post foi reblogado por %{name}:' subject: "%{name} reblogou o seu post" + number: + human: + decimal_units: + format: "%n%u" + units: + billion: B + million: M + quadrillion: Q + thousand: K + trillion: T + unit: '' pagination: next: Next prev: Prev diff --git a/config/locales/pt.yml b/config/locales/pt.yml index f6dd32200..140f6b71b 100644 --- a/config/locales/pt.yml +++ b/config/locales/pt.yml @@ -182,6 +182,17 @@ pt: reblog: body: 'O teu post foi partilhado por %{name}:' subject: "%{name} partilhou o teu post" + number: + human: + decimal_units: + format: "%n%u" + units: + billion: B + million: M + quadrillion: Q + thousand: K + trillion: T + unit: '' pagination: next: Seguinte prev: Anterior diff --git a/config/locales/ru.yml b/config/locales/ru.yml index 0156f0e95..9ca08831e 100644 --- a/config/locales/ru.yml +++ b/config/locales/ru.yml @@ -193,7 +193,7 @@ ru: content: Проверка безопасности не удалась. Возможно, Вы блокируете cookies? title: Проверка безопасности не удалась. '429': Слишком много запросов - noscript: Для работы с Mastodon, пожалуйста, включите JavaScript. + noscript_html: Для работы с Mastodon, пожалуйста, включите JavaScript. exports: blocks: Список блокировки csv: CSV @@ -262,6 +262,17 @@ ru: reblog: body: 'Ваш статус был продвинут %{name}:' subject: "%{name} продвинул(а) Ваш статус" + number: + human: + decimal_units: + format: "%n%u" + units: + billion: B + million: M + quadrillion: Q + thousand: K + trillion: T + unit: '' pagination: next: След prev: Пред @@ -278,9 +289,6 @@ ru: title: Вас упомянул(а) %{name} reblog: title: "%{name} продвинул(а) Ваш статус" - subscribed: - body: Теперь Вы можете получать push-уведомления. - title: Подписка зарегистрирована! remote_follow: acct: Введите username@domain, откуда Вы хотите подписаться missing_resource: Поиск требуемого перенаправления URL для Вашего аккаунта завершился неудачей diff --git a/config/locales/simple_form.de.yml b/config/locales/simple_form.de.yml index 85ec0e4fc..c07dc2846 100644 --- a/config/locales/simple_form.de.yml +++ b/config/locales/simple_form.de.yml @@ -6,7 +6,7 @@ de: avatar: PNG, GIF oder JPG. Maximal 2MB. Wird auf 120x120px herunterskaliert display_name: '<span class="name-counter">%{count}</span> Zeichen verbleiben' header: PNG, GIF oder JPG. Maximal 2MB. Wird auf 700x335px herunterskaliert - locked: Erlaubt dir, Nutzer zu überprüfen, bevor sie dir folgen können + locked: Erlaubt dir, Profile zu überprüfen, bevor sie dir folgen können note: '<span class="note-counter">%{count}</span> Zeichen verbleiben' imports: data: CSV-Datei, die von einer anderen Mastodon-Instanz exportiert wurde @@ -33,10 +33,10 @@ de: setting_default_privacy: Beitragsprivatspäre severity: Gewichtung type: Importtyp - username: Nutzername + username: Profilname interactions: must_be_follower: Benachrichtigungen von Nicht-Folgern blockieren - must_be_following: Benachrichtigungen von Nutzern blockieren, denen ich nicht folge + must_be_following: Benachrichtigungen von Profilen blockieren, denen ich nicht folge notification_emails: digest: Schicke Übersichts-E-Mails favourite: E-Mail senden, wenn jemand meinen Beitrag favorisiert diff --git a/config/locales/simple_form.fr.yml b/config/locales/simple_form.fr.yml index 8717a4abd..adfb1a875 100644 --- a/config/locales/simple_form.fr.yml +++ b/config/locales/simple_form.fr.yml @@ -12,6 +12,7 @@ fr: note: one: <span class="note-counter">1</span> caractère restant other: <span class="note-counter">%{count}</span> caractères restants + setting_noindex: Affecte votre profil public ainsi que vos statuts imports: data: Un fichier CSV généré par une autre instance de Mastodon sessions: @@ -27,6 +28,7 @@ fr: data: Données display_name: Nom public email: Adresse courriel + filtered_languages: Langues filtrées header: Image d’en-tête locale: Langue locked: Verrouiller le compte @@ -37,8 +39,11 @@ fr: setting_auto_play_gif: Lire automatiquement les GIFs animés setting_boost_modal: Afficher un dialogue de confirmation avant de partager setting_default_privacy: Confidentialité des statuts + setting_default_sensitive: Toujours marquer les médias comme sensibles setting_delete_modal: Afficher un dialogue de confirmation avant de supprimer un pouet + setting_noindex: Demander aux moteurs de recherche de ne pas indexer vos informations personnelles setting_system_font_ui: Utiliser la police par défaut du système + setting_unfollow_modal: Afficher un dialogue de confirmation avant de vous désabonner d’un compte severity: Séverité type: Type d’import username: Identifiant diff --git a/config/locales/simple_form.pl.yml b/config/locales/simple_form.pl.yml index 7e84472b9..e2eba3dd8 100644 --- a/config/locales/simple_form.pl.yml +++ b/config/locales/simple_form.pl.yml @@ -16,12 +16,13 @@ pl: many: Pozostało <span class="name-counter">%{count}</span> znaków one: Pozostał <span class="name-counter">1</span> znak. other: Pozostało <span class="name-counter">%{count}</span> znaków + setting_noindex: Wpływa na widoczność strony profilu i Twoich wpisów imports: data: Plik CSV wyeksportowany z innej instancji Mastodona sessions: - otp: Wprowadź kod weryfikacji dwuetapowej z telefonu lub wykorzystaj jeden z kodów zapasowych. + otp: Wprowadź kod weryfikacji dwuetapowej z telefonu lub wykorzystaj jeden z kodów zapasowych user: - filtered_languages: Wybrane języki nie będą się pojawiać na publicznych osiach czasu. + filtered_languages: Wpisy w wybranych językach nie będą pojawiać się na publicznych osiach czasu. labels: defaults: avatar: Awatar @@ -41,25 +42,25 @@ pl: password: Hasło setting_auto_play_gif: Automatycznie odtwarzaj animowane GIFy setting_boost_modal: Pytaj o potwierdzenie przed podbiciem - setting_default_privacy: Widoczność posta + setting_default_privacy: Widoczność wpisów setting_default_sensitive: Zawsze oznaczaj zawartość multimedialną jako wrażliwą - setting_delete_modal: Pytaj o potwierdzenie przed usunięciem postu + setting_delete_modal: Pytaj o potwierdzenie przed usunięciem wpisu setting_noindex: Nie indeksuj mojego profilu w wyszukiwarkach internetowych setting_system_font_ui: Używaj domyślnej czcionki systemu - setting_unfollow_modal: Pytaj o potwierdzenie przed usunięciem śledzenia + setting_unfollow_modal: Pytaj o potwierdzenie przed cofnięciem śledzenia severity: Priorytet type: Typ importu username: Nazwa użytkownika interactions: - must_be_follower: Zablokuj powiadomienia od osób, które Cię nie śledzą - must_be_following: Zablokuj powiadomienia od osób, których nie śledzisz + must_be_follower: Nie wyświetlaj powiadomień od osób, które Cię nie śledzą + must_be_following: Nie wyświetlaj powiadomień od osób, których nie śledzisz notification_emails: digest: Wysyłaj podsumowania e-mailem - favourite: Powiadom mnie e-mailem gdy ktoś polubi mój status. - follow: Powiadom mnie e-mailem gdy ktoś zacznie mnie śledzić. - follow_request: Powiadom mnie e-mailem gdy ktoś poprosi o pozwolenie śledzenia mnie. - mention: Powiadom mnie e-mailem gdy ktoś mnie wspomni. - reblog: Powiadom mnie e-mailem gdy ktoś podbije mój status. + favourite: Powiadamiaj mnie e-mailem, gdy ktoś polubi mój wpis + follow: Powiadamiaj mnie e-mailem, gdy ktoś zacznie mnie śledzić + follow_request: Powiadamiaj mnie e-mailem, gdy ktoś poprosi o pozwolenie na śledzenie mnie + mention: Powiadamiaj mnie e-mailem, gdy ktoś o mnie wspomni + reblog: Powiadamiaj mnie e-mailem, gdy ktoś podbije mój wpis 'no': Nie required: mark: "*" diff --git a/config/locales/th.yml b/config/locales/th.yml index 9d0887928..2db3aee8a 100644 --- a/config/locales/th.yml +++ b/config/locales/th.yml @@ -257,6 +257,17 @@ th: reblog: body: 'Your status was boosted by %{name}:' subject: "%{name} boosted your status" + number: + human: + decimal_units: + format: "%n%u" + units: + billion: B + million: M + quadrillion: Q + thousand: K + trillion: T + unit: '' pagination: next: ต่อไป prev: ย้อนกลับ diff --git a/config/locales/tr.yml b/config/locales/tr.yml index 91ef9544c..6aff78fa1 100644 --- a/config/locales/tr.yml +++ b/config/locales/tr.yml @@ -255,6 +255,17 @@ tr: reblog: body: "%{name} durumunuzu boost etti:" subject: "%{name} durumunuzu boost etti" + number: + human: + decimal_units: + format: "%n%u" + units: + billion: B + million: M + quadrillion: Q + thousand: K + trillion: T + unit: '' pagination: next: Sonraki prev: Önceki diff --git a/config/locales/uk.yml b/config/locales/uk.yml index 4d12ddf4e..995a682a7 100644 --- a/config/locales/uk.yml +++ b/config/locales/uk.yml @@ -250,6 +250,17 @@ uk: reblog: body: 'Ваш статус було передмухнуто %{name}:' subject: "%{name} передмухнув ваш статус" + number: + human: + decimal_units: + format: "%n%u" + units: + billion: B + million: M + quadrillion: Q + thousand: K + trillion: T + unit: '' pagination: next: Далі prev: Назад diff --git a/config/locales/zh-CN.yml b/config/locales/zh-CN.yml index 0672202a2..95c24d0bc 100644 --- a/config/locales/zh-CN.yml +++ b/config/locales/zh-CN.yml @@ -261,6 +261,17 @@ zh-CN: reblog: body: 你的嘟文得到 %{name} 的转嘟 subject: "%{name} 转嘟(嘟嘟滴)了你的嘟文" + number: + human: + decimal_units: + format: "%n%u" + units: + billion: B + million: M + quadrillion: Q + thousand: K + trillion: T + unit: '' pagination: next: 下一页 prev: 上一页 diff --git a/config/locales/zh-HK.yml b/config/locales/zh-HK.yml index 9d6c74008..aa6b1ea6a 100644 --- a/config/locales/zh-HK.yml +++ b/config/locales/zh-HK.yml @@ -256,6 +256,17 @@ zh-HK: reblog: body: 你的文章得到 %{name} 的轉推 subject: "%{name} 轉推了你的文章" + number: + human: + decimal_units: + format: "%n%u" + units: + billion: B + million: M + quadrillion: Q + thousand: K + trillion: T + unit: '' pagination: next: 下一頁 prev: 上一頁 diff --git a/config/locales/zh-TW.yml b/config/locales/zh-TW.yml index 7065acf9a..299a92da7 100644 --- a/config/locales/zh-TW.yml +++ b/config/locales/zh-TW.yml @@ -211,6 +211,17 @@ zh-TW: reblog: body: 您的文章被 %{name} 轉推 subject: "%{name} 轉推了您的文章" + number: + human: + decimal_units: + format: "%n%u" + units: + billion: B + million: M + quadrillion: Q + thousand: K + trillion: T + unit: '' pagination: next: 下一頁 prev: 上一頁 diff --git a/config/navigation.rb b/config/navigation.rb index 535d033f5..4b454b3fc 100644 --- a/config/navigation.rb +++ b/config/navigation.rb @@ -15,6 +15,10 @@ SimpleNavigation::Configuration.run do |navigation| settings.item :follower_domains, safe_join([fa_icon('users fw'), t('settings.followers')]), settings_follower_domains_url end + primary.item :development, safe_join([fa_icon('code fw'), t('settings.development')]), settings_applications_url do |development| + development.item :your_apps, safe_join([fa_icon('list fw'), t('settings.your_apps')]), settings_applications_url, highlights_on: %r{/settings/applications} + end + primary.item :admin, safe_join([fa_icon('cogs fw'), t('admin.title')]), admin_reports_url, if: proc { current_user.admin? } do |admin| admin.item :reports, safe_join([fa_icon('flag fw'), t('admin.reports.title')]), admin_reports_url, highlights_on: %r{/admin/reports} admin.item :accounts, safe_join([fa_icon('users fw'), t('admin.accounts.title')]), admin_accounts_url, highlights_on: %r{/admin/accounts} diff --git a/config/routes.rb b/config/routes.rb index c60a8b131..2ff7e890a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -20,6 +20,7 @@ Rails.application.routes.draw do get '.well-known/host-meta', to: 'well_known/host_meta#show', as: :host_meta, defaults: { format: 'xml' } get '.well-known/webfinger', to: 'well_known/webfinger#show', as: :webfinger get 'manifest', to: 'manifests#show', defaults: { format: 'json' } + get 'intent', to: 'intents#show' devise_for :users, path: 'auth', controllers: { sessions: 'auth/sessions', @@ -43,6 +44,7 @@ Rails.application.routes.draw do resources :statuses, only: [:show] do member do get :activity + get :embed end end @@ -51,10 +53,16 @@ Rails.application.routes.draw do resource :follow, only: [:create], controller: :account_follow resource :unfollow, only: [:create], controller: :account_unfollow resource :outbox, only: [:show], module: :activitypub + resource :inbox, only: [:create], module: :activitypub end + resource :inbox, only: [:create], module: :activitypub + get '/@:username', to: 'accounts#show', as: :short_account + get '/@:username/with_replies', to: 'accounts#show', as: :short_account_with_replies + get '/@:username/media', to: 'accounts#show', as: :short_account_media get '/@:account_username/:id', to: 'statuses#show', as: :short_account_status + get '/@:account_username/:id/embed', to: 'statuses#embed', as: :embed_short_account_status namespace :settings do resource :profile, only: [:show, :update] @@ -75,6 +83,13 @@ Rails.application.routes.draw do end resource :follower_domains, only: [:show, :update] + + resources :applications, except: [:edit] do + member do + post :regenerate + end + end + resource :delete, only: [:show, :destroy] resources :sessions, only: [:destroy] @@ -85,12 +100,13 @@ Rails.application.routes.draw do # Remote follow resource :authorize_follow, only: [:show, :create] + resource :share, only: [:show, :create] namespace :admin do resources :subscriptions, only: [:index] resources :domain_blocks, only: [:index, :new, :create, :show, :destroy] resource :settings, only: [:edit, :update] - + resources :instances, only: [:index] do collection do post :resubscribe @@ -150,6 +166,9 @@ Rails.application.routes.draw do resource :mute, only: :create post :unmute, to: 'mutes#destroy' + + resource :pin, only: :create + post :unpin, to: 'pins#destroy' end member do @@ -163,7 +182,8 @@ Rails.application.routes.draw do resource :public, only: :show, controller: :public resources :tag, only: :show end - resources :streaming, only: [:index] + + resources :streaming, only: [:index] get '/search', to: 'search#index', as: :search @@ -199,6 +219,7 @@ Rails.application.routes.draw do resource :search, only: :show, controller: :search resources :relationships, only: :index end + resources :accounts, only: [:show] do resources :statuses, only: :index, controller: 'accounts/statuses' resources :followers, only: :index, controller: 'accounts/follower_accounts' @@ -217,6 +238,7 @@ Rails.application.routes.draw do namespace :web do resource :settings, only: [:update] + resource :embed, only: [:create] resources :push_subscriptions, only: [:create] do member do put :update @@ -234,7 +256,7 @@ Rails.application.routes.draw do root 'home#index' match '*unmatched_route', - via: :all, - to: 'application#raise_not_found', - format: false + via: :all, + to: 'application#raise_not_found', + format: false end diff --git a/config/sidekiq.yml b/config/sidekiq.yml index 8273c1201..a502f5593 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -21,3 +21,6 @@ user_cleanup_scheduler: cron: '4 5 * * *' class: Scheduler::UserCleanupScheduler + subscriptions_cleanup_scheduler: + cron: '2 2 * * 0' + class: Scheduler::SubscriptionsCleanupScheduler diff --git a/config/webpack/loaders/babel.js b/config/webpack/loaders/babel.js index 05ef8431c..989a87dcf 100644 --- a/config/webpack/loaders/babel.js +++ b/config/webpack/loaders/babel.js @@ -1,5 +1,7 @@ const { resolve } = require('path'); +const env = process.env.NODE_ENV || 'development'; + module.exports = { test: /\.js$/, // include react-intl because transform-react-remove-prop-types needs to apply to it @@ -11,6 +13,6 @@ module.exports = { options: { forceEnv: process.env.NODE_ENV || 'development', sourceRoot: 'app/javascript', - cacheDirectory: resolve(__dirname, '..', '..', '..', 'tmp', 'cache', 'babel-loader'), + cacheDirectory: env === 'development' ? false : resolve(__dirname, '..', '..', '..', 'tmp', 'cache', 'babel-loader'), }, }; diff --git a/db/migrate/20170427011934_re_add_owner_to_application.rb b/db/migrate/20170427011934_re_add_owner_to_application.rb new file mode 100644 index 000000000..a41d71d2a --- /dev/null +++ b/db/migrate/20170427011934_re_add_owner_to_application.rb @@ -0,0 +1,8 @@ +class ReAddOwnerToApplication < ActiveRecord::Migration[5.0] + def change + add_column :oauth_applications, :owner_id, :integer, null: true + add_column :oauth_applications, :owner_type, :string, null: true + add_index :oauth_applications, [:owner_id, :owner_type] + add_foreign_key :oauth_applications, :users, column: :owner_id, on_delete: :cascade + end +end diff --git a/db/migrate/20170823162448_create_status_pins.rb b/db/migrate/20170823162448_create_status_pins.rb new file mode 100644 index 000000000..9a6d4a7b9 --- /dev/null +++ b/db/migrate/20170823162448_create_status_pins.rb @@ -0,0 +1,10 @@ +class CreateStatusPins < ActiveRecord::Migration[5.1] + def change + create_table :status_pins do |t| + t.belongs_to :account, foreign_key: { on_delete: :cascade }, null: false + t.belongs_to :status, foreign_key: { on_delete: :cascade }, null: false + end + + add_index :status_pins, [:account_id, :status_id], unique: true + end +end diff --git a/db/migrate/20170824103029_add_timestamps_to_status_pins.rb b/db/migrate/20170824103029_add_timestamps_to_status_pins.rb new file mode 100644 index 000000000..09f0fbeaf --- /dev/null +++ b/db/migrate/20170824103029_add_timestamps_to_status_pins.rb @@ -0,0 +1,5 @@ +class AddTimestampsToStatusPins < ActiveRecord::Migration[5.1] + def change + add_timestamps :status_pins, null: false, default: -> { 'CURRENT_TIMESTAMP' } + end +end diff --git a/db/migrate/20170829215220_remove_status_pins_account_index.rb b/db/migrate/20170829215220_remove_status_pins_account_index.rb new file mode 100644 index 000000000..e0144242e --- /dev/null +++ b/db/migrate/20170829215220_remove_status_pins_account_index.rb @@ -0,0 +1,6 @@ +class RemoveStatusPinsAccountIndex < ActiveRecord::Migration[5.1] + def change + remove_index :status_pins, :account_id + remove_index :status_pins, :status_id + end +end diff --git a/db/migrate/20170901141119_truncate_preview_cards.rb b/db/migrate/20170901141119_truncate_preview_cards.rb new file mode 100644 index 000000000..4d9802f3b --- /dev/null +++ b/db/migrate/20170901141119_truncate_preview_cards.rb @@ -0,0 +1,30 @@ +class TruncatePreviewCards < ActiveRecord::Migration[5.1] + def up + rename_table :preview_cards, :deprecated_preview_cards + + create_table :preview_cards do |t| + t.string :url, default: '', null: false, index: { unique: true } + t.string :title, default: '', null: false + t.string :description, default: '', null: false + t.attachment :image + t.integer :type, default: 0, null: false + t.text :html, default: '', null: false + t.string :author_name, default: '', null: false + t.string :author_url, default: '', null: false + t.string :provider_name, default: '', null: false + t.string :provider_url, default: '', null: false + t.integer :width, default: 0, null: false + t.integer :height, default: 0, null: false + t.timestamps + end + end + + def down + if ActiveRecord::Base.connection.table_exists? 'deprecated_preview_cards' + drop_table :preview_cards + rename_table :deprecated_preview_cards, :preview_cards + else + raise ActiveRecord::IrreversibleMigration, 'Previous preview cards table has already been removed' + end + end +end diff --git a/db/migrate/20170901142658_create_join_table_preview_cards_statuses.rb b/db/migrate/20170901142658_create_join_table_preview_cards_statuses.rb new file mode 100644 index 000000000..be7f533b5 --- /dev/null +++ b/db/migrate/20170901142658_create_join_table_preview_cards_statuses.rb @@ -0,0 +1,7 @@ +class CreateJoinTablePreviewCardsStatuses < ActiveRecord::Migration[5.1] + def change + create_join_table :preview_cards, :statuses do |t| + t.index [:status_id, :preview_card_id] + end + end +end diff --git a/db/migrate/20170905044538_add_index_id_account_id_activity_type_on_notifications.rb b/db/migrate/20170905044538_add_index_id_account_id_activity_type_on_notifications.rb new file mode 100644 index 000000000..c47cea9e2 --- /dev/null +++ b/db/migrate/20170905044538_add_index_id_account_id_activity_type_on_notifications.rb @@ -0,0 +1,5 @@ +class AddIndexIdAccountIdActivityTypeOnNotifications < ActiveRecord::Migration[5.1] + def change + add_index :notifications, [:id, :account_id, :activity_type], order: { id: :desc } + end +end diff --git a/db/migrate/20170905165803_add_local_to_statuses.rb b/db/migrate/20170905165803_add_local_to_statuses.rb new file mode 100644 index 000000000..fb4e7019d --- /dev/null +++ b/db/migrate/20170905165803_add_local_to_statuses.rb @@ -0,0 +1,5 @@ +class AddLocalToStatuses < ActiveRecord::Migration[5.1] + def change + add_column :statuses, :local, :boolean, null: true, default: nil + end +end diff --git a/db/schema.rb b/db/schema.rb index 2501e451d..d8af0a1f8 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20170720000000) do +ActiveRecord::Schema.define(version: 20170905165803) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -180,6 +180,7 @@ ActiveRecord::Schema.define(version: 20170720000000) do t.integer "from_account_id" t.index ["account_id", "activity_id", "activity_type"], name: "account_activity", unique: true t.index ["activity_id", "activity_type"], name: "index_notifications_on_activity_id_and_activity_type" + t.index ["id", "account_id", "activity_type"], name: "index_notifications_on_id_and_account_id_and_activity_type", order: { id: :desc } end create_table "oauth_access_grants", id: :serial, force: :cascade do |t| @@ -218,20 +219,20 @@ ActiveRecord::Schema.define(version: 20170720000000) do t.datetime "updated_at" t.boolean "superapp", default: false, null: false t.string "website" + t.integer "owner_id" + t.string "owner_type" + t.index ["owner_id", "owner_type"], name: "index_oauth_applications_on_owner_id_and_owner_type" t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true end - create_table "preview_cards", id: :serial, force: :cascade do |t| - t.bigint "status_id" + create_table "preview_cards", force: :cascade do |t| t.string "url", default: "", null: false - t.string "title" - t.string "description" + t.string "title", default: "", null: false + t.string "description", default: "", null: false t.string "image_file_name" t.string "image_content_type" t.integer "image_file_size" t.datetime "image_updated_at" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.integer "type", default: 0, null: false t.text "html", default: "", null: false t.string "author_name", default: "", null: false @@ -240,7 +241,15 @@ ActiveRecord::Schema.define(version: 20170720000000) do t.string "provider_url", default: "", null: false t.integer "width", default: 0, null: false t.integer "height", default: 0, null: false - t.index ["status_id"], name: "index_preview_cards_on_status_id", unique: true + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["url"], name: "index_preview_cards_on_url", unique: true + end + + create_table "preview_cards_statuses", id: false, force: :cascade do |t| + t.bigint "preview_card_id", null: false + t.bigint "status_id", null: false + t.index ["status_id", "preview_card_id"], name: "index_preview_cards_statuses_on_status_id_and_preview_card_id" end create_table "reports", id: :serial, force: :cascade do |t| @@ -279,6 +288,14 @@ ActiveRecord::Schema.define(version: 20170720000000) do t.index ["thing_type", "thing_id", "var"], name: "index_settings_on_thing_type_and_thing_id_and_var", unique: true end + create_table "status_pins", force: :cascade do |t| + t.bigint "account_id", null: false + t.bigint "status_id", null: false + t.datetime "created_at", default: -> { "now()" }, null: false + t.datetime "updated_at", default: -> { "now()" }, null: false + t.index ["account_id", "status_id"], name: "index_status_pins_on_account_id_and_status_id", unique: true + end + create_table "statuses", force: :cascade do |t| t.string "uri" t.integer "account_id", null: false @@ -298,6 +315,7 @@ ActiveRecord::Schema.define(version: 20170720000000) do t.integer "reblogs_count", default: 0, null: false t.string "language" t.bigint "conversation_id" + t.boolean "local" t.index ["account_id", "id"], name: "index_statuses_on_account_id_id" t.index ["conversation_id"], name: "index_statuses_on_conversation_id" t.index ["in_reply_to_id"], name: "index_statuses_on_in_reply_to_id" @@ -420,12 +438,14 @@ ActiveRecord::Schema.define(version: 20170720000000) do add_foreign_key "oauth_access_grants", "users", column: "resource_owner_id", on_delete: :cascade add_foreign_key "oauth_access_tokens", "oauth_applications", column: "application_id", on_delete: :cascade add_foreign_key "oauth_access_tokens", "users", column: "resource_owner_id", on_delete: :cascade - add_foreign_key "preview_cards", "statuses", on_delete: :cascade + add_foreign_key "oauth_applications", "users", column: "owner_id", on_delete: :cascade add_foreign_key "reports", "accounts", column: "action_taken_by_account_id", on_delete: :nullify add_foreign_key "reports", "accounts", column: "target_account_id", on_delete: :cascade add_foreign_key "reports", "accounts", on_delete: :cascade add_foreign_key "session_activations", "oauth_access_tokens", column: "access_token_id", on_delete: :cascade add_foreign_key "session_activations", "users", on_delete: :cascade + add_foreign_key "status_pins", "accounts", on_delete: :cascade + add_foreign_key "status_pins", "statuses", on_delete: :cascade add_foreign_key "statuses", "accounts", column: "in_reply_to_account_id", on_delete: :nullify add_foreign_key "statuses", "accounts", on_delete: :cascade add_foreign_key "statuses", "statuses", column: "in_reply_to_id", on_delete: :nullify diff --git a/lib/json_ld/activitystreams.rb b/lib/json_ld/activitystreams.rb new file mode 100644 index 000000000..ce740f93b --- /dev/null +++ b/lib/json_ld/activitystreams.rb @@ -0,0 +1,153 @@ +# -*- encoding: utf-8 -*- +# frozen_string_literal: true +# This file generated automatically from https://www.w3.org/ns/activitystreams +require 'json/ld' +class JSON::LD::Context + add_preloaded("https://www.w3.org/ns/activitystreams") do + new(vocab: "_:", processingMode: "json-ld-1.0", term_definitions: { + "Accept" => TermDefinition.new("Accept", id: "https://www.w3.org/ns/activitystreams#Accept", simple: true), + "Activity" => TermDefinition.new("Activity", id: "https://www.w3.org/ns/activitystreams#Activity", simple: true), + "Add" => TermDefinition.new("Add", id: "https://www.w3.org/ns/activitystreams#Add", simple: true), + "Announce" => TermDefinition.new("Announce", id: "https://www.w3.org/ns/activitystreams#Announce", simple: true), + "Application" => TermDefinition.new("Application", id: "https://www.w3.org/ns/activitystreams#Application", simple: true), + "Arrive" => TermDefinition.new("Arrive", id: "https://www.w3.org/ns/activitystreams#Arrive", simple: true), + "Article" => TermDefinition.new("Article", id: "https://www.w3.org/ns/activitystreams#Article", simple: true), + "Audio" => TermDefinition.new("Audio", id: "https://www.w3.org/ns/activitystreams#Audio", simple: true), + "Block" => TermDefinition.new("Block", id: "https://www.w3.org/ns/activitystreams#Block", simple: true), + "Collection" => TermDefinition.new("Collection", id: "https://www.w3.org/ns/activitystreams#Collection", simple: true), + "CollectionPage" => TermDefinition.new("CollectionPage", id: "https://www.w3.org/ns/activitystreams#CollectionPage", simple: true), + "Create" => TermDefinition.new("Create", id: "https://www.w3.org/ns/activitystreams#Create", simple: true), + "Delete" => TermDefinition.new("Delete", id: "https://www.w3.org/ns/activitystreams#Delete", simple: true), + "Dislike" => TermDefinition.new("Dislike", id: "https://www.w3.org/ns/activitystreams#Dislike", simple: true), + "Document" => TermDefinition.new("Document", id: "https://www.w3.org/ns/activitystreams#Document", simple: true), + "Event" => TermDefinition.new("Event", id: "https://www.w3.org/ns/activitystreams#Event", simple: true), + "Flag" => TermDefinition.new("Flag", id: "https://www.w3.org/ns/activitystreams#Flag", simple: true), + "Follow" => TermDefinition.new("Follow", id: "https://www.w3.org/ns/activitystreams#Follow", simple: true), + "Group" => TermDefinition.new("Group", id: "https://www.w3.org/ns/activitystreams#Group", simple: true), + "Ignore" => TermDefinition.new("Ignore", id: "https://www.w3.org/ns/activitystreams#Ignore", simple: true), + "Image" => TermDefinition.new("Image", id: "https://www.w3.org/ns/activitystreams#Image", simple: true), + "IntransitiveActivity" => TermDefinition.new("IntransitiveActivity", id: "https://www.w3.org/ns/activitystreams#IntransitiveActivity", simple: true), + "Invite" => TermDefinition.new("Invite", id: "https://www.w3.org/ns/activitystreams#Invite", simple: true), + "IsContact" => TermDefinition.new("IsContact", id: "https://www.w3.org/ns/activitystreams#IsContact", simple: true), + "IsFollowedBy" => TermDefinition.new("IsFollowedBy", id: "https://www.w3.org/ns/activitystreams#IsFollowedBy", simple: true), + "IsFollowing" => TermDefinition.new("IsFollowing", id: "https://www.w3.org/ns/activitystreams#IsFollowing", simple: true), + "IsMember" => TermDefinition.new("IsMember", id: "https://www.w3.org/ns/activitystreams#IsMember", simple: true), + "Join" => TermDefinition.new("Join", id: "https://www.w3.org/ns/activitystreams#Join", simple: true), + "Leave" => TermDefinition.new("Leave", id: "https://www.w3.org/ns/activitystreams#Leave", simple: true), + "Like" => TermDefinition.new("Like", id: "https://www.w3.org/ns/activitystreams#Like", simple: true), + "Link" => TermDefinition.new("Link", id: "https://www.w3.org/ns/activitystreams#Link", simple: true), + "Listen" => TermDefinition.new("Listen", id: "https://www.w3.org/ns/activitystreams#Listen", simple: true), + "Mention" => TermDefinition.new("Mention", id: "https://www.w3.org/ns/activitystreams#Mention", simple: true), + "Move" => TermDefinition.new("Move", id: "https://www.w3.org/ns/activitystreams#Move", simple: true), + "Note" => TermDefinition.new("Note", id: "https://www.w3.org/ns/activitystreams#Note", simple: true), + "Object" => TermDefinition.new("Object", id: "https://www.w3.org/ns/activitystreams#Object", simple: true), + "Offer" => TermDefinition.new("Offer", id: "https://www.w3.org/ns/activitystreams#Offer", simple: true), + "OrderedCollection" => TermDefinition.new("OrderedCollection", id: "https://www.w3.org/ns/activitystreams#OrderedCollection", simple: true), + "OrderedCollectionPage" => TermDefinition.new("OrderedCollectionPage", id: "https://www.w3.org/ns/activitystreams#OrderedCollectionPage", simple: true), + "Organization" => TermDefinition.new("Organization", id: "https://www.w3.org/ns/activitystreams#Organization", simple: true), + "Page" => TermDefinition.new("Page", id: "https://www.w3.org/ns/activitystreams#Page", simple: true), + "Person" => TermDefinition.new("Person", id: "https://www.w3.org/ns/activitystreams#Person", simple: true), + "Place" => TermDefinition.new("Place", id: "https://www.w3.org/ns/activitystreams#Place", simple: true), + "Profile" => TermDefinition.new("Profile", id: "https://www.w3.org/ns/activitystreams#Profile", simple: true), + "Question" => TermDefinition.new("Question", id: "https://www.w3.org/ns/activitystreams#Question", simple: true), + "Read" => TermDefinition.new("Read", id: "https://www.w3.org/ns/activitystreams#Read", simple: true), + "Reject" => TermDefinition.new("Reject", id: "https://www.w3.org/ns/activitystreams#Reject", simple: true), + "Relationship" => TermDefinition.new("Relationship", id: "https://www.w3.org/ns/activitystreams#Relationship", simple: true), + "Remove" => TermDefinition.new("Remove", id: "https://www.w3.org/ns/activitystreams#Remove", simple: true), + "Service" => TermDefinition.new("Service", id: "https://www.w3.org/ns/activitystreams#Service", simple: true), + "TentativeAccept" => TermDefinition.new("TentativeAccept", id: "https://www.w3.org/ns/activitystreams#TentativeAccept", simple: true), + "TentativeReject" => TermDefinition.new("TentativeReject", id: "https://www.w3.org/ns/activitystreams#TentativeReject", simple: true), + "Tombstone" => TermDefinition.new("Tombstone", id: "https://www.w3.org/ns/activitystreams#Tombstone", simple: true), + "Travel" => TermDefinition.new("Travel", id: "https://www.w3.org/ns/activitystreams#Travel", simple: true), + "Undo" => TermDefinition.new("Undo", id: "https://www.w3.org/ns/activitystreams#Undo", simple: true), + "Update" => TermDefinition.new("Update", id: "https://www.w3.org/ns/activitystreams#Update", simple: true), + "Video" => TermDefinition.new("Video", id: "https://www.w3.org/ns/activitystreams#Video", simple: true), + "View" => TermDefinition.new("View", id: "https://www.w3.org/ns/activitystreams#View", simple: true), + "accuracy" => TermDefinition.new("accuracy", id: "https://www.w3.org/ns/activitystreams#accuracy", type_mapping: "http://www.w3.org/2001/XMLSchema#float"), + "actor" => TermDefinition.new("actor", id: "https://www.w3.org/ns/activitystreams#actor", type_mapping: "@id"), + "altitude" => TermDefinition.new("altitude", id: "https://www.w3.org/ns/activitystreams#altitude", type_mapping: "http://www.w3.org/2001/XMLSchema#float"), + "anyOf" => TermDefinition.new("anyOf", id: "https://www.w3.org/ns/activitystreams#anyOf", type_mapping: "@id"), + "as" => TermDefinition.new("as", id: "https://www.w3.org/ns/activitystreams#", simple: true, prefix: true), + "attachment" => TermDefinition.new("attachment", id: "https://www.w3.org/ns/activitystreams#attachment", type_mapping: "@id"), + "attributedTo" => TermDefinition.new("attributedTo", id: "https://www.w3.org/ns/activitystreams#attributedTo", type_mapping: "@id"), + "audience" => TermDefinition.new("audience", id: "https://www.w3.org/ns/activitystreams#audience", type_mapping: "@id"), + "bcc" => TermDefinition.new("bcc", id: "https://www.w3.org/ns/activitystreams#bcc", type_mapping: "@id"), + "bto" => TermDefinition.new("bto", id: "https://www.w3.org/ns/activitystreams#bto", type_mapping: "@id"), + "cc" => TermDefinition.new("cc", id: "https://www.w3.org/ns/activitystreams#cc", type_mapping: "@id"), + "closed" => TermDefinition.new("closed", id: "https://www.w3.org/ns/activitystreams#closed", type_mapping: "http://www.w3.org/2001/XMLSchema#dateTime"), + "content" => TermDefinition.new("content", id: "https://www.w3.org/ns/activitystreams#content", simple: true), + "contentMap" => TermDefinition.new("contentMap", id: "https://www.w3.org/ns/activitystreams#content", container_mapping: "@language"), + "context" => TermDefinition.new("context", id: "https://www.w3.org/ns/activitystreams#context", type_mapping: "@id"), + "current" => TermDefinition.new("current", id: "https://www.w3.org/ns/activitystreams#current", type_mapping: "@id"), + "deleted" => TermDefinition.new("deleted", id: "https://www.w3.org/ns/activitystreams#deleted", type_mapping: "http://www.w3.org/2001/XMLSchema#dateTime"), + "describes" => TermDefinition.new("describes", id: "https://www.w3.org/ns/activitystreams#describes", type_mapping: "@id"), + "duration" => TermDefinition.new("duration", id: "https://www.w3.org/ns/activitystreams#duration", type_mapping: "http://www.w3.org/2001/XMLSchema#duration"), + "endTime" => TermDefinition.new("endTime", id: "https://www.w3.org/ns/activitystreams#endTime", type_mapping: "http://www.w3.org/2001/XMLSchema#dateTime"), + "endpoints" => TermDefinition.new("endpoints", id: "https://www.w3.org/ns/activitystreams#endpoints", type_mapping: "@id"), + "first" => TermDefinition.new("first", id: "https://www.w3.org/ns/activitystreams#first", type_mapping: "@id"), + "followers" => TermDefinition.new("followers", id: "https://www.w3.org/ns/activitystreams#followers", type_mapping: "@id"), + "following" => TermDefinition.new("following", id: "https://www.w3.org/ns/activitystreams#following", type_mapping: "@id"), + "formerType" => TermDefinition.new("formerType", id: "https://www.w3.org/ns/activitystreams#formerType", type_mapping: "@id"), + "generator" => TermDefinition.new("generator", id: "https://www.w3.org/ns/activitystreams#generator", type_mapping: "@id"), + "height" => TermDefinition.new("height", id: "https://www.w3.org/ns/activitystreams#height", type_mapping: "http://www.w3.org/2001/XMLSchema#nonNegativeInteger"), + "href" => TermDefinition.new("href", id: "https://www.w3.org/ns/activitystreams#href", type_mapping: "@id"), + "hreflang" => TermDefinition.new("hreflang", id: "https://www.w3.org/ns/activitystreams#hreflang", simple: true), + "icon" => TermDefinition.new("icon", id: "https://www.w3.org/ns/activitystreams#icon", type_mapping: "@id"), + "id" => TermDefinition.new("id", id: "@id", simple: true), + "image" => TermDefinition.new("image", id: "https://www.w3.org/ns/activitystreams#image", type_mapping: "@id"), + "inReplyTo" => TermDefinition.new("inReplyTo", id: "https://www.w3.org/ns/activitystreams#inReplyTo", type_mapping: "@id"), + "inbox" => TermDefinition.new("inbox", id: "http://www.w3.org/ns/ldp#inbox", type_mapping: "@id"), + "instrument" => TermDefinition.new("instrument", id: "https://www.w3.org/ns/activitystreams#instrument", type_mapping: "@id"), + "items" => TermDefinition.new("items", id: "https://www.w3.org/ns/activitystreams#items", type_mapping: "@id"), + "last" => TermDefinition.new("last", id: "https://www.w3.org/ns/activitystreams#last", type_mapping: "@id"), + "latitude" => TermDefinition.new("latitude", id: "https://www.w3.org/ns/activitystreams#latitude", type_mapping: "http://www.w3.org/2001/XMLSchema#float"), + "ldp" => TermDefinition.new("ldp", id: "http://www.w3.org/ns/ldp#", simple: true, prefix: true), + "liked" => TermDefinition.new("liked", id: "https://www.w3.org/ns/activitystreams#liked", type_mapping: "@id"), + "location" => TermDefinition.new("location", id: "https://www.w3.org/ns/activitystreams#location", type_mapping: "@id"), + "longitude" => TermDefinition.new("longitude", id: "https://www.w3.org/ns/activitystreams#longitude", type_mapping: "http://www.w3.org/2001/XMLSchema#float"), + "mediaType" => TermDefinition.new("mediaType", id: "https://www.w3.org/ns/activitystreams#mediaType", simple: true), + "name" => TermDefinition.new("name", id: "https://www.w3.org/ns/activitystreams#name", simple: true), + "nameMap" => TermDefinition.new("nameMap", id: "https://www.w3.org/ns/activitystreams#name", container_mapping: "@language"), + "next" => TermDefinition.new("next", id: "https://www.w3.org/ns/activitystreams#next", type_mapping: "@id"), + "oauthAuthorizationEndpoint" => TermDefinition.new("oauthAuthorizationEndpoint", id: "https://www.w3.org/ns/activitystreams#oauthAuthorizationEndpoint", type_mapping: "@id"), + "oauthTokenEndpoint" => TermDefinition.new("oauthTokenEndpoint", id: "https://www.w3.org/ns/activitystreams#oauthTokenEndpoint", type_mapping: "@id"), + "object" => TermDefinition.new("object", id: "https://www.w3.org/ns/activitystreams#object", type_mapping: "@id"), + "oneOf" => TermDefinition.new("oneOf", id: "https://www.w3.org/ns/activitystreams#oneOf", type_mapping: "@id"), + "orderedItems" => TermDefinition.new("orderedItems", id: "https://www.w3.org/ns/activitystreams#items", type_mapping: "@id", container_mapping: "@list"), + "origin" => TermDefinition.new("origin", id: "https://www.w3.org/ns/activitystreams#origin", type_mapping: "@id"), + "outbox" => TermDefinition.new("outbox", id: "https://www.w3.org/ns/activitystreams#outbox", type_mapping: "@id"), + "partOf" => TermDefinition.new("partOf", id: "https://www.w3.org/ns/activitystreams#partOf", type_mapping: "@id"), + "preferredUsername" => TermDefinition.new("preferredUsername", id: "https://www.w3.org/ns/activitystreams#preferredUsername", simple: true), + "prev" => TermDefinition.new("prev", id: "https://www.w3.org/ns/activitystreams#prev", type_mapping: "@id"), + "preview" => TermDefinition.new("preview", id: "https://www.w3.org/ns/activitystreams#preview", type_mapping: "@id"), + "provideClientKey" => TermDefinition.new("provideClientKey", id: "https://www.w3.org/ns/activitystreams#provideClientKey", type_mapping: "@id"), + "proxyUrl" => TermDefinition.new("proxyUrl", id: "https://www.w3.org/ns/activitystreams#proxyUrl", type_mapping: "@id"), + "published" => TermDefinition.new("published", id: "https://www.w3.org/ns/activitystreams#published", type_mapping: "http://www.w3.org/2001/XMLSchema#dateTime"), + "radius" => TermDefinition.new("radius", id: "https://www.w3.org/ns/activitystreams#radius", type_mapping: "http://www.w3.org/2001/XMLSchema#float"), + "rel" => TermDefinition.new("rel", id: "https://www.w3.org/ns/activitystreams#rel", simple: true), + "relationship" => TermDefinition.new("relationship", id: "https://www.w3.org/ns/activitystreams#relationship", type_mapping: "@id"), + "replies" => TermDefinition.new("replies", id: "https://www.w3.org/ns/activitystreams#replies", type_mapping: "@id"), + "result" => TermDefinition.new("result", id: "https://www.w3.org/ns/activitystreams#result", type_mapping: "@id"), + "sharedInbox" => TermDefinition.new("sharedInbox", id: "https://www.w3.org/ns/activitystreams#sharedInbox", type_mapping: "@id"), + "signClientKey" => TermDefinition.new("signClientKey", id: "https://www.w3.org/ns/activitystreams#signClientKey", type_mapping: "@id"), + "source" => TermDefinition.new("source", id: "https://www.w3.org/ns/activitystreams#source", simple: true), + "startIndex" => TermDefinition.new("startIndex", id: "https://www.w3.org/ns/activitystreams#startIndex", type_mapping: "http://www.w3.org/2001/XMLSchema#nonNegativeInteger"), + "startTime" => TermDefinition.new("startTime", id: "https://www.w3.org/ns/activitystreams#startTime", type_mapping: "http://www.w3.org/2001/XMLSchema#dateTime"), + "streams" => TermDefinition.new("streams", id: "https://www.w3.org/ns/activitystreams#streams", type_mapping: "@id"), + "subject" => TermDefinition.new("subject", id: "https://www.w3.org/ns/activitystreams#subject", type_mapping: "@id"), + "summary" => TermDefinition.new("summary", id: "https://www.w3.org/ns/activitystreams#summary", simple: true), + "summaryMap" => TermDefinition.new("summaryMap", id: "https://www.w3.org/ns/activitystreams#summary", container_mapping: "@language"), + "tag" => TermDefinition.new("tag", id: "https://www.w3.org/ns/activitystreams#tag", type_mapping: "@id"), + "target" => TermDefinition.new("target", id: "https://www.w3.org/ns/activitystreams#target", type_mapping: "@id"), + "to" => TermDefinition.new("to", id: "https://www.w3.org/ns/activitystreams#to", type_mapping: "@id"), + "totalItems" => TermDefinition.new("totalItems", id: "https://www.w3.org/ns/activitystreams#totalItems", type_mapping: "http://www.w3.org/2001/XMLSchema#nonNegativeInteger"), + "type" => TermDefinition.new("type", id: "@type", simple: true), + "units" => TermDefinition.new("units", id: "https://www.w3.org/ns/activitystreams#units", simple: true), + "updated" => TermDefinition.new("updated", id: "https://www.w3.org/ns/activitystreams#updated", type_mapping: "http://www.w3.org/2001/XMLSchema#dateTime"), + "uploadMedia" => TermDefinition.new("uploadMedia", id: "https://www.w3.org/ns/activitystreams#uploadMedia", type_mapping: "@id"), + "url" => TermDefinition.new("url", id: "https://www.w3.org/ns/activitystreams#url", type_mapping: "@id"), + "width" => TermDefinition.new("width", id: "https://www.w3.org/ns/activitystreams#width", type_mapping: "http://www.w3.org/2001/XMLSchema#nonNegativeInteger"), + "xsd" => TermDefinition.new("xsd", id: "http://www.w3.org/2001/XMLSchema#", simple: true, prefix: true) + }) + end +end diff --git a/lib/json_ld/identity.rb b/lib/json_ld/identity.rb new file mode 100644 index 000000000..cfe50b956 --- /dev/null +++ b/lib/json_ld/identity.rb @@ -0,0 +1,86 @@ +# -*- encoding: utf-8 -*- +# frozen_string_literal: true +# This file generated automatically from https://w3id.org/identity/v1 +require 'json/ld' +class JSON::LD::Context + add_preloaded("https://w3id.org/identity/v1") do + new(processingMode: "json-ld-1.0", term_definitions: { + "Credential" => TermDefinition.new("Credential", id: "https://w3id.org/credentials#Credential", simple: true), + "CryptographicKey" => TermDefinition.new("CryptographicKey", id: "https://w3id.org/security#Key", simple: true), + "CryptographicKeyCredential" => TermDefinition.new("CryptographicKeyCredential", id: "https://w3id.org/credentials#CryptographicKeyCredential", simple: true), + "EncryptedMessage" => TermDefinition.new("EncryptedMessage", id: "https://w3id.org/security#EncryptedMessage", simple: true), + "GraphSignature2012" => TermDefinition.new("GraphSignature2012", id: "https://w3id.org/security#GraphSignature2012", simple: true), + "Group" => TermDefinition.new("Group", id: "https://www.w3.org/ns/activitystreams#Group", simple: true), + "Identity" => TermDefinition.new("Identity", id: "https://w3id.org/identity#Identity", simple: true), + "LinkedDataSignature2015" => TermDefinition.new("LinkedDataSignature2015", id: "https://w3id.org/security#LinkedDataSignature2015", simple: true), + "Organization" => TermDefinition.new("Organization", id: "http://schema.org/Organization", simple: true), + "Person" => TermDefinition.new("Person", id: "http://schema.org/Person", simple: true), + "PostalAddress" => TermDefinition.new("PostalAddress", id: "http://schema.org/PostalAddress", simple: true), + "about" => TermDefinition.new("about", id: "http://schema.org/about", type_mapping: "@id"), + "accessControl" => TermDefinition.new("accessControl", id: "https://w3id.org/permissions#accessControl", type_mapping: "@id"), + "address" => TermDefinition.new("address", id: "http://schema.org/address", type_mapping: "@id"), + "addressCountry" => TermDefinition.new("addressCountry", id: "http://schema.org/addressCountry", simple: true), + "addressLocality" => TermDefinition.new("addressLocality", id: "http://schema.org/addressLocality", simple: true), + "addressRegion" => TermDefinition.new("addressRegion", id: "http://schema.org/addressRegion", simple: true), + "cipherAlgorithm" => TermDefinition.new("cipherAlgorithm", id: "https://w3id.org/security#cipherAlgorithm", simple: true), + "cipherData" => TermDefinition.new("cipherData", id: "https://w3id.org/security#cipherData", simple: true), + "cipherKey" => TermDefinition.new("cipherKey", id: "https://w3id.org/security#cipherKey", simple: true), + "claim" => TermDefinition.new("claim", id: "https://w3id.org/credentials#claim", type_mapping: "@id"), + "comment" => TermDefinition.new("comment", id: "http://www.w3.org/2000/01/rdf-schema#comment", simple: true), + "created" => TermDefinition.new("created", id: "http://purl.org/dc/terms/created", type_mapping: "http://www.w3.org/2001/XMLSchema#dateTime"), + "creator" => TermDefinition.new("creator", id: "http://purl.org/dc/terms/creator", type_mapping: "@id"), + "cred" => TermDefinition.new("cred", id: "https://w3id.org/credentials#", simple: true, prefix: true), + "credential" => TermDefinition.new("credential", id: "https://w3id.org/credentials#credential", type_mapping: "@id"), + "dc" => TermDefinition.new("dc", id: "http://purl.org/dc/terms/", simple: true, prefix: true), + "description" => TermDefinition.new("description", id: "http://schema.org/description", simple: true), + "digestAlgorithm" => TermDefinition.new("digestAlgorithm", id: "https://w3id.org/security#digestAlgorithm", simple: true), + "digestValue" => TermDefinition.new("digestValue", id: "https://w3id.org/security#digestValue", simple: true), + "domain" => TermDefinition.new("domain", id: "https://w3id.org/security#domain", simple: true), + "email" => TermDefinition.new("email", id: "http://schema.org/email", simple: true), + "expires" => TermDefinition.new("expires", id: "https://w3id.org/security#expiration", type_mapping: "http://www.w3.org/2001/XMLSchema#dateTime"), + "familyName" => TermDefinition.new("familyName", id: "http://schema.org/familyName", simple: true), + "givenName" => TermDefinition.new("givenName", id: "http://schema.org/givenName", simple: true), + "id" => TermDefinition.new("id", id: "@id", simple: true), + "identity" => TermDefinition.new("identity", id: "https://w3id.org/identity#", simple: true, prefix: true), + "identityService" => TermDefinition.new("identityService", id: "https://w3id.org/identity#identityService", type_mapping: "@id"), + "idp" => TermDefinition.new("idp", id: "https://w3id.org/identity#idp", type_mapping: "@id"), + "image" => TermDefinition.new("image", id: "http://schema.org/image", type_mapping: "@id"), + "initializationVector" => TermDefinition.new("initializationVector", id: "https://w3id.org/security#initializationVector", simple: true), + "issued" => TermDefinition.new("issued", id: "https://w3id.org/credentials#issued", type_mapping: "http://www.w3.org/2001/XMLSchema#dateTime"), + "issuer" => TermDefinition.new("issuer", id: "https://w3id.org/credentials#issuer", type_mapping: "@id"), + "label" => TermDefinition.new("label", id: "http://www.w3.org/2000/01/rdf-schema#label", simple: true), + "member" => TermDefinition.new("member", id: "http://schema.org/member", type_mapping: "@id"), + "memberOf" => TermDefinition.new("memberOf", id: "http://schema.org/memberOf", type_mapping: "@id"), + "name" => TermDefinition.new("name", id: "http://schema.org/name", simple: true), + "nonce" => TermDefinition.new("nonce", id: "https://w3id.org/security#nonce", simple: true), + "normalizationAlgorithm" => TermDefinition.new("normalizationAlgorithm", id: "https://w3id.org/security#normalizationAlgorithm", simple: true), + "owner" => TermDefinition.new("owner", id: "https://w3id.org/security#owner", type_mapping: "@id"), + "password" => TermDefinition.new("password", id: "https://w3id.org/security#password", simple: true), + "paymentProcessor" => TermDefinition.new("paymentProcessor", id: "https://w3id.org/payswarm#processor", simple: true), + "perm" => TermDefinition.new("perm", id: "https://w3id.org/permissions#", simple: true, prefix: true), + "postalCode" => TermDefinition.new("postalCode", id: "http://schema.org/postalCode", simple: true), + "preferences" => TermDefinition.new("preferences", id: "https://w3id.org/payswarm#preferences", type_mapping: "@vocab"), + "privateKey" => TermDefinition.new("privateKey", id: "https://w3id.org/security#privateKey", type_mapping: "@id"), + "privateKeyPem" => TermDefinition.new("privateKeyPem", id: "https://w3id.org/security#privateKeyPem", simple: true), + "ps" => TermDefinition.new("ps", id: "https://w3id.org/payswarm#", simple: true, prefix: true), + "publicKey" => TermDefinition.new("publicKey", id: "https://w3id.org/security#publicKey", type_mapping: "@id"), + "publicKeyPem" => TermDefinition.new("publicKeyPem", id: "https://w3id.org/security#publicKeyPem", simple: true), + "publicKeyService" => TermDefinition.new("publicKeyService", id: "https://w3id.org/security#publicKeyService", type_mapping: "@id"), + "rdf" => TermDefinition.new("rdf", id: "http://www.w3.org/1999/02/22-rdf-syntax-ns#", simple: true, prefix: true), + "rdfs" => TermDefinition.new("rdfs", id: "http://www.w3.org/2000/01/rdf-schema#", simple: true, prefix: true), + "recipient" => TermDefinition.new("recipient", id: "https://w3id.org/credentials#recipient", type_mapping: "@id"), + "revoked" => TermDefinition.new("revoked", id: "https://w3id.org/security#revoked", type_mapping: "http://www.w3.org/2001/XMLSchema#dateTime"), + "schema" => TermDefinition.new("schema", id: "http://schema.org/", simple: true, prefix: true), + "sec" => TermDefinition.new("sec", id: "https://w3id.org/security#", simple: true, prefix: true), + "signature" => TermDefinition.new("signature", id: "https://w3id.org/security#signature", simple: true), + "signatureAlgorithm" => TermDefinition.new("signatureAlgorithm", id: "https://w3id.org/security#signatureAlgorithm", simple: true), + "signatureValue" => TermDefinition.new("signatureValue", id: "https://w3id.org/security#signatureValue", simple: true), + "streetAddress" => TermDefinition.new("streetAddress", id: "http://schema.org/streetAddress", simple: true), + "title" => TermDefinition.new("title", id: "http://purl.org/dc/terms/title", simple: true), + "type" => TermDefinition.new("type", id: "@type", simple: true), + "url" => TermDefinition.new("url", id: "http://schema.org/url", type_mapping: "@id"), + "writePermission" => TermDefinition.new("writePermission", id: "https://w3id.org/permissions#writePermission", type_mapping: "@id"), + "xsd" => TermDefinition.new("xsd", id: "http://www.w3.org/2001/XMLSchema#", simple: true, prefix: true) + }) + end +end diff --git a/lib/json_ld/security.rb b/lib/json_ld/security.rb new file mode 100644 index 000000000..1230206f0 --- /dev/null +++ b/lib/json_ld/security.rb @@ -0,0 +1,50 @@ +# -*- encoding: utf-8 -*- +# frozen_string_literal: true +# This file generated automatically from https://w3id.org/security/v1 +require 'json/ld' +class JSON::LD::Context + add_preloaded("https://w3id.org/security/v1") do + new(processingMode: "json-ld-1.0", term_definitions: { + "CryptographicKey" => TermDefinition.new("CryptographicKey", id: "https://w3id.org/security#Key", simple: true), + "EcdsaKoblitzSignature2016" => TermDefinition.new("EcdsaKoblitzSignature2016", id: "https://w3id.org/security#EcdsaKoblitzSignature2016", simple: true), + "EncryptedMessage" => TermDefinition.new("EncryptedMessage", id: "https://w3id.org/security#EncryptedMessage", simple: true), + "GraphSignature2012" => TermDefinition.new("GraphSignature2012", id: "https://w3id.org/security#GraphSignature2012", simple: true), + "LinkedDataSignature2015" => TermDefinition.new("LinkedDataSignature2015", id: "https://w3id.org/security#LinkedDataSignature2015", simple: true), + "LinkedDataSignature2016" => TermDefinition.new("LinkedDataSignature2016", id: "https://w3id.org/security#LinkedDataSignature2016", simple: true), + "authenticationTag" => TermDefinition.new("authenticationTag", id: "https://w3id.org/security#authenticationTag", simple: true), + "canonicalizationAlgorithm" => TermDefinition.new("canonicalizationAlgorithm", id: "https://w3id.org/security#canonicalizationAlgorithm", simple: true), + "cipherAlgorithm" => TermDefinition.new("cipherAlgorithm", id: "https://w3id.org/security#cipherAlgorithm", simple: true), + "cipherData" => TermDefinition.new("cipherData", id: "https://w3id.org/security#cipherData", simple: true), + "cipherKey" => TermDefinition.new("cipherKey", id: "https://w3id.org/security#cipherKey", simple: true), + "created" => TermDefinition.new("created", id: "http://purl.org/dc/terms/created", type_mapping: "http://www.w3.org/2001/XMLSchema#dateTime"), + "creator" => TermDefinition.new("creator", id: "http://purl.org/dc/terms/creator", type_mapping: "@id"), + "dc" => TermDefinition.new("dc", id: "http://purl.org/dc/terms/", simple: true, prefix: true), + "digestAlgorithm" => TermDefinition.new("digestAlgorithm", id: "https://w3id.org/security#digestAlgorithm", simple: true), + "digestValue" => TermDefinition.new("digestValue", id: "https://w3id.org/security#digestValue", simple: true), + "domain" => TermDefinition.new("domain", id: "https://w3id.org/security#domain", simple: true), + "encryptionKey" => TermDefinition.new("encryptionKey", id: "https://w3id.org/security#encryptionKey", simple: true), + "expiration" => TermDefinition.new("expiration", id: "https://w3id.org/security#expiration", type_mapping: "http://www.w3.org/2001/XMLSchema#dateTime"), + "expires" => TermDefinition.new("expires", id: "https://w3id.org/security#expiration", type_mapping: "http://www.w3.org/2001/XMLSchema#dateTime"), + "id" => TermDefinition.new("id", id: "@id", simple: true), + "initializationVector" => TermDefinition.new("initializationVector", id: "https://w3id.org/security#initializationVector", simple: true), + "iterationCount" => TermDefinition.new("iterationCount", id: "https://w3id.org/security#iterationCount", simple: true), + "nonce" => TermDefinition.new("nonce", id: "https://w3id.org/security#nonce", simple: true), + "normalizationAlgorithm" => TermDefinition.new("normalizationAlgorithm", id: "https://w3id.org/security#normalizationAlgorithm", simple: true), + "owner" => TermDefinition.new("owner", id: "https://w3id.org/security#owner", type_mapping: "@id"), + "password" => TermDefinition.new("password", id: "https://w3id.org/security#password", simple: true), + "privateKey" => TermDefinition.new("privateKey", id: "https://w3id.org/security#privateKey", type_mapping: "@id"), + "privateKeyPem" => TermDefinition.new("privateKeyPem", id: "https://w3id.org/security#privateKeyPem", simple: true), + "publicKey" => TermDefinition.new("publicKey", id: "https://w3id.org/security#publicKey", type_mapping: "@id"), + "publicKeyPem" => TermDefinition.new("publicKeyPem", id: "https://w3id.org/security#publicKeyPem", simple: true), + "publicKeyService" => TermDefinition.new("publicKeyService", id: "https://w3id.org/security#publicKeyService", type_mapping: "@id"), + "revoked" => TermDefinition.new("revoked", id: "https://w3id.org/security#revoked", type_mapping: "http://www.w3.org/2001/XMLSchema#dateTime"), + "salt" => TermDefinition.new("salt", id: "https://w3id.org/security#salt", simple: true), + "sec" => TermDefinition.new("sec", id: "https://w3id.org/security#", simple: true, prefix: true), + "signature" => TermDefinition.new("signature", id: "https://w3id.org/security#signature", simple: true), + "signatureAlgorithm" => TermDefinition.new("signatureAlgorithm", id: "https://w3id.org/security#signingAlgorithm", simple: true), + "signatureValue" => TermDefinition.new("signatureValue", id: "https://w3id.org/security#signatureValue", simple: true), + "type" => TermDefinition.new("type", id: "@type", simple: true), + "xsd" => TermDefinition.new("xsd", id: "http://www.w3.org/2001/XMLSchema#", simple: true, prefix: true) + }) + end +end diff --git a/lib/mastodon/unique_retry_job_middleware.rb b/lib/mastodon/unique_retry_job_middleware.rb new file mode 100644 index 000000000..75da8a0c9 --- /dev/null +++ b/lib/mastodon/unique_retry_job_middleware.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class Mastodon::UniqueRetryJobMiddleware + def call(_worker_class, item, _queue, _redis_pool) + return if item['unique_retry'] && retried?(item) + yield + end + + private + + def retried?(item) + # Use unique digest key of SidekiqUniqueJobs + unique_key = SidekiqUniqueJobs::UNIQUE_DIGEST_KEY + unique_digest = item[unique_key] + class_name = item['class'] + retries = Sidekiq::RetrySet.new + + retries.any? { |job| job.item['class'] == class_name && job.item[unique_key] == unique_digest } + end +end diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb index 381e9aac9..de2516d6c 100644 --- a/lib/mastodon/version.rb +++ b/lib/mastodon/version.rb @@ -9,11 +9,11 @@ module Mastodon end def minor - 5 + 6 end def patch - 1 + 0 end def pre @@ -21,7 +21,7 @@ module Mastodon end def flags - '' + 'rc2' end def to_a @@ -31,5 +31,22 @@ module Mastodon def to_s [to_a.join('.'), flags].join end + + def source_base_url + 'https://github.com/tootsuite/mastodon' + end + + # specify git tag or commit hash here + def source_tag + nil + end + + def source_url + if source_tag + "#{source_base_url}/tree/#{source_tag}" + else + source_base_url + end + end end end diff --git a/lib/tasks/mastodon.rake b/lib/tasks/mastodon.rake index 226523554..307bc240d 100644 --- a/lib/tasks/mastodon.rake +++ b/lib/tasks/mastodon.rake @@ -111,10 +111,7 @@ namespace :mastodon do namespace :push do desc 'Unsubscribes from PuSH updates of feeds nobody follows locally' task clear: :environment do - Account.remote.without_followers.where.not(subscription_expires_at: nil).find_each do |a| - Rails.logger.debug "PuSH unsubscribing from #{a.acct}" - UnsubscribeService.new.call(a) - end + Pubsubhubbub::UnsubscribeWorker.push_bulk(Account.remote.without_followers.where.not(subscription_expires_at: nil).pluck(:id)) end desc 'Re-subscribes to soon expiring PuSH subscriptions (deprecated)' @@ -273,5 +270,35 @@ namespace :mastodon do ActiveRecord::Base.connection.execute('UPDATE media_attachments SET account_id = NULL FROM media_attachments ma LEFT JOIN accounts a ON a.id = ma.account_id WHERE media_attachments.id = ma.id AND ma.account_id IS NOT NULL AND a.id IS NULL') ActiveRecord::Base.connection.execute('UPDATE reports SET action_taken_by_account_id = NULL FROM reports r LEFT JOIN accounts a ON a.id = r.action_taken_by_account_id WHERE reports.id = r.id AND r.action_taken_by_account_id IS NOT NULL AND a.id IS NULL') end + + desc 'Remove deprecated preview cards' + task remove_deprecated_preview_cards: :environment do + next unless ActiveRecord::Base.connection.table_exists? 'deprecated_preview_cards' + + class DeprecatedPreviewCard < ActiveRecord::Base + self.inheritance_column = false + + path = '/preview_cards/:attachment/:id_partition/:style/:filename' + if ENV['S3_ENABLED'] != 'true' + path = (ENV['PAPERCLIP_ROOT_PATH'] || ':rails_root/public/system') + path + end + + has_attached_file :image, styles: { original: '280x120>' }, convert_options: { all: '-quality 80 -strip' }, path: path + end + + puts 'Delete records and associated files from deprecated preview cards? [y/N]: ' + confirm = STDIN.gets.chomp + + if confirm.casecmp?('y') + DeprecatedPreviewCard.in_batches.destroy_all + + puts 'Drop deprecated preview cards table? [y/N]: ' + confirm = STDIN.gets.chomp + + if confirm.casecmp?('y') + ActiveRecord::Migration.drop_table :deprecated_preview_cards + end + end + end end end diff --git a/package.json b/package.json index 5fdd491ee..228dd1f25 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,7 @@ "react-dom": "^15.6.1", "react-immutable-proptypes": "^2.1.0", "react-immutable-pure-component": "^1.0.0", - "react-intl": "^2.3.0", + "react-intl": "^2.4.0", "react-motion": "^0.5.0", "react-notification": "^6.7.1", "react-redux": "^5.0.4", diff --git a/spec/controllers/accounts_controller_spec.rb b/spec/controllers/accounts_controller_spec.rb index d61c8c9bd..92f888590 100644 --- a/spec/controllers/accounts_controller_spec.rb +++ b/spec/controllers/accounts_controller_spec.rb @@ -10,6 +10,13 @@ RSpec.describe AccountsController, type: :controller do let!(:status2) { Status.create!(account: alice, text: 'Boop', thread: status1) } let!(:status3) { Status.create!(account: alice, text: 'Picture!') } let!(:status4) { Status.create!(account: alice, text: 'Mentioning @alice') } + let!(:status5) { Status.create!(account: alice, text: 'Kitsune') } + let!(:status6) { Status.create!(account: alice, text: 'Neko') } + let!(:status7) { Status.create!(account: alice, text: 'Tanuki') } + + let!(:status_pin1) { StatusPin.create!(account: alice, status: status5, created_at: 5.days.ago) } + let!(:status_pin2) { StatusPin.create!(account: alice, status: status6, created_at: 2.years.ago) } + let!(:status_pin3) { StatusPin.create!(account: alice, status: status7, created_at: 10.minutes.ago) } before do status3.media_attachments.create!(account: alice, file: fixture_file_upload('files/attachment.jpg', 'image/jpeg')) @@ -48,9 +55,35 @@ RSpec.describe AccountsController, type: :controller do it 'returns http success with Activity Streams 2.0' do expect(response).to have_http_status(:success) end + + it 'returns application/activity+json' do + expect(response.content_type).to eq 'application/activity+json' + end end - context 'html' do + context 'html without since_id nor max_id' do + before do + get :show, params: { username: alice.username } + end + + it 'assigns @account' do + expect(assigns(:account)).to eq alice + end + + it 'assigns @pinned_statuses' do + pinned_statuses = assigns(:pinned_statuses).to_a + expect(pinned_statuses.size).to eq 3 + expect(pinned_statuses[0]).to eq status7 + expect(pinned_statuses[1]).to eq status5 + expect(pinned_statuses[2]).to eq status6 + end + + it 'returns http success' do + expect(response).to have_http_status(:success) + end + end + + context 'html with since_id and max_id' do before do get :show, params: { username: alice.username, max_id: status4.id, since_id: status1.id } end @@ -66,6 +99,11 @@ RSpec.describe AccountsController, type: :controller do expect(statuses[1]).to eq status2 end + it 'assigns an empty array to @pinned_statuses' do + pinned_statuses = assigns(:pinned_statuses).to_a + expect(pinned_statuses.size).to eq 0 + end + it 'returns http success' do expect(response).to have_http_status(:success) end diff --git a/spec/controllers/activitypub/inboxes_controller_spec.rb b/spec/controllers/activitypub/inboxes_controller_spec.rb new file mode 100644 index 000000000..5c12fea7d --- /dev/null +++ b/spec/controllers/activitypub/inboxes_controller_spec.rb @@ -0,0 +1,7 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::InboxesController, type: :controller do + describe 'POST #create' do + pending + end +end diff --git a/spec/controllers/activitypub/outboxes_controller_spec.rb b/spec/controllers/activitypub/outboxes_controller_spec.rb new file mode 100644 index 000000000..a25998021 --- /dev/null +++ b/spec/controllers/activitypub/outboxes_controller_spec.rb @@ -0,0 +1,23 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::OutboxesController, type: :controller do + let!(:account) { Fabricate(:account) } + + before do + Fabricate(:status, account: account) + end + + describe 'GET #show' do + before do + get :show, params: { account_username: account.username } + end + + it 'returns http success' do + expect(response).to have_http_status(:success) + end + + it 'returns application/activity+json' do + expect(response.content_type).to eq 'application/activity+json' + end + end +end diff --git a/spec/controllers/api/oembed_controller_spec.rb b/spec/controllers/api/oembed_controller_spec.rb index 43631a7e5..7af4a6a5b 100644 --- a/spec/controllers/api/oembed_controller_spec.rb +++ b/spec/controllers/api/oembed_controller_spec.rb @@ -8,6 +8,7 @@ RSpec.describe Api::OEmbedController, type: :controller do describe 'GET #show' do before do + request.host = Rails.configuration.x.local_domain get :show, params: { url: account_stream_entry_url(alice, status.stream_entry) }, format: :json end diff --git a/spec/controllers/api/subscriptions_controller_spec.rb b/spec/controllers/api/subscriptions_controller_spec.rb index 76f9740ca..d90da9e32 100644 --- a/spec/controllers/api/subscriptions_controller_spec.rb +++ b/spec/controllers/api/subscriptions_controller_spec.rb @@ -38,19 +38,19 @@ RSpec.describe Api::SubscriptionsController, type: :controller do before do stub_request(:post, "https://quitter.no/main/push/hub").to_return(:status => 200, :body => "", :headers => {}) stub_request(:get, "https://quitter.no/avatar/7477-300-20160211190340.png").to_return(request_fixture('avatar.txt')) - stub_request(:head, "https://quitter.no/notice/1269244").to_return(status: 404) - stub_request(:head, "https://quitter.no/notice/1265331").to_return(status: 404) - stub_request(:head, "https://community.highlandarrow.com/notice/54411").to_return(status: 404) - stub_request(:head, "https://community.highlandarrow.com/notice/53857").to_return(status: 404) - stub_request(:head, "https://community.highlandarrow.com/notice/51852").to_return(status: 404) - stub_request(:head, "https://social.umeahackerspace.se/notice/424348").to_return(status: 404) - stub_request(:head, "https://community.highlandarrow.com/notice/50467").to_return(status: 404) - stub_request(:head, "https://quitter.no/notice/1243309").to_return(status: 404) - stub_request(:head, "https://quitter.no/user/7477").to_return(status: 404) - stub_request(:head, "https://community.highlandarrow.com/user/1").to_return(status: 404) - stub_request(:head, "https://social.umeahackerspace.se/user/2").to_return(status: 404) - stub_request(:head, "https://gs.kawa-kun.com/user/2").to_return(status: 404) - stub_request(:head, "https://mastodon.social/users/Gargron").to_return(status: 404) + stub_request(:get, "https://quitter.no/notice/1269244").to_return(status: 404) + stub_request(:get, "https://quitter.no/notice/1265331").to_return(status: 404) + stub_request(:get, "https://community.highlandarrow.com/notice/54411").to_return(status: 404) + stub_request(:get, "https://community.highlandarrow.com/notice/53857").to_return(status: 404) + stub_request(:get, "https://community.highlandarrow.com/notice/51852").to_return(status: 404) + stub_request(:get, "https://social.umeahackerspace.se/notice/424348").to_return(status: 404) + stub_request(:get, "https://community.highlandarrow.com/notice/50467").to_return(status: 404) + stub_request(:get, "https://quitter.no/notice/1243309").to_return(status: 404) + stub_request(:get, "https://quitter.no/user/7477").to_return(status: 404) + stub_request(:any, "https://community.highlandarrow.com/user/1").to_return(status: 404) + stub_request(:any, "https://social.umeahackerspace.se/user/2").to_return(status: 404) + stub_request(:any, "https://gs.kawa-kun.com/user/2").to_return(status: 404) + stub_request(:any, "https://mastodon.social/users/Gargron").to_return(status: 404) request.env['HTTP_X_HUB_SIGNATURE'] = "sha1=#{OpenSSL::HMAC.hexdigest('sha1', 'abc', feed)}" request.env['RAW_POST_DATA'] = feed diff --git a/spec/controllers/api/v1/accounts/credentials_controller_spec.rb b/spec/controllers/api/v1/accounts/credentials_controller_spec.rb index 3f655c7b2..461b8b34b 100644 --- a/spec/controllers/api/v1/accounts/credentials_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/credentials_controller_spec.rb @@ -4,52 +4,79 @@ describe Api::V1::Accounts::CredentialsController do render_views let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } - let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'write') } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read write') } - before do - allow(controller).to receive(:doorkeeper_token) { token } - end - - describe 'GET #show' do - it 'returns http success' do - get :show - expect(response).to have_http_status(:success) + context 'with an oauth token' do + before do + allow(controller).to receive(:doorkeeper_token) { token } end - end - - describe 'PATCH #update' do - describe 'with valid data' do - before do - patch :update, params: { - display_name: "Alice Isn't Dead", - note: "Hi!\n\nToot toot!", - avatar: fixture_file_upload('files/avatar.gif', 'image/gif'), - header: fixture_file_upload('files/attachment.jpg', 'image/jpeg'), - } - end + describe 'GET #show' do it 'returns http success' do + get :show expect(response).to have_http_status(:success) end + end + + describe 'PATCH #update' do + describe 'with valid data' do + before do + allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_async) + + patch :update, params: { + display_name: "Alice Isn't Dead", + note: "Hi!\n\nToot toot!", + avatar: fixture_file_upload('files/avatar.gif', 'image/gif'), + header: fixture_file_upload('files/attachment.jpg', 'image/jpeg'), + } + end + + it 'returns http success' do + expect(response).to have_http_status(:success) + end - it 'updates account info' do - user.account.reload + it 'updates account info' do + user.account.reload - expect(user.account.display_name).to eq("Alice Isn't Dead") - expect(user.account.note).to eq("Hi!\n\nToot toot!") - expect(user.account.avatar).to exist - expect(user.account.header).to exist + expect(user.account.display_name).to eq("Alice Isn't Dead") + expect(user.account.note).to eq("Hi!\n\nToot toot!") + expect(user.account.avatar).to exist + expect(user.account.header).to exist + end + + it 'queues up an account update distribution' do + expect(ActivityPub::UpdateDistributionWorker).to have_received(:perform_async).with(user.account_id) + end + end + + describe 'with invalid data' do + before do + patch :update, params: { note: 'This is too long. ' * 10 } + end + + it 'returns http unprocessable entity' do + expect(response).to have_http_status(:unprocessable_entity) + end end end + end + + context 'without an oauth token' do + before do + allow(controller).to receive(:doorkeeper_token) { nil } + end - describe 'with invalid data' do - before do - # note length limit is 501, presently hardcoded, so give it 510 to fail - patch :update, params: { note: '1234567890' * 51 } + describe 'GET #show' do + it 'returns http unauthorized' do + get :show + expect(response).to have_http_status(:unauthorized) end + end - it 'returns http unprocessable entity' do - expect(response).to have_http_status(:unprocessable_entity) + describe 'PATCH #update' do + it 'returns http unauthorized' do + patch :update, params: { note: 'Foo' } + expect(response).to have_http_status(:unauthorized) end end end diff --git a/spec/controllers/api/v1/accounts/relationships_controller_spec.rb b/spec/controllers/api/v1/accounts/relationships_controller_spec.rb index 3a9607317..a9073b197 100644 --- a/spec/controllers/api/v1/accounts/relationships_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/relationships_controller_spec.rb @@ -50,14 +50,14 @@ describe Api::V1::Accounts::RelationshipsController do json = body_as_json expect(json).to be_a Enumerable - expect(json.first[:id]).to be simon.id + expect(json.first[:id]).to eq simon.id expect(json.first[:following]).to be true expect(json.first[:followed_by]).to be false expect(json.first[:muting]).to be false expect(json.first[:requested]).to be false expect(json.first[:domain_blocking]).to be false - expect(json.second[:id]).to be lewis.id + expect(json.second[:id]).to eq lewis.id expect(json.second[:following]).to be false expect(json.second[:followed_by]).to be true expect(json.second[:muting]).to be false diff --git a/spec/controllers/api/v1/accounts/statuses_controller_spec.rb b/spec/controllers/api/v1/accounts/statuses_controller_spec.rb index 8b4fd6a5b..c49a77ac3 100644 --- a/spec/controllers/api/v1/accounts/statuses_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/statuses_controller_spec.rb @@ -18,21 +18,37 @@ describe Api::V1::Accounts::StatusesController do expect(response).to have_http_status(:success) expect(response.headers['Link'].links.size).to eq(2) end - end - describe 'GET #index with only media' do - it 'returns http success' do - get :index, params: { account_id: user.account.id, only_media: true } + context 'with only media' do + it 'returns http success' do + get :index, params: { account_id: user.account.id, only_media: true } - expect(response).to have_http_status(:success) + expect(response).to have_http_status(:success) + end end - end - describe 'GET #index with exclude replies' do - it 'returns http success' do - get :index, params: { account_id: user.account.id, exclude_replies: true } + context 'with exclude replies' do + before do + Fabricate(:status, account: user.account, thread: Fabricate(:status)) + end - expect(response).to have_http_status(:success) + it 'returns http success' do + get :index, params: { account_id: user.account.id, exclude_replies: true } + + expect(response).to have_http_status(:success) + end + end + + context 'with only pinned' do + before do + Fabricate(:status_pin, account: user.account, status: Fabricate(:status, account: user.account)) + end + + it 'returns http success' do + get :index, params: { account_id: user.account.id, pinned: true } + + expect(response).to have_http_status(:success) + end end end end diff --git a/spec/controllers/api/v1/accounts_controller_spec.rb b/spec/controllers/api/v1/accounts_controller_spec.rb index c13509e7b..05df2f844 100644 --- a/spec/controllers/api/v1/accounts_controller_spec.rb +++ b/spec/controllers/api/v1/accounts_controller_spec.rb @@ -28,6 +28,13 @@ RSpec.describe Api::V1::AccountsController, type: :controller do expect(response).to have_http_status(:success) end + it 'returns JSON with following=true and requested=false' do + json = body_as_json + + expect(json[:following]).to be true + expect(json[:requested]).to be false + end + it 'creates a following relation between user and target user' do expect(user.account.following?(other_account)).to be true end diff --git a/spec/controllers/api/v1/favourites_controller_spec.rb b/spec/controllers/api/v1/favourites_controller_spec.rb index 3de045377..46cf70f4d 100644 --- a/spec/controllers/api/v1/favourites_controller_spec.rb +++ b/spec/controllers/api/v1/favourites_controller_spec.rb @@ -70,8 +70,7 @@ RSpec.describe Api::V1::FavouritesController, type: :controller do it 'does not add pagination headers if not necessary' do get :index - expect(response.headers['Link'].find_link(['rel', 'next'])).to eq nil - expect(response.headers['Link'].find_link(['rel', 'prev'])).to eq nil + expect(response.headers['Link']).to eq nil end end end diff --git a/spec/controllers/api/v1/statuses/pins_controller_spec.rb b/spec/controllers/api/v1/statuses/pins_controller_spec.rb new file mode 100644 index 000000000..2e170da24 --- /dev/null +++ b/spec/controllers/api/v1/statuses/pins_controller_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Api::V1::Statuses::PinsController do + render_views + + let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:app) { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'write', application: app) } + + context 'with an oauth token' do + before do + allow(controller).to receive(:doorkeeper_token) { token } + end + + describe 'POST #create' do + let(:status) { Fabricate(:status, account: user.account) } + + before do + post :create, params: { status_id: status.id } + end + + it 'returns http success' do + expect(response).to have_http_status(:success) + end + + it 'updates the pinned attribute' do + expect(user.account.pinned?(status)).to be true + end + + it 'return json with updated attributes' do + hash_body = body_as_json + + expect(hash_body[:id]).to eq status.id + expect(hash_body[:pinned]).to be true + end + end + + describe 'POST #destroy' do + let(:status) { Fabricate(:status, account: user.account) } + + before do + Fabricate(:status_pin, status: status, account: user.account) + post :destroy, params: { status_id: status.id } + end + + it 'returns http success' do + expect(response).to have_http_status(:success) + end + + it 'updates the pinned attribute' do + expect(user.account.pinned?(status)).to be false + end + end + end +end diff --git a/spec/controllers/concerns/account_controller_concern_spec.rb b/spec/controllers/concerns/account_controller_concern_spec.rb index bdc181edc..ae46f9ba6 100644 --- a/spec/controllers/concerns/account_controller_concern_spec.rb +++ b/spec/controllers/concerns/account_controller_concern_spec.rb @@ -33,7 +33,7 @@ describe ApplicationController, type: :controller do it 'sets link headers' do account = Fabricate(:account, username: 'username') get 'success', params: { account_username: 'username' } - expect(response.headers['Link'].to_s).to eq '<http://test.host/.well-known/webfinger?resource=acct%3Ausername%40cb6e6126.ngrok.io>; rel="lrdd"; type="application/xrd+xml", <http://test.host/users/username.atom>; rel="alternate"; type="application/atom+xml"' + expect(response.headers['Link'].to_s).to eq '<http://test.host/.well-known/webfinger?resource=acct%3Ausername%40cb6e6126.ngrok.io>; rel="lrdd"; type="application/xrd+xml", <http://test.host/users/username.atom>; rel="alternate"; type="application/atom+xml", <https://cb6e6126.ngrok.io/users/username>; rel="alternate"; type="application/activity+json"' end it 'returns http success' do diff --git a/spec/controllers/concerns/signature_verification_spec.rb b/spec/controllers/concerns/signature_verification_spec.rb index b371795ab..64648621e 100644 --- a/spec/controllers/concerns/signature_verification_spec.rb +++ b/spec/controllers/concerns/signature_verification_spec.rb @@ -16,7 +16,7 @@ describe ApplicationController, type: :controller do end before do - routes.draw { get 'success' => 'anonymous#success' } + routes.draw { match via: [:get, :post], 'success' => 'anonymous#success' } end context 'without signature header' do @@ -40,34 +40,74 @@ describe ApplicationController, type: :controller do context 'with signature header' do let!(:author) { Fabricate(:account) } - before do - get :success + context 'without body' do + before do + get :success - fake_request = Request.new(:get, request.url) - fake_request.on_behalf_of(author) + fake_request = Request.new(:get, request.url) + fake_request.on_behalf_of(author) - request.headers.merge!(fake_request.headers) - end + request.headers.merge!(fake_request.headers) + end - describe '#signed_request?' do - it 'returns true' do - expect(controller.signed_request?).to be true + describe '#signed_request?' do + it 'returns true' do + expect(controller.signed_request?).to be true + end + end + + describe '#signed_request_account' do + it 'returns an account' do + expect(controller.signed_request_account).to eq author + end + + it 'returns nil when path does not match' do + request.path = '/alternative-path' + expect(controller.signed_request_account).to be_nil + end + + it 'returns nil when method does not match' do + post :success + expect(controller.signed_request_account).to be_nil + end end end - describe '#signed_request_account' do - it 'returns an account' do - expect(controller.signed_request_account).to eq author + context 'with body' do + before do + post :success, body: 'Hello world' + + fake_request = Request.new(:post, request.url, body: 'Hello world') + fake_request.on_behalf_of(author) + + request.headers.merge!(fake_request.headers) end - it 'returns nil when path does not match' do - request.path = '/alternative-path' - expect(controller.signed_request_account).to be_nil + describe '#signed_request?' do + it 'returns true' do + expect(controller.signed_request?).to be true + end end - it 'returns nil when method does not match' do - post :success - expect(controller.signed_request_account).to be_nil + describe '#signed_request_account' do + it 'returns an account' do + expect(controller.signed_request_account).to eq author + end + + it 'returns nil when path does not match' do + request.path = '/alternative-path' + expect(controller.signed_request_account).to be_nil + end + + it 'returns nil when method does not match' do + get :success + expect(controller.signed_request_account).to be_nil + end + + it 'returns nil when body has been tampered' do + request.headers['RAW_POST_DATA'] = 'doo doo doo' + expect(controller.signed_request_account).to be_nil + end end end end diff --git a/spec/controllers/remote_follow_controller_spec.rb b/spec/controllers/remote_follow_controller_spec.rb index 915c86f8e..86b1eb8d0 100644 --- a/spec/controllers/remote_follow_controller_spec.rb +++ b/spec/controllers/remote_follow_controller_spec.rb @@ -87,6 +87,14 @@ describe RemoteFollowController do expect(response).to render_template(:new) expect(response.body).to include(I18n.t('remote_follow.missing_resource')) end + + it 'renders new when occur HTTP::ConnectionError' do + allow(Goldfinger).to receive(:finger).with('acct:user@unknown').and_raise(HTTP::ConnectionError) + post :create, params: { account_username: @account.to_param, remote_follow: { acct: 'user@unknown' } } + + expect(response).to render_template(:new) + expect(response.body).to include(I18n.t('remote_follow.missing_resource')) + end end end diff --git a/spec/controllers/settings/applications_controller_spec.rb b/spec/controllers/settings/applications_controller_spec.rb new file mode 100644 index 000000000..ca66f8d23 --- /dev/null +++ b/spec/controllers/settings/applications_controller_spec.rb @@ -0,0 +1,188 @@ +require 'rails_helper' + +describe Settings::ApplicationsController do + render_views + + let!(:user) { Fabricate(:user) } + let!(:app) { Fabricate(:application, owner: user) } + + before do + sign_in user, scope: :user + end + + describe 'GET #index' do + let!(:other_app) { Fabricate(:application) } + + it 'shows apps' do + get :index + expect(response).to have_http_status(:success) + expect(assigns(:applications)).to include(app) + expect(assigns(:applications)).to_not include(other_app) + end + end + + + describe 'GET #show' do + it 'returns http success' do + get :show, params: { id: app.id } + expect(response).to have_http_status(:success) + expect(assigns[:application]).to eql(app) + end + + it 'returns 404 if you dont own app' do + app.update!(owner: nil) + + get :show, params: { id: app.id } + expect(response.status).to eq 404 + end + end + + describe 'GET #new' do + it 'works' do + get :new + expect(response).to have_http_status(:success) + end + end + + describe 'POST #create' do + context 'success (passed scopes as a String)' do + def call_create + post :create, params: { + doorkeeper_application: { + name: 'My New App', + redirect_uri: 'urn:ietf:wg:oauth:2.0:oob', + website: 'http://google.com', + scopes: 'read write follow' + } + } + response + end + + it 'creates an entry in the database' do + expect { call_create }.to change(Doorkeeper::Application, :count) + end + + it 'redirects back to applications page' do + expect(call_create).to redirect_to(settings_applications_path) + end + end + + context 'success (passed scopes as an Array)' do + def call_create + post :create, params: { + doorkeeper_application: { + name: 'My New App', + redirect_uri: 'urn:ietf:wg:oauth:2.0:oob', + website: 'http://google.com', + scopes: [ 'read', 'write', 'follow' ] + } + } + response + end + + it 'creates an entry in the database' do + expect { call_create }.to change(Doorkeeper::Application, :count) + end + + it 'redirects back to applications page' do + expect(call_create).to redirect_to(settings_applications_path) + end + end + + context 'failure' do + before do + post :create, params: { + doorkeeper_application: { + name: '', + redirect_uri: '', + website: '', + scopes: [] + } + } + end + + it 'returns http success' do + expect(response).to have_http_status(:success) + end + + it 'renders form again' do + expect(response).to render_template(:new) + end + end + end + + describe 'PATCH #update' do + context 'success' do + let(:opts) { + { + website: 'https://foo.bar/' + } + } + + def call_update + patch :update, params: { + id: app.id, + doorkeeper_application: opts + } + response + end + + it 'updates existing application' do + call_update + expect(app.reload.website).to eql(opts[:website]) + end + + it 'redirects back to applications page' do + expect(call_update).to redirect_to(settings_applications_path) + end + end + + context 'failure' do + before do + patch :update, params: { + id: app.id, + doorkeeper_application: { + name: '', + redirect_uri: '', + website: '', + scopes: [] + } + } + end + + it 'returns http success' do + expect(response).to have_http_status(:success) + end + + it 'renders form again' do + expect(response).to render_template(:show) + end + end + end + + describe 'destroy' do + before do + post :destroy, params: { id: app.id } + end + + it 'redirects back to applications page' do + expect(response).to redirect_to(settings_applications_path) + end + + it 'removes the app' do + expect(Doorkeeper::Application.find_by(id: app.id)).to be_nil + end + end + + describe 'regenerate' do + let(:token) { user.token_for_app(app) } + before do + expect(token).to_not be_nil + post :regenerate, params: { id: app.id } + end + + it 'should create new token' do + expect(user.token_for_app(app)).to_not eql(token) + end + end +end diff --git a/spec/controllers/settings/profiles_controller_spec.rb b/spec/controllers/settings/profiles_controller_spec.rb index e502dbda7..ee3315be6 100644 --- a/spec/controllers/settings/profiles_controller_spec.rb +++ b/spec/controllers/settings/profiles_controller_spec.rb @@ -17,11 +17,13 @@ RSpec.describe Settings::ProfilesController, type: :controller do describe 'PUT #update' do it 'updates the user profile' do + allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_async) account = Fabricate(:account, user: @user, display_name: 'Old name') put :update, params: { account: { display_name: 'New name' } } expect(account.reload.display_name).to eq 'New name' expect(response).to redirect_to(settings_profile_path) + expect(ActivityPub::UpdateDistributionWorker).to have_received(:perform_async).with(account.id) end end end diff --git a/spec/controllers/statuses_controller_spec.rb b/spec/controllers/statuses_controller_spec.rb index 88d365624..95fb4d594 100644 --- a/spec/controllers/statuses_controller_spec.rb +++ b/spec/controllers/statuses_controller_spec.rb @@ -30,6 +30,18 @@ describe StatusesController do end end + context 'status is a reblog' do + it 'redirects to the original status' do + original_account = Fabricate(:account, domain: 'example.com') + original_status = Fabricate(:status, account: original_account, uri: 'tag:example.com,2017:foo', url: 'https://example.com/123') + status = Fabricate(:status, reblog: original_status) + + get :show, params: { account_username: status.account.username, id: status.id } + + expect(response).to redirect_to(original_status.url) + end + end + context 'account is not suspended and status is permitted' do it 'assigns @account' do status = Fabricate(:status) diff --git a/spec/controllers/stream_entries_controller_spec.rb b/spec/controllers/stream_entries_controller_spec.rb index 2cc428e0c..f81e2be7b 100644 --- a/spec/controllers/stream_entries_controller_spec.rb +++ b/spec/controllers/stream_entries_controller_spec.rb @@ -21,7 +21,7 @@ RSpec.describe StreamEntriesController, type: :controller do get route, params: { account_username: alice.username, id: status.stream_entry.id } - expect(response.headers['Link'].to_s).to eq "<http://test.host/users/alice/updates/#{status.stream_entry.id}.atom>; rel=\"alternate\"; type=\"application/atom+xml\"" + expect(response.headers['Link'].to_s).to eq "<http://test.host/users/alice/updates/#{status.stream_entry.id}.atom>; rel=\"alternate\"; type=\"application/atom+xml\", <https://cb6e6126.ngrok.io/users/alice/statuses/#{status.id}>; rel=\"alternate\"; type=\"application/activity+json\"" end end @@ -88,14 +88,12 @@ RSpec.describe StreamEntriesController, type: :controller do describe 'GET #embed' do include_examples 'before_action', :embed - it 'returns embedded view of status' do + it 'redirects to new embed page' do status = Fabricate(:status) get :embed, params: { account_username: status.account.username, id: status.stream_entry.id } - expect(response).to have_http_status(:success) - expect(response.headers['X-Frame-Options']).to eq 'ALLOWALL' - expect(response).to render_template(layout: 'embedded') + expect(response).to redirect_to(embed_short_account_status_url(status.account, status)) end end end diff --git a/spec/fabricators/status_fabricator.rb b/spec/fabricators/status_fabricator.rb index 8ec5f4ba7..04bbbcf4b 100644 --- a/spec/fabricators/status_fabricator.rb +++ b/spec/fabricators/status_fabricator.rb @@ -1,4 +1,8 @@ Fabricator(:status) do account text "Lorem ipsum dolor sit amet" + + after_build do |status| + status.uri = Faker::Internet.device_token if !status.account.local? && status.uri.nil? + end end diff --git a/spec/fabricators/status_pin_fabricator.rb b/spec/fabricators/status_pin_fabricator.rb new file mode 100644 index 000000000..6a9006c9f --- /dev/null +++ b/spec/fabricators/status_pin_fabricator.rb @@ -0,0 +1,4 @@ +Fabricator(:status_pin) do + account + status +end diff --git a/spec/helpers/jsonld_helper_spec.rb b/spec/helpers/jsonld_helper_spec.rb new file mode 100644 index 000000000..7d3912e6c --- /dev/null +++ b/spec/helpers/jsonld_helper_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe JsonLdHelper do + describe '#equals_or_includes?' do + it 'returns true when value equals' do + expect(helper.equals_or_includes?('foo', 'foo')).to be true + end + + it 'returns false when value does not equal' do + expect(helper.equals_or_includes?('foo', 'bar')).to be false + end + + it 'returns true when value is included' do + expect(helper.equals_or_includes?(%w(foo baz), 'foo')).to be true + end + + it 'returns false when value is not included' do + expect(helper.equals_or_includes?(%w(foo baz), 'bar')).to be false + end + end + + describe '#first_of_value' do + pending + end + + describe '#supported_context?' do + pending + end + + describe '#fetch_resource' do + pending + end +end diff --git a/spec/helpers/routing_helper_spec.rb b/spec/helpers/routing_helper_spec.rb new file mode 100644 index 000000000..940392c9b --- /dev/null +++ b/spec/helpers/routing_helper_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe RoutingHelper, type: :helper do + describe '.full_asset_url' do + around do |example| + use_s3 = Rails.configuration.x.use_s3 + example.run + Rails.configuration.x.use_s3 = use_s3 + end + + shared_examples 'returns full path URL' do + it 'with host' do + url = helper.full_asset_url('https://example.com/avatars/000/000/002/original/icon.png') + + expect(url).to eq 'https://example.com/avatars/000/000/002/original/icon.png' + end + + it 'without host' do + url = helper.full_asset_url('/avatars/original/missing.png', skip_pipeline: true) + + expect(url).to eq 'http://test.host/avatars/original/missing.png' + end + end + + context 'Do not use S3' do + before do + Rails.configuration.x.use_s3 = false + end + + it_behaves_like 'returns full path URL' + end + + context 'Use S3' do + before do + Rails.configuration.x.use_s3 = true + end + + it_behaves_like 'returns full path URL' + end + end +end diff --git a/spec/lib/activitypub/activity/accept_spec.rb b/spec/lib/activitypub/activity/accept_spec.rb new file mode 100644 index 000000000..6503c83e3 --- /dev/null +++ b/spec/lib/activitypub/activity/accept_spec.rb @@ -0,0 +1,38 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::Activity::Accept do + let(:sender) { Fabricate(:account) } + let(:recipient) { Fabricate(:account) } + + let(:json) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'Accept', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: { + id: 'bar', + type: 'Follow', + actor: ActivityPub::TagManager.instance.uri_for(recipient), + object: ActivityPub::TagManager.instance.uri_for(sender), + }, + }.with_indifferent_access + end + + describe '#perform' do + subject { described_class.new(json, sender) } + + before do + Fabricate(:follow_request, account: recipient, target_account: sender) + subject.perform + end + + it 'creates a follow relationship' do + expect(recipient.following?(sender)).to be true + end + + it 'removes the follow request' do + expect(recipient.requested?(sender)).to be false + end + end +end diff --git a/spec/lib/activitypub/activity/announce_spec.rb b/spec/lib/activitypub/activity/announce_spec.rb new file mode 100644 index 000000000..54dd52a60 --- /dev/null +++ b/spec/lib/activitypub/activity/announce_spec.rb @@ -0,0 +1,29 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::Activity::Announce do + let(:sender) { Fabricate(:account) } + let(:recipient) { Fabricate(:account) } + let(:status) { Fabricate(:status, account: recipient) } + + let(:json) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'Announce', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: ActivityPub::TagManager.instance.uri_for(status), + }.with_indifferent_access + end + + describe '#perform' do + subject { described_class.new(json, sender) } + + before do + subject.perform + end + + it 'creates a reblog by sender of status' do + expect(sender.reblogged?(status)).to be true + end + end +end diff --git a/spec/lib/activitypub/activity/block_spec.rb b/spec/lib/activitypub/activity/block_spec.rb new file mode 100644 index 000000000..23c8cc31c --- /dev/null +++ b/spec/lib/activitypub/activity/block_spec.rb @@ -0,0 +1,28 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::Activity::Block do + let(:sender) { Fabricate(:account) } + let(:recipient) { Fabricate(:account) } + + let(:json) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'Block', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: ActivityPub::TagManager.instance.uri_for(recipient), + }.with_indifferent_access + end + + describe '#perform' do + subject { described_class.new(json, sender) } + + before do + subject.perform + end + + it 'creates a block from sender to recipient' do + expect(sender.blocking?(recipient)).to be true + end + end +end diff --git a/spec/lib/activitypub/activity/create_spec.rb b/spec/lib/activitypub/activity/create_spec.rb new file mode 100644 index 000000000..fcb044ebc --- /dev/null +++ b/spec/lib/activitypub/activity/create_spec.rb @@ -0,0 +1,221 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::Activity::Create do + let(:sender) { Fabricate(:account, followers_url: 'http://example.com/followers') } + + let(:json) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'Create', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: object_json, + }.with_indifferent_access + end + + subject { described_class.new(json, sender) } + + before do + stub_request(:get, 'http://example.com/attachment.png').to_return(request_fixture('avatar.txt')) + end + + describe '#perform' do + before do + subject.perform + end + + context 'standalone' do + let(:object_json) do + { + id: 'bar', + type: 'Note', + content: 'Lorem ipsum', + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.text).to eq 'Lorem ipsum' + end + + it 'missing to/cc defaults to direct privacy' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.visibility).to eq 'direct' + end + end + + context 'public' do + let(:object_json) do + { + id: 'bar', + type: 'Note', + content: 'Lorem ipsum', + to: 'https://www.w3.org/ns/activitystreams#Public', + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.visibility).to eq 'public' + end + end + + context 'unlisted' do + let(:object_json) do + { + id: 'bar', + type: 'Note', + content: 'Lorem ipsum', + cc: 'https://www.w3.org/ns/activitystreams#Public', + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.visibility).to eq 'unlisted' + end + end + + context 'private' do + let(:object_json) do + { + id: 'bar', + type: 'Note', + content: 'Lorem ipsum', + to: 'http://example.com/followers', + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.visibility).to eq 'private' + end + end + + context 'direct' do + let(:recipient) { Fabricate(:account) } + + let(:object_json) do + { + id: 'bar', + type: 'Note', + content: 'Lorem ipsum', + to: ActivityPub::TagManager.instance.uri_for(recipient), + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.visibility).to eq 'direct' + end + end + + context 'as a reply' do + let(:original_status) { Fabricate(:status) } + + let(:object_json) do + { + id: 'bar', + type: 'Note', + content: 'Lorem ipsum', + inReplyTo: ActivityPub::TagManager.instance.uri_for(original_status), + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.thread).to eq original_status + expect(status.reply?).to be true + expect(status.in_reply_to_account).to eq original_status.account + expect(status.conversation).to eq original_status.conversation + end + end + + context 'with mentions' do + let(:recipient) { Fabricate(:account) } + + let(:object_json) do + { + id: 'bar', + type: 'Note', + content: 'Lorem ipsum', + tag: [ + { + type: 'Mention', + href: ActivityPub::TagManager.instance.uri_for(recipient), + }, + ], + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.mentions.map(&:account)).to include(recipient) + end + end + + context 'with media attachments' do + let(:object_json) do + { + id: 'bar', + type: 'Note', + content: 'Lorem ipsum', + attachment: [ + { + type: 'Document', + mime_type: 'image/png', + url: 'http://example.com/attachment.png', + }, + ], + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.media_attachments.map(&:remote_url)).to include('http://example.com/attachment.png') + end + end + + context 'with hashtags' do + let(:object_json) do + { + id: 'bar', + type: 'Note', + content: 'Lorem ipsum', + tag: [ + { + type: 'Hashtag', + href: 'http://example.com/blah', + name: '#test', + }, + ], + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.tags.map(&:name)).to include('test') + end + end + end +end diff --git a/spec/lib/activitypub/activity/delete_spec.rb b/spec/lib/activitypub/activity/delete_spec.rb new file mode 100644 index 000000000..38254e31c --- /dev/null +++ b/spec/lib/activitypub/activity/delete_spec.rb @@ -0,0 +1,53 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::Activity::Delete do + let(:sender) { Fabricate(:account, domain: 'example.com') } + let(:status) { Fabricate(:status, account: sender, uri: 'foobar') } + + let(:json) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'Delete', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: ActivityPub::TagManager.instance.uri_for(status), + signature: 'foo', + }.with_indifferent_access + end + + describe '#perform' do + subject { described_class.new(json, sender) } + + before do + subject.perform + end + + it 'deletes sender\'s status' do + expect(Status.find_by(id: status.id)).to be_nil + end + end + + context 'when the status has been reblogged' do + describe '#perform' do + subject { described_class.new(json, sender) } + let(:reblogger) { Fabricate(:account) } + let(:follower) { Fabricate(:account, username: 'follower', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') } + + before do + stub_request(:post, 'http://example.com/inbox').to_return(status: 200) + follower.follow!(reblogger) + Fabricate(:status, account: reblogger, reblog: status) + subject.perform + end + + it 'deletes sender\'s status' do + expect(Status.find_by(id: status.id)).to be_nil + end + + it 'sends delete activity to followers of rebloggers' do + # one for Delete original post, and one for Undo reblog (normal delivery) + expect(a_request(:post, 'http://example.com/inbox')).to have_been_made.twice + end + end + end +end diff --git a/spec/lib/activitypub/activity/follow_spec.rb b/spec/lib/activitypub/activity/follow_spec.rb new file mode 100644 index 000000000..6bbacdbe6 --- /dev/null +++ b/spec/lib/activitypub/activity/follow_spec.rb @@ -0,0 +1,49 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::Activity::Follow do + let(:sender) { Fabricate(:account) } + let(:recipient) { Fabricate(:account) } + + let(:json) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'Follow', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: ActivityPub::TagManager.instance.uri_for(recipient), + }.with_indifferent_access + end + + describe '#perform' do + subject { described_class.new(json, sender) } + + context 'unlocked account' do + before do + subject.perform + end + + it 'creates a follow from sender to recipient' do + expect(sender.following?(recipient)).to be true + end + + it 'does not create a follow request' do + expect(sender.requested?(recipient)).to be false + end + end + + context 'locked account' do + before do + recipient.update(locked: true) + subject.perform + end + + it 'does not create a follow from sender to recipient' do + expect(sender.following?(recipient)).to be false + end + + it 'creates a follow request' do + expect(sender.requested?(recipient)).to be true + end + end + end +end diff --git a/spec/lib/activitypub/activity/like_spec.rb b/spec/lib/activitypub/activity/like_spec.rb new file mode 100644 index 000000000..b69615a9d --- /dev/null +++ b/spec/lib/activitypub/activity/like_spec.rb @@ -0,0 +1,29 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::Activity::Like do + let(:sender) { Fabricate(:account) } + let(:recipient) { Fabricate(:account) } + let(:status) { Fabricate(:status, account: recipient) } + + let(:json) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'Like', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: ActivityPub::TagManager.instance.uri_for(status), + }.with_indifferent_access + end + + describe '#perform' do + subject { described_class.new(json, sender) } + + before do + subject.perform + end + + it 'creates a favourite from sender to status' do + expect(sender.favourited?(status)).to be true + end + end +end diff --git a/spec/lib/activitypub/activity/reject_spec.rb b/spec/lib/activitypub/activity/reject_spec.rb new file mode 100644 index 000000000..7fd95bcc6 --- /dev/null +++ b/spec/lib/activitypub/activity/reject_spec.rb @@ -0,0 +1,38 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::Activity::Reject do + let(:sender) { Fabricate(:account) } + let(:recipient) { Fabricate(:account) } + + let(:json) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'Reject', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: { + id: 'bar', + type: 'Follow', + actor: ActivityPub::TagManager.instance.uri_for(recipient), + object: ActivityPub::TagManager.instance.uri_for(sender), + }, + }.with_indifferent_access + end + + describe '#perform' do + subject { described_class.new(json, sender) } + + before do + Fabricate(:follow_request, account: recipient, target_account: sender) + subject.perform + end + + it 'does not create a follow relationship' do + expect(recipient.following?(sender)).to be false + end + + it 'removes the follow request' do + expect(recipient.requested?(sender)).to be false + end + end +end diff --git a/spec/lib/activitypub/activity/undo_spec.rb b/spec/lib/activitypub/activity/undo_spec.rb new file mode 100644 index 000000000..14c68efe5 --- /dev/null +++ b/spec/lib/activitypub/activity/undo_spec.rb @@ -0,0 +1,107 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::Activity::Undo do + let(:sender) { Fabricate(:account, domain: 'example.com') } + + let(:json) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'Undo', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: object_json, + }.with_indifferent_access + end + + subject { described_class.new(json, sender) } + + describe '#perform' do + context 'with Announce' do + let(:status) { Fabricate(:status) } + + let(:object_json) do + { + id: 'bar', + type: 'Announce', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: ActivityPub::TagManager.instance.uri_for(status), + } + end + + before do + Fabricate(:status, reblog: status, account: sender, uri: 'bar') + end + + it 'deletes the reblog' do + subject.perform + expect(sender.reblogged?(status)).to be false + end + end + + context 'with Block' do + let(:recipient) { Fabricate(:account) } + + let(:object_json) do + { + id: 'bar', + type: 'Block', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: ActivityPub::TagManager.instance.uri_for(recipient), + } + end + + before do + sender.block!(recipient) + end + + it 'deletes block from sender to recipient' do + subject.perform + expect(sender.blocking?(recipient)).to be false + end + end + + context 'with Follow' do + let(:recipient) { Fabricate(:account) } + + let(:object_json) do + { + id: 'bar', + type: 'Follow', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: ActivityPub::TagManager.instance.uri_for(recipient), + } + end + + before do + sender.follow!(recipient) + end + + it 'deletes follow from sender to recipient' do + subject.perform + expect(sender.following?(recipient)).to be false + end + end + + context 'with Like' do + let(:status) { Fabricate(:status) } + + let(:object_json) do + { + id: 'bar', + type: 'Like', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: ActivityPub::TagManager.instance.uri_for(status), + } + end + + before do + Fabricate(:favourite, account: sender, status: status) + end + + it 'deletes favourite from sender to status' do + subject.perform + expect(sender.favourited?(status)).to be false + end + end + end +end diff --git a/spec/lib/activitypub/activity/update_spec.rb b/spec/lib/activitypub/activity/update_spec.rb new file mode 100644 index 000000000..0bd6d00d9 --- /dev/null +++ b/spec/lib/activitypub/activity/update_spec.rb @@ -0,0 +1,41 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::Activity::Update do + let!(:sender) { Fabricate(:account) } + + before do + sender.update!(uri: ActivityPub::TagManager.instance.uri_for(sender)) + end + + let(:modified_sender) do + sender.dup.tap do |modified_sender| + modified_sender.display_name = 'Totally modified now' + end + end + + let(:actor_json) do + ActiveModelSerializers::SerializableResource.new(modified_sender, serializer: ActivityPub::ActorSerializer, key_transform: :camel_lower).as_json + end + + let(:json) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'Update', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: actor_json, + }.with_indifferent_access + end + + describe '#perform' do + subject { described_class.new(json, sender) } + + before do + subject.perform + end + + it 'updates profile' do + expect(sender.reload.display_name).to eq 'Totally modified now' + end + end +end diff --git a/spec/lib/activitypub/linked_data_signature_spec.rb b/spec/lib/activitypub/linked_data_signature_spec.rb new file mode 100644 index 000000000..a4d6fe8c3 --- /dev/null +++ b/spec/lib/activitypub/linked_data_signature_spec.rb @@ -0,0 +1,82 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::LinkedDataSignature do + include JsonLdHelper + + let!(:sender) { Fabricate(:account, uri: 'http://example.com/alice') } + + let(:raw_json) do + { + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => 'http://example.com/hello-world', + } + end + + let(:json) { raw_json.merge('signature' => signature) } + + subject { described_class.new(json) } + + describe '#verify_account!' do + context 'when signature matches' do + let(:raw_signature) do + { + 'creator' => 'http://example.com/alice', + 'created' => '2017-09-23T20:21:34Z', + } + end + + let(:signature) { raw_signature.merge('type' => 'RsaSignature2017', 'signatureValue' => sign(sender, raw_signature, raw_json)) } + + it 'returns creator' do + expect(subject.verify_account!).to eq sender + end + end + + context 'when signature is missing' do + let(:signature) { nil } + + it 'returns nil' do + expect(subject.verify_account!).to be_nil + end + end + + context 'when signature is tampered' do + let(:raw_signature) do + { + 'creator' => 'http://example.com/alice', + 'created' => '2017-09-23T20:21:34Z', + } + end + + let(:signature) { raw_signature.merge('type' => 'RsaSignature2017', 'signatureValue' => 's69F3mfddd99dGjmvjdjjs81e12jn121Gkm1') } + + it 'returns nil' do + expect(subject.verify_account!).to be_nil + end + end + end + + describe '#sign!' do + subject { described_class.new(raw_json).sign!(sender) } + + it 'returns a hash' do + expect(subject).to be_a Hash + end + + it 'contains signature' do + expect(subject['signature']).to be_a Hash + expect(subject['signature']['signatureValue']).to be_present + end + + it 'can be verified again' do + expect(described_class.new(subject).verify_account!).to eq sender + end + end + + def sign(from_account, options, document) + options_hash = Digest::SHA256.hexdigest(canonicalize(options.merge('@context' => ActivityPub::LinkedDataSignature::CONTEXT))) + document_hash = Digest::SHA256.hexdigest(canonicalize(document)) + to_be_verified = options_hash + document_hash + Base64.strict_encode64(from_account.keypair.sign(OpenSSL::Digest::SHA256.new, to_be_verified)) + end +end diff --git a/spec/lib/activitypub/tag_manager_spec.rb b/spec/lib/activitypub/tag_manager_spec.rb new file mode 100644 index 000000000..8f7662e24 --- /dev/null +++ b/spec/lib/activitypub/tag_manager_spec.rb @@ -0,0 +1,99 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::TagManager do + include RoutingHelper + + subject { described_class.instance } + + describe '#url_for' do + it 'returns a string' do + account = Fabricate(:account) + expect(subject.url_for(account)).to be_a String + end + end + + describe '#uri_for' do + it 'returns a string' do + account = Fabricate(:account) + expect(subject.uri_for(account)).to be_a String + end + end + + describe '#to' do + it 'returns public collection for public status' do + status = Fabricate(:status, visibility: :public) + expect(subject.to(status)).to eq ['https://www.w3.org/ns/activitystreams#Public'] + end + + it 'returns followers collection for unlisted status' do + status = Fabricate(:status, visibility: :unlisted) + expect(subject.to(status)).to eq [account_followers_url(status.account)] + end + + it 'returns followers collection for private status' do + status = Fabricate(:status, visibility: :private) + expect(subject.to(status)).to eq [account_followers_url(status.account)] + end + + it 'returns URIs of mentions for direct status' do + status = Fabricate(:status, visibility: :direct) + mentioned = Fabricate(:account) + status.mentions.create(account: mentioned) + expect(subject.to(status)).to eq [subject.uri_for(mentioned)] + end + end + + describe '#cc' do + it 'returns followers collection for public status' do + status = Fabricate(:status, visibility: :public) + expect(subject.cc(status)).to eq [account_followers_url(status.account)] + end + + it 'returns public collection for unlisted status' do + status = Fabricate(:status, visibility: :unlisted) + expect(subject.cc(status)).to eq ['https://www.w3.org/ns/activitystreams#Public'] + end + + it 'returns empty array for private status' do + status = Fabricate(:status, visibility: :private) + expect(subject.cc(status)).to eq [] + end + + it 'returns empty array for direct status' do + status = Fabricate(:status, visibility: :direct) + expect(subject.cc(status)).to eq [] + end + + it 'returns URIs of mentions for non-direct status' do + status = Fabricate(:status, visibility: :public) + mentioned = Fabricate(:account) + status.mentions.create(account: mentioned) + expect(subject.cc(status)).to include(subject.uri_for(mentioned)) + end + end + + describe '#local_uri?' do + it 'returns false for non-local URI' do + expect(subject.local_uri?('http://example.com/123')).to be false + end + + it 'returns true for local URIs' do + account = Fabricate(:account) + expect(subject.local_uri?(subject.uri_for(account))).to be true + end + end + + describe '#uri_to_local_id' do + it 'returns the local ID' do + account = Fabricate(:account) + expect(subject.uri_to_local_id(subject.uri_for(account), :username)).to eq account.username + end + end + + describe '#uri_to_resource' do + it 'returns the local resource' do + account = Fabricate(:account) + expect(subject.uri_to_resource(subject.uri_for(account), Account)).to eq account + end + end +end diff --git a/spec/lib/formatter_spec.rb b/spec/lib/formatter_spec.rb index dfe1d8b8f..ab04ccbab 100644 --- a/spec/lib/formatter_spec.rb +++ b/spec/lib/formatter_spec.rb @@ -178,7 +178,7 @@ RSpec.describe Formatter do end context 'with remote status' do - let(:status) { Fabricate(:status, text: 'Beep boop', uri: 'beepboop') } + let(:status) { Fabricate(:status, account: remote_account, text: 'Beep boop') } it 'reformats' do is_expected.to eq 'Beep boop' @@ -226,7 +226,7 @@ RSpec.describe Formatter do end context 'with remote status' do - let(:status) { Fabricate(:status, text: '<script>alert("Hello")</script>', uri: 'beep boop') } + let(:status) { Fabricate(:status, account: remote_account, text: '<script>alert("Hello")</script>') } it 'returns tag-stripped text' do is_expected.to eq '' diff --git a/spec/lib/ostatus/atom_serializer_spec.rb b/spec/lib/ostatus/atom_serializer_spec.rb index b0cb8f019..0451eceeb 100644 --- a/spec/lib/ostatus/atom_serializer_spec.rb +++ b/spec/lib/ostatus/atom_serializer_spec.rb @@ -196,7 +196,7 @@ RSpec.describe OStatus::AtomSerializer do author = OStatus::AtomSerializer.new.author(account) - link = author.nodes.find { |node| node.name == 'link' && node[:rel] == 'alternate' } + link = author.nodes.find { |node| node.name == 'link' && node[:rel] == 'alternate' && node[:type] == 'text/html' } expect(link[:type]).to eq 'text/html' expect(link[:rel]).to eq 'alternate' expect(link[:href]).to eq 'https://cb6e6126.ngrok.io/@username' @@ -403,10 +403,10 @@ RSpec.describe OStatus::AtomSerializer do it 'returns element whose rendered view triggers creation when processed' do remote_account = Account.create!(username: 'username') - remote_status = Fabricate(:status, account: remote_account) - remote_status.stream_entry.update!(created_at: '2000-01-01T00:00:00Z') + remote_status = Fabricate(:status, account: remote_account, created_at: '2000-01-01T00:00:00Z') entry = OStatus::AtomSerializer.new.entry(remote_status.stream_entry, true) + entry.nodes.delete_if { |node| node[:type] == 'application/activity+json' } # Remove ActivityPub link to simplify test xml = OStatus::AtomSerializer.render(entry).gsub('cb6e6126.ngrok.io', 'remote') remote_status.destroy! @@ -415,12 +415,12 @@ RSpec.describe OStatus::AtomSerializer do account = Account.create!( domain: 'remote', username: 'username', - last_webfingered_at: Time.now.utc, + last_webfingered_at: Time.now.utc ) ProcessFeedService.new.call(xml, account) - expect(Status.find_by(uri: "tag:remote,2000-01-01:objectId=#{remote_status.id}:objectType=Status")).to be_instance_of Status + expect(Status.find_by(uri: "https://remote/users/#{remote_status.account.to_param}/statuses/#{remote_status.id}")).to be_instance_of Status end end @@ -464,12 +464,11 @@ RSpec.describe OStatus::AtomSerializer do end it 'appends id element with unique tag' do - status = Fabricate(:status, reblog_of_id: nil) - status.stream_entry.update!(created_at: '2000-01-01T00:00:00Z') + status = Fabricate(:status, reblog_of_id: nil, created_at: '2000-01-01T00:00:00Z') entry = OStatus::AtomSerializer.new.entry(status.stream_entry) - expect(entry.id.text).to eq "tag:cb6e6126.ngrok.io,2000-01-01:objectId=#{status.id}:objectType=Status" + expect(entry.id.text).to eq "https://cb6e6126.ngrok.io/users/#{status.account.to_param}/statuses/#{status.id}" end it 'appends published element with created date' do @@ -514,7 +513,7 @@ RSpec.describe OStatus::AtomSerializer do entry = OStatus::AtomSerializer.new.entry(reblog.stream_entry) object = entry.nodes.find { |node| node.name == 'activity:object' } - expect(object.id.text).to eq "tag:cb6e6126.ngrok.io,2000-01-01:objectId=#{reblogged.id}:objectType=Status" + expect(object.id.text).to eq "https://cb6e6126.ngrok.io/users/#{reblogged.account.to_param}/statuses/#{reblogged.id}" end it 'does not append activity:object element if target is not present' do @@ -529,9 +528,9 @@ RSpec.describe OStatus::AtomSerializer do entry = OStatus::AtomSerializer.new.entry(status.stream_entry) - link = entry.nodes.find { |node| node.name == 'link' && node[:rel] == 'alternate' } + link = entry.nodes.find { |node| node.name == 'link' && node[:rel] == 'alternate' && node[:type] == 'text/html' } expect(link[:type]).to eq 'text/html' - expect(link[:href]).to eq "https://cb6e6126.ngrok.io/users/username/updates/#{status.stream_entry.id}" + expect(link[:href]).to eq "https://cb6e6126.ngrok.io/@username/#{status.id}" end it 'appends link element for itself' do @@ -552,7 +551,7 @@ RSpec.describe OStatus::AtomSerializer do entry = OStatus::AtomSerializer.new.entry(reply_status.stream_entry) in_reply_to = entry.nodes.find { |node| node.name == 'thr:in-reply-to' } - expect(in_reply_to[:ref]).to eq "tag:cb6e6126.ngrok.io,2000-01-01:objectId=#{in_reply_to_status.id}:objectType=Status" + expect(in_reply_to[:ref]).to eq "https://cb6e6126.ngrok.io/users/#{in_reply_to_status.account.to_param}/statuses/#{in_reply_to_status.id}" end it 'does not append thr:in-reply-to element if not threaded' do @@ -642,7 +641,7 @@ RSpec.describe OStatus::AtomSerializer do feed = OStatus::AtomSerializer.new.feed(account, []) - link = feed.nodes.find { |node| node.name == 'link' && node[:rel] == 'alternate' } + link = feed.nodes.find { |node| node.name == 'link' && node[:rel] == 'alternate' && node[:type] == 'text/html' } expect(link[:type]).to eq 'text/html' expect(link[:href]).to eq 'https://cb6e6126.ngrok.io/@username' end @@ -933,7 +932,7 @@ RSpec.describe OStatus::AtomSerializer do favourite_salmon = OStatus::AtomSerializer.new.favourite_salmon(favourite) object = favourite_salmon.nodes.find { |node| node.name == 'activity:object' } - expect(object.id.text).to eq "tag:cb6e6126.ngrok.io,2000-01-01:objectId=#{status.id}:objectType=Status" + expect(object.id.text).to eq "https://cb6e6126.ngrok.io/users/#{status.account.to_param}/statuses/#{status.id}" end it 'appends thr:in-reply-to element for status' do @@ -944,7 +943,7 @@ RSpec.describe OStatus::AtomSerializer do favourite_salmon = OStatus::AtomSerializer.new.favourite_salmon(favourite) in_reply_to = favourite_salmon.nodes.find { |node| node.name == 'thr:in-reply-to' } - expect(in_reply_to.ref).to eq "tag:cb6e6126.ngrok.io,2000-01-01:objectId=#{status.id}:objectType=Status" + expect(in_reply_to.ref).to eq "https://cb6e6126.ngrok.io/users/#{status.account.to_param}/statuses/#{status.id}" expect(in_reply_to.href).to eq "https://cb6e6126.ngrok.io/@username/#{status.id}" end @@ -1033,7 +1032,7 @@ RSpec.describe OStatus::AtomSerializer do unfavourite_salmon = OStatus::AtomSerializer.new.unfavourite_salmon(favourite) object = unfavourite_salmon.nodes.find { |node| node.name == 'activity:object' } - expect(object.id.text).to eq "tag:cb6e6126.ngrok.io,2000-01-01:objectId=#{status.id}:objectType=Status" + expect(object.id.text).to eq "https://cb6e6126.ngrok.io/users/#{status.account.to_param}/statuses/#{status.id}" end it 'appends thr:in-reply-to element for status' do @@ -1044,7 +1043,7 @@ RSpec.describe OStatus::AtomSerializer do unfavourite_salmon = OStatus::AtomSerializer.new.unfavourite_salmon(favourite) in_reply_to = unfavourite_salmon.nodes.find { |node| node.name == 'thr:in-reply-to' } - expect(in_reply_to.ref).to eq "tag:cb6e6126.ngrok.io,2000-01-01:objectId=#{status.id}:objectType=Status" + expect(in_reply_to.ref).to eq "https://cb6e6126.ngrok.io/users/#{status.account.to_param}/statuses/#{status.id}" expect(in_reply_to.href).to eq "https://cb6e6126.ngrok.io/@username/#{status.id}" end @@ -1452,7 +1451,7 @@ RSpec.describe OStatus::AtomSerializer do it 'appends id element with URL for status' do status = Fabricate(:status, created_at: '2000-01-01T00:00:00Z') object = OStatus::AtomSerializer.new.object(status) - expect(object.id.text).to eq "tag:cb6e6126.ngrok.io,2000-01-01:objectId=#{status.id}:objectType=Status" + expect(object.id.text).to eq "https://cb6e6126.ngrok.io/users/#{status.account.to_param}/statuses/#{status.id}" end it 'appends published element with created date' do @@ -1462,7 +1461,8 @@ RSpec.describe OStatus::AtomSerializer do end it 'appends updated element with updated date' do - status = Fabricate(:status, updated_at: '2000-01-01T00:00:00Z') + status = Fabricate(:status) + status.updated_at = '2000-01-01T00:00:00Z' object = OStatus::AtomSerializer.new.object(status) expect(object.updated.text).to eq '2000-01-01T00:00:00Z' end @@ -1509,7 +1509,7 @@ RSpec.describe OStatus::AtomSerializer do entry = OStatus::AtomSerializer.new.object(status) - link = entry.nodes.find { |node| node.name == 'link' && node[:rel] == 'alternate' } + link = entry.nodes.find { |node| node.name == 'link' && node[:rel] == 'alternate' && node[:type] == 'text/html' } expect(link[:type]).to eq 'text/html' expect(link[:href]).to eq "https://cb6e6126.ngrok.io/@username/#{status.id}" end @@ -1522,7 +1522,7 @@ RSpec.describe OStatus::AtomSerializer do entry = OStatus::AtomSerializer.new.object(reply) in_reply_to = entry.nodes.find { |node| node.name == 'thr:in-reply-to' } - expect(in_reply_to.ref).to eq "tag:cb6e6126.ngrok.io,2000-01-01:objectId=#{thread.id}:objectType=Status" + expect(in_reply_to.ref).to eq "https://cb6e6126.ngrok.io/users/#{thread.account.to_param}/statuses/#{thread.id}" expect(in_reply_to.href).to eq "https://cb6e6126.ngrok.io/@username/#{thread.id}" end diff --git a/spec/lib/stream_entry_finder_spec.rb b/spec/lib/status_finder_spec.rb index 64e03c36a..3ef086736 100644 --- a/spec/lib/stream_entry_finder_spec.rb +++ b/spec/lib/status_finder_spec.rb @@ -2,17 +2,17 @@ require 'rails_helper' -describe StreamEntryFinder do +describe StatusFinder do include RoutingHelper - describe '#stream_entry' do + describe '#status' do context 'with a status url' do let(:status) { Fabricate(:status) } let(:url) { short_account_status_url(account_username: status.account.username, id: status.id) } subject { described_class.new(url) } it 'finds the stream entry' do - expect(subject.stream_entry).to eq(status.stream_entry) + expect(subject.status).to eq(status) end it 'raises an error if action is not :show' do @@ -20,7 +20,7 @@ describe StreamEntryFinder do expect(recognized).to receive(:[]).with(:action).and_return(:create) expect(Rails.application.routes).to receive(:recognize_path).with(url).and_return(recognized) - expect { subject.stream_entry }.to raise_error(ActiveRecord::RecordNotFound) + expect { subject.status }.to raise_error(ActiveRecord::RecordNotFound) end end @@ -30,7 +30,17 @@ describe StreamEntryFinder do subject { described_class.new(url) } it 'finds the stream entry' do - expect(subject.stream_entry).to eq(stream_entry) + expect(subject.status).to eq(stream_entry.status) + end + end + + context 'with a remote url even if id exists on local' do + let(:status) { Fabricate(:status) } + let(:url) { "https://example.com/users/test/statuses/#{status.id}" } + subject { described_class.new(url) } + + it 'raises an error' do + expect { subject.status }.to raise_error(ActiveRecord::RecordNotFound) end end @@ -39,7 +49,7 @@ describe StreamEntryFinder do subject { described_class.new(url) } it 'raises an error' do - expect { subject.stream_entry }.to raise_error(ActiveRecord::RecordNotFound) + expect { subject.status }.to raise_error(ActiveRecord::RecordNotFound) end end @@ -48,7 +58,7 @@ describe StreamEntryFinder do subject { described_class.new(url) } it 'raises an error' do - expect { subject.stream_entry }.to raise_error(ActiveRecord::RecordNotFound) + expect { subject.status }.to raise_error(ActiveRecord::RecordNotFound) end end end diff --git a/spec/lib/tag_manager_spec.rb b/spec/lib/tag_manager_spec.rb index 1fae6bec4..1cd6e0a6f 100644 --- a/spec/lib/tag_manager_spec.rb +++ b/spec/lib/tag_manager_spec.rb @@ -157,23 +157,12 @@ RSpec.describe TagManager do describe '#uri_for' do subject { TagManager.instance.uri_for(target) } - context 'activity object' do - let(:target) { Fabricate(:status, reblog: Fabricate(:status)).stream_entry } - - before { target.update!(created_at: '2000-01-01T00:00:00Z') } - - it 'returns the unique tag for status' do - expect(target.object_type).to eq :activity - is_expected.to eq "tag:cb6e6126.ngrok.io,2000-01-01:objectId=#{target.id}:objectType=Status" - end - end - context 'comment object' do let(:target) { Fabricate(:status, created_at: '2000-01-01T00:00:00Z', reply: true) } it 'returns the unique tag for status' do expect(target.object_type).to eq :comment - is_expected.to eq "tag:cb6e6126.ngrok.io,2000-01-01:objectId=#{target.id}:objectType=Status" + is_expected.to eq target.uri end end @@ -182,7 +171,7 @@ RSpec.describe TagManager do it 'returns the unique tag for status' do expect(target.object_type).to eq :note - is_expected.to eq "tag:cb6e6126.ngrok.io,2000-01-01:objectId=#{target.id}:objectType=Status" + is_expected.to eq target.uri end end diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb index 17e2d8499..361577eff 100644 --- a/spec/models/account_spec.rb +++ b/spec/models/account_spec.rb @@ -642,7 +642,6 @@ RSpec.describe Account, type: :model do it 'returns remote accounts with followers whose subscription expiration date is past or not given' do local = Fabricate(:account, domain: nil) matches = [ - { domain: 'remote', subscription_expires_at: nil }, { domain: 'remote', subscription_expires_at: '2000-01-01T00:00:00Z' }, ].map(&method(:Fabricate).curry(2).call(:account)) matches.each(&local.method(:follow!)) diff --git a/spec/models/import_spec.rb b/spec/models/import_spec.rb index fa52077cd..321761166 100644 --- a/spec/models/import_spec.rb +++ b/spec/models/import_spec.rb @@ -1,5 +1,24 @@ require 'rails_helper' RSpec.describe Import, type: :model do + let (:account) { Fabricate(:account) } + let (:type) { 'following' } + let (:data) { attachment_fixture('imports.txt') } + describe 'validations' do + it 'has a valid parameters' do + import = Import.create(account: account, type: type, data: data) + expect(import).to be_valid + end + + it 'is invalid without an type' do + import = Import.create(account: account, data: data) + expect(import).to model_have_error_on_field(:type) + end + + it 'is invalid without a data' do + import = Import.create(account: account, type: type) + expect(import).to model_have_error_on_field(:data) + end + end end diff --git a/spec/models/status_pin_spec.rb b/spec/models/status_pin_spec.rb new file mode 100644 index 000000000..6f54f80f9 --- /dev/null +++ b/spec/models/status_pin_spec.rb @@ -0,0 +1,41 @@ +require 'rails_helper' + +RSpec.describe StatusPin, type: :model do + describe 'validations' do + it 'allows pins of own statuses' do + account = Fabricate(:account) + status = Fabricate(:status, account: account) + + expect(StatusPin.new(account: account, status: status).save).to be true + end + + it 'does not allow pins of statuses by someone else' do + account = Fabricate(:account) + status = Fabricate(:status) + + expect(StatusPin.new(account: account, status: status).save).to be false + end + + it 'does not allow pins of reblogs' do + account = Fabricate(:account) + status = Fabricate(:status, account: account) + reblog = Fabricate(:status, reblog: status) + + expect(StatusPin.new(account: account, status: reblog).save).to be false + end + + it 'does not allow pins of private statuses' do + account = Fabricate(:account) + status = Fabricate(:status, account: account, visibility: :private) + + expect(StatusPin.new(account: account, status: status).save).to be false + end + + it 'does not allow pins of direct statuses' do + account = Fabricate(:account) + status = Fabricate(:status, account: account, visibility: :direct) + + expect(StatusPin.new(account: account, status: status).save).to be false + end + end +end diff --git a/spec/models/status_spec.rb b/spec/models/status_spec.rb index 626fc3f98..484effd5e 100644 --- a/spec/models/status_spec.rb +++ b/spec/models/status_spec.rb @@ -13,9 +13,15 @@ RSpec.describe Status, type: :model do end it 'returns false if a remote URI is set' do - subject.uri = 'a' + alice.update(domain: 'example.com') + subject.save expect(subject.local?).to be false end + + it 'returns true if a URI is set and `local` is true' do + subject.update(uri: 'example.com', local: true) + expect(subject.local?).to be true + end end describe '#reblog?' do @@ -495,7 +501,7 @@ RSpec.describe Status, type: :model do end end - describe 'before_create' do + describe 'before_validation' do it 'sets account being replied to correctly over intermediary nodes' do first_status = Fabricate(:status, account: bob) intermediary = Fabricate(:status, thread: first_status, account: alice) @@ -512,5 +518,22 @@ RSpec.describe Status, type: :model do parent = Fabricate(:status, text: 'First') expect(Status.create(account: alice, thread: parent, text: 'Response').conversation_id).to eq parent.conversation_id end + + it 'sets `local` to true for status by local account' do + expect(Status.create(account: alice, text: 'foo').local).to be true + end + + it 'sets `local` to false for status by remote account' do + alice.update(domain: 'example.com') + expect(Status.create(account: alice, text: 'foo').local).to be false + end + end + + describe 'after_create' do + it 'saves ActivityPub uri as uri for local status' do + status = Status.create(account: alice, text: 'foo') + status.reload + expect(status.uri).to start_with('https://') + end end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index ef45818b9..99aeca01b 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -286,4 +286,24 @@ RSpec.describe User, type: :model do Fabricate(:user) end end + + describe 'token_for_app' do + let(:user) { Fabricate(:user) } + let(:app) { Fabricate(:application, owner: user) } + + it 'returns a token' do + expect(user.token_for_app(app)).to be_a(Doorkeeper::AccessToken) + end + + it 'persists a token' do + t = user.token_for_app(app) + expect(user.token_for_app(app)).to eql(t) + end + + it 'is nil if user does not own app' do + app.update!(owner: nil) + + expect(user.token_for_app(app)).to be_nil + end + end end diff --git a/spec/services/activitypub/fetch_remote_account_service_spec.rb b/spec/services/activitypub/fetch_remote_account_service_spec.rb new file mode 100644 index 000000000..391d051c1 --- /dev/null +++ b/spec/services/activitypub/fetch_remote_account_service_spec.rb @@ -0,0 +1,123 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::FetchRemoteAccountService do + subject { ActivityPub::FetchRemoteAccountService.new } + + let!(:actor) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'https://example.com/alice', + type: 'Person', + preferredUsername: 'alice', + name: 'Alice', + summary: 'Foo bar', + inbox: 'http://example.com/alice/inbox', + } + end + + describe '#call' do + let(:account) { subject.call('https://example.com/alice') } + + shared_examples 'sets profile data' do + it 'returns an account' do + expect(account).to be_an Account + end + + it 'sets display name' do + expect(account.display_name).to eq 'Alice' + end + + it 'sets note' do + expect(account.note).to eq 'Foo bar' + end + + it 'sets URL' do + expect(account.url).to eq 'https://example.com/alice' + end + end + + context 'when the account does not have a inbox' do + let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice' }] } } + + before do + actor[:inbox] = nil + + stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor)) + stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' }) + end + + it 'fetches resource' do + account + expect(a_request(:get, 'https://example.com/alice')).to have_been_made.once + end + + it 'looks up webfinger' do + account + expect(a_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com')).to have_been_made.once + end + + it 'returns nil' do + expect(account).to be_nil + end + + end + + context 'when URI and WebFinger share the same host' do + let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice' }] } } + + before do + stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor)) + stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' }) + end + + it 'fetches resource' do + account + expect(a_request(:get, 'https://example.com/alice')).to have_been_made.once + end + + it 'looks up webfinger' do + account + expect(a_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com')).to have_been_made.once + end + + it 'sets username and domain from webfinger' do + expect(account.username).to eq 'alice' + expect(account.domain).to eq 'example.com' + end + + include_examples 'sets profile data' + end + + context 'when WebFinger presents different domain than URI' do + let!(:webfinger) { { subject: 'acct:alice@iscool.af', links: [{ rel: 'self', href: 'https://example.com/alice' }] } } + + before do + stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor)) + stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' }) + stub_request(:get, 'https://iscool.af/.well-known/webfinger?resource=acct:alice@iscool.af').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' }) + end + + it 'fetches resource' do + account + expect(a_request(:get, 'https://example.com/alice')).to have_been_made.once + end + + it 'looks up webfinger' do + account + expect(a_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com')).to have_been_made.once + end + + it 'looks up "redirected" webfinger' do + account + expect(a_request(:get, 'https://iscool.af/.well-known/webfinger?resource=acct:alice@iscool.af')).to have_been_made.once + end + + it 'sets username and domain from final webfinger' do + expect(account.username).to eq 'alice' + expect(account.domain).to eq 'iscool.af' + end + + include_examples 'sets profile data' + end + end +end diff --git a/spec/services/activitypub/fetch_remote_status_service_spec.rb b/spec/services/activitypub/fetch_remote_status_service_spec.rb new file mode 100644 index 000000000..3b22257ed --- /dev/null +++ b/spec/services/activitypub/fetch_remote_status_service_spec.rb @@ -0,0 +1,75 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::FetchRemoteStatusService do + let(:sender) { Fabricate(:account) } + let(:recipient) { Fabricate(:account) } + let(:valid_domain) { Rails.configuration.x.local_domain } + + let(:note) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: "https://#{valid_domain}/@foo/1234", + type: 'Note', + content: 'Lorem ipsum', + attributedTo: ActivityPub::TagManager.instance.uri_for(sender), + } + end + + let(:create) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: "https://#{valid_domain}/@foo/1234/activity", + type: 'Create', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: note, + } + end + + subject { described_class.new } + + describe '#call' do + before do + subject.call(object[:id], Oj.dump(object)) + end + + context 'with Note object' do + let(:object) { note } + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.text).to eq 'Lorem ipsum' + end + end + + context 'with Create activity' do + let(:object) { create } + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.text).to eq 'Lorem ipsum' + end + end + + context 'with Announce activity' do + let(:status) { Fabricate(:status, account: recipient) } + + let(:object) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: "https://#{valid_domain}/@foo/1234/activity", + type: 'Announce', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: ActivityPub::TagManager.instance.uri_for(status), + } + end + + it 'creates a reblog by sender of status' do + expect(sender.reblogged?(status)).to be true + end + end + end +end diff --git a/spec/services/activitypub/process_account_service_spec.rb b/spec/services/activitypub/process_account_service_spec.rb new file mode 100644 index 000000000..84a74c231 --- /dev/null +++ b/spec/services/activitypub/process_account_service_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::ProcessAccountService do + pending +end diff --git a/spec/services/activitypub/process_collection_service_spec.rb b/spec/services/activitypub/process_collection_service_spec.rb new file mode 100644 index 000000000..249b12470 --- /dev/null +++ b/spec/services/activitypub/process_collection_service_spec.rb @@ -0,0 +1,55 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::ProcessCollectionService do + let(:actor) { Fabricate(:account) } + + let(:payload) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'Create', + actor: ActivityPub::TagManager.instance.uri_for(actor), + object: { + id: 'bar', + type: 'Note', + content: 'Lorem ipsum', + }, + } + end + + let(:json) { Oj.dump(payload) } + + subject { described_class.new } + + describe '#call' do + context 'when actor is the sender' + context 'when actor differs from sender' do + let(:forwarder) { Fabricate(:account) } + + it 'processes payload with sender if no signature exists' do + expect_any_instance_of(ActivityPub::LinkedDataSignature).not_to receive(:verify_account!) + expect(ActivityPub::Activity).to receive(:factory).with(instance_of(Hash), forwarder) + + subject.call(json, forwarder) + end + + it 'processes payload with actor if valid signature exists' do + payload['signature'] = {'type' => 'RsaSignature2017'} + + expect_any_instance_of(ActivityPub::LinkedDataSignature).to receive(:verify_account!).and_return(actor) + expect(ActivityPub::Activity).to receive(:factory).with(instance_of(Hash), actor) + + subject.call(json, forwarder) + end + + it 'does not process payload if invalid signature exists' do + payload['signature'] = {'type' => 'RsaSignature2017'} + + expect_any_instance_of(ActivityPub::LinkedDataSignature).to receive(:verify_account!).and_return(nil) + expect(ActivityPub::Activity).not_to receive(:factory) + + subject.call(json, forwarder) + end + end + end +end diff --git a/spec/services/authorize_follow_service_spec.rb b/spec/services/authorize_follow_service_spec.rb index 3f3a2bc56..d74eb41a2 100644 --- a/spec/services/authorize_follow_service_spec.rb +++ b/spec/services/authorize_follow_service_spec.rb @@ -22,7 +22,7 @@ RSpec.describe AuthorizeFollowService do end end - describe 'remote' do + describe 'remote OStatus' do let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com')).account } before do @@ -46,4 +46,26 @@ RSpec.describe AuthorizeFollowService do }).to have_been_made.once end end + + describe 'remote ActivityPub' do + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', domain: 'example.com', protocol: :activitypub, inbox_url: 'http://example.com/inbox')).account } + + before do + FollowRequest.create(account: bob, target_account: sender) + stub_request(:post, bob.inbox_url).to_return(status: 200) + subject.call(bob, sender) + end + + it 'removes follow request' do + expect(bob.requested?(sender)).to be false + end + + it 'creates follow relation' do + expect(bob.following?(sender)).to be true + end + + it 'sends an accept activity' do + expect(a_request(:post, bob.inbox_url)).to have_been_made.once + end + end end diff --git a/spec/services/batched_remove_status_service_spec.rb b/spec/services/batched_remove_status_service_spec.rb index c20085e25..b1e9ac567 100644 --- a/spec/services/batched_remove_status_service_spec.rb +++ b/spec/services/batched_remove_status_service_spec.rb @@ -6,6 +6,7 @@ RSpec.describe BatchedRemoveStatusService do let!(:alice) { Fabricate(:account) } let!(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://example.com/salmon') } let!(:jeff) { Fabricate(:account) } + let!(:hank) { Fabricate(:account, username: 'hank', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') } let(:status1) { PostStatusService.new.call(alice, 'Hello @bob@example.com') } let(:status2) { PostStatusService.new.call(alice, 'Another status') } @@ -15,9 +16,11 @@ RSpec.describe BatchedRemoveStatusService do stub_request(:post, 'http://example.com/push').to_return(status: 200, body: '', headers: {}) stub_request(:post, 'http://example.com/salmon').to_return(status: 200, body: '', headers: {}) + stub_request(:post, 'http://example.com/inbox').to_return(status: 200) Fabricate(:subscription, account: alice, callback_url: 'http://example.com/push', confirmed: true, expires_at: 30.days.from_now) jeff.follow!(alice) + hank.follow!(alice) status1 status2 @@ -45,11 +48,10 @@ RSpec.describe BatchedRemoveStatusService do expect(Redis.current).to have_received(:publish).with('timeline:public', any_args).at_least(:once) end - it 'sends PuSH update to PuSH subscribers with two payloads united' do + it 'sends PuSH update to PuSH subscribers' do expect(a_request(:post, 'http://example.com/push').with { |req| - matches = req.body.scan(TagManager::VERBS[:delete]) - matches.size == 2 - }).to have_been_made + matches = req.body.match(TagManager::VERBS[:delete]) + }).to have_been_made.at_least_once end it 'sends Salmon slap to previously mentioned users' do @@ -58,4 +60,8 @@ RSpec.describe BatchedRemoveStatusService do xml.match(TagManager::VERBS[:delete]) }).to have_been_made.once end + + it 'sends delete activity to followers' do + expect(a_request(:post, 'http://example.com/inbox')).to have_been_made.at_least_once + end end diff --git a/spec/services/block_service_spec.rb b/spec/services/block_service_spec.rb index 2a54e032e..bd2ab3d53 100644 --- a/spec/services/block_service_spec.rb +++ b/spec/services/block_service_spec.rb @@ -17,7 +17,7 @@ RSpec.describe BlockService do end end - describe 'remote' do + describe 'remote OStatus' do let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com')).account } before do @@ -36,4 +36,21 @@ RSpec.describe BlockService do }).to have_been_made.once end end + + describe 'remote ActivityPub' do + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox')).account } + + before do + stub_request(:post, 'http://example.com/inbox').to_return(status: 200) + subject.call(sender, bob) + end + + it 'creates a blocking relation' do + expect(sender.blocking?(bob)).to be true + end + + it 'sends a block activity' do + expect(a_request(:post, 'http://example.com/inbox')).to have_been_made.once + end + end end diff --git a/spec/services/favourite_service_spec.rb b/spec/services/favourite_service_spec.rb index 36f1b64d4..2ab1f32ca 100644 --- a/spec/services/favourite_service_spec.rb +++ b/spec/services/favourite_service_spec.rb @@ -18,8 +18,8 @@ RSpec.describe FavouriteService do end end - describe 'remote' do - let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com')).account } + describe 'remote OStatus' do + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', protocol: :ostatus, domain: 'example.com', salmon_url: 'http://salmon.example.com')).account } let(:status) { Fabricate(:status, account: bob, uri: 'tag:example.com:blahblah') } before do @@ -38,4 +38,22 @@ RSpec.describe FavouriteService do }).to have_been_made.once end end + + describe 'remote ActivityPub' do + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, protocol: :activitypub, username: 'bob', domain: 'example.com', inbox_url: 'http://example.com/inbox')).account } + let(:status) { Fabricate(:status, account: bob) } + + before do + stub_request(:post, "http://example.com/inbox").to_return(:status => 200, :body => "", :headers => {}) + subject.call(sender, status) + end + + it 'creates a favourite' do + expect(status.favourites.first).to_not be_nil + end + + it 'sends a like activity' do + expect(a_request(:post, "http://example.com/inbox")).to have_been_made.once + end + end end diff --git a/spec/services/fetch_link_card_service_spec.rb b/spec/services/fetch_link_card_service_spec.rb index 698eb0324..b0aa740ac 100644 --- a/spec/services/fetch_link_card_service_spec.rb +++ b/spec/services/fetch_link_card_service_spec.rb @@ -31,7 +31,7 @@ RSpec.describe FetchLinkCardService do it 'works with SJIS' do expect(a_request(:get, 'http://example.com/sjis')).to have_been_made.at_least_once - expect(status.preview_card.title).to eq("SJISのページ") + expect(status.preview_cards.first.title).to eq("SJISのページ") end end @@ -40,7 +40,7 @@ RSpec.describe FetchLinkCardService do it 'works with SJIS even with wrong charset header' do expect(a_request(:get, 'http://example.com/sjis_with_wrong_charset')).to have_been_made.at_least_once - expect(status.preview_card.title).to eq("SJISのページ") + expect(status.preview_cards.first.title).to eq("SJISのページ") end end @@ -49,13 +49,13 @@ RSpec.describe FetchLinkCardService do it 'works with koi8-r' do expect(a_request(:get, 'http://example.com/koi8-r')).to have_been_made.at_least_once - expect(status.preview_card.title).to eq("Московя начинаетъ только въ XVI ст. привлекать внимане иностранцевъ.") + expect(status.preview_cards.first.title).to eq("Московя начинаетъ только въ XVI ст. привлекать внимане иностранцевъ.") end end end context 'in a remote status' do - let(:status) { Fabricate(:status, uri: 'abc', text: 'Habt ihr ein paar gute Links zu #<span class="tag"><a href="https://quitter.se/tag/wannacry" target="_blank" rel="tag noopener" title="https://quitter.se/tag/wannacry">Wannacry</a></span> herumfliegen? Ich will mal unter <br> <a href="https://github.com/qbi/WannaCry" target="_blank" rel="noopener" title="https://github.com/qbi/WannaCry">https://github.com/qbi/WannaCry</a> was sammeln. !<a href="http://sn.jonkman.ca/group/416/id" target="_blank" rel="noopener" title="http://sn.jonkman.ca/group/416/id">security</a> ') } + let(:status) { Fabricate(:status, account: Fabricate(:account, domain: 'example.com'), text: 'Habt ihr ein paar gute Links zu #<span class="tag"><a href="https://quitter.se/tag/wannacry" target="_blank" rel="tag noopener" title="https://quitter.se/tag/wannacry">Wannacry</a></span> herumfliegen? Ich will mal unter <br> <a href="https://github.com/qbi/WannaCry" target="_blank" rel="noopener" title="https://github.com/qbi/WannaCry">https://github.com/qbi/WannaCry</a> was sammeln. !<a href="http://sn.jonkman.ca/group/416/id" target="_blank" rel="noopener" title="http://sn.jonkman.ca/group/416/id">security</a> ') } it 'parses out URLs' do expect(a_request(:head, 'https://github.com/qbi/WannaCry')).to have_been_made.at_least_once diff --git a/spec/services/fetch_remote_resource_service_spec.rb b/spec/services/fetch_remote_resource_service_spec.rb index 81b0e48e3..c14fcfc4e 100644 --- a/spec/services/fetch_remote_resource_service_spec.rb +++ b/spec/services/fetch_remote_resource_service_spec.rb @@ -30,7 +30,7 @@ describe FetchRemoteResourceService do _result = subject.call(url) - expect(account_service).to have_received(:call).with(feed_url, feed_content) + expect(account_service).to have_received(:call).with(feed_url, feed_content, nil) end it 'fetches remote statuses for entry types' do @@ -47,7 +47,7 @@ describe FetchRemoteResourceService do _result = subject.call(url) - expect(account_service).to have_received(:call).with(feed_url, feed_content) + expect(account_service).to have_received(:call).with(feed_url, feed_content, nil) end end end diff --git a/spec/services/follow_service_spec.rb b/spec/services/follow_service_spec.rb index 32dedb3ad..1e2378031 100644 --- a/spec/services/follow_service_spec.rb +++ b/spec/services/follow_service_spec.rb @@ -44,9 +44,9 @@ RSpec.describe FollowService do end end - context 'remote account' do + context 'remote OStatus account' do describe 'locked account' do - let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, locked: true, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com')).account } + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, protocol: :ostatus, locked: true, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com')).account } before do stub_request(:post, "http://salmon.example.com/").to_return(:status => 200, :body => "", :headers => {}) @@ -66,7 +66,7 @@ RSpec.describe FollowService do end describe 'unlocked account' do - let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com', hub_url: 'http://hub.example.com')).account } + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, protocol: :ostatus, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com', hub_url: 'http://hub.example.com')).account } before do stub_request(:post, "http://salmon.example.com/").to_return(:status => 200, :body => "", :headers => {}) @@ -91,7 +91,7 @@ RSpec.describe FollowService do end describe 'already followed account' do - let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com', hub_url: 'http://hub.example.com')).account } + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, protocol: :ostatus, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com', hub_url: 'http://hub.example.com')).account } before do sender.follow!(bob) @@ -111,4 +111,21 @@ RSpec.describe FollowService do end end end + + context 'remote ActivityPub account' do + let(:bob) { Fabricate(:user, account: Fabricate(:account, username: 'bob', domain: 'example.com', protocol: :activitypub, inbox_url: 'http://example.com/inbox')).account } + + before do + stub_request(:post, "http://example.com/inbox").to_return(:status => 200, :body => "", :headers => {}) + subject.call(sender, bob.acct) + end + + it 'creates follow request' do + expect(FollowRequest.find_by(account: sender, target_account: bob)).to_not be_nil + end + + it 'sends a follow activity to the inbox' do + expect(a_request(:post, 'http://example.com/inbox')).to have_been_made.once + end + end end diff --git a/spec/services/post_status_service_spec.rb b/spec/services/post_status_service_spec.rb index 57876dcc2..4182c4e1f 100644 --- a/spec/services/post_status_service_spec.rb +++ b/spec/services/post_status_service_spec.rb @@ -100,16 +100,18 @@ RSpec.describe PostStatusService do expect(hashtags_service).to have_received(:call).with(status) end - it 'pings PuSH hubs' do + it 'gets distributed' do allow(DistributionWorker).to receive(:perform_async) allow(Pubsubhubbub::DistributionWorker).to receive(:perform_async) + allow(ActivityPub::DistributionWorker).to receive(:perform_async) + account = Fabricate(:account) status = subject.call(account, "test status update") expect(DistributionWorker).to have_received(:perform_async).with(status.id) - expect(Pubsubhubbub::DistributionWorker). - to have_received(:perform_async).with(status.stream_entry.id) + expect(Pubsubhubbub::DistributionWorker).to have_received(:perform_async).with(status.stream_entry.id) + expect(ActivityPub::DistributionWorker).to have_received(:perform_async).with(status.id) end it 'crawls links' do diff --git a/spec/services/process_feed_service_spec.rb b/spec/services/process_feed_service_spec.rb index 5e34370ee..aca675dc6 100644 --- a/spec/services/process_feed_service_spec.rb +++ b/spec/services/process_feed_service_spec.rb @@ -124,8 +124,7 @@ RSpec.describe ProcessFeedService do </entry> XML - stub_request(:head, 'https://overwatch.com/users/tracer/updates/1').to_return(status: 200, headers: { 'Content-Type' => 'application/atom+xml' }) - stub_request(:get, 'https://overwatch.com/users/tracer/updates/1').to_return(status: 200, body: real_body) + stub_request(:get, 'https://overwatch.com/users/tracer/updates/1').to_return(status: 200, body: real_body, headers: { 'Content-Type' => 'application/atom+xml' }) bad_actor = Fabricate(:account, username: 'sombra', domain: 'talon.xyz') @@ -168,7 +167,7 @@ XML end it 'ignores reblogs if it failed to retreive reblogged statuses' do - stub_request(:head, 'https://overwatch.com/users/tracer/updates/1').to_return(status: 404) + stub_request(:get, 'https://overwatch.com/users/tracer/updates/1').to_return(status: 404) actor = Fabricate(:account, username: 'tracer', domain: 'overwatch.com') diff --git a/spec/services/process_mentions_service_spec.rb b/spec/services/process_mentions_service_spec.rb index 984d13746..09f8fa45b 100644 --- a/spec/services/process_mentions_service_spec.rb +++ b/spec/services/process_mentions_service_spec.rb @@ -1,22 +1,44 @@ require 'rails_helper' RSpec.describe ProcessMentionsService do - let(:account) { Fabricate(:account, username: 'alice') } - let(:remote_user) { Fabricate(:account, username: 'remote_user', domain: 'example.com', salmon_url: 'http://salmon.example.com') } - let(:status) { Fabricate(:status, account: account, text: "Hello @#{remote_user.acct}") } + let(:account) { Fabricate(:account, username: 'alice') } + let(:status) { Fabricate(:status, account: account, text: "Hello @#{remote_user.acct}") } - subject { ProcessMentionsService.new } + context 'OStatus' do + let(:remote_user) { Fabricate(:account, username: 'remote_user', protocol: :ostatus, domain: 'example.com', salmon_url: 'http://salmon.example.com') } - before do - stub_request(:post, remote_user.salmon_url) - subject.(status) - end + subject { ProcessMentionsService.new } + + before do + stub_request(:post, remote_user.salmon_url) + subject.call(status) + end - it 'creates a mention' do - expect(remote_user.mentions.where(status: status).count).to eq 1 + it 'creates a mention' do + expect(remote_user.mentions.where(status: status).count).to eq 1 + end + + it 'posts to remote user\'s Salmon end point' do + expect(a_request(:post, remote_user.salmon_url)).to have_been_made.once + end end - it 'posts to remote user\'s Salmon end point' do - expect(a_request(:post, remote_user.salmon_url)).to have_been_made + context 'ActivityPub' do + let(:remote_user) { Fabricate(:account, username: 'remote_user', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') } + + subject { ProcessMentionsService.new } + + before do + stub_request(:post, remote_user.inbox_url) + subject.call(status) + end + + it 'creates a mention' do + expect(remote_user.mentions.where(status: status).count).to eq 1 + end + + it 'sends activity to the inbox' do + expect(a_request(:post, remote_user.inbox_url)).to have_been_made.once + end end end diff --git a/spec/services/reblog_service_spec.rb b/spec/services/reblog_service_spec.rb index 5f89169e9..0ad5c5f6b 100644 --- a/spec/services/reblog_service_spec.rb +++ b/spec/services/reblog_service_spec.rb @@ -2,22 +2,49 @@ require 'rails_helper' RSpec.describe ReblogService do let(:alice) { Fabricate(:account, username: 'alice') } - let(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com') } - let(:status) { Fabricate(:status, account: bob, uri: 'tag:example.com;something:something') } - subject { ReblogService.new } + context 'OStatus' do + let(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com') } + let(:status) { Fabricate(:status, account: bob, uri: 'tag:example.com;something:something') } - before do - stub_request(:post, 'http://salmon.example.com') + subject { ReblogService.new } - subject.(alice, status) - end + before do + stub_request(:post, 'http://salmon.example.com') + subject.call(alice, status) + end + + it 'creates a reblog' do + expect(status.reblogs.count).to eq 1 + end - it 'creates a reblog' do - expect(status.reblogs.count).to eq 1 + it 'sends a Salmon slap for a remote reblog' do + expect(a_request(:post, 'http://salmon.example.com')).to have_been_made + end end - it 'sends a Salmon slap for a remote reblog' do - expect(a_request(:post, 'http://salmon.example.com')).to have_been_made + context 'ActivityPub' do + let(:bob) { Fabricate(:account, username: 'bob', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') } + let(:status) { Fabricate(:status, account: bob) } + + subject { ReblogService.new } + + before do + stub_request(:post, bob.inbox_url) + allow(ActivityPub::DistributionWorker).to receive(:perform_async) + subject.call(alice, status) + end + + it 'creates a reblog' do + expect(status.reblogs.count).to eq 1 + end + + it 'distributes to followers' do + expect(ActivityPub::DistributionWorker).to have_received(:perform_async) + end + + it 'sends an announce activity to the author' do + expect(a_request(:post, bob.inbox_url)).to have_been_made.once + end end end diff --git a/spec/services/reject_follow_service_spec.rb b/spec/services/reject_follow_service_spec.rb index 50749b633..2e06345b3 100644 --- a/spec/services/reject_follow_service_spec.rb +++ b/spec/services/reject_follow_service_spec.rb @@ -22,7 +22,7 @@ RSpec.describe RejectFollowService do end end - describe 'remote' do + describe 'remote OStatus' do let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com')).account } before do @@ -46,4 +46,26 @@ RSpec.describe RejectFollowService do }).to have_been_made.once end end + + describe 'remote ActivityPub' do + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', domain: 'example.com', protocol: :activitypub, inbox_url: 'http://example.com/inbox')).account } + + before do + FollowRequest.create(account: bob, target_account: sender) + stub_request(:post, bob.inbox_url).to_return(status: 200) + subject.call(bob, sender) + end + + it 'removes follow request' do + expect(bob.requested?(sender)).to be false + end + + it 'does not create follow relation' do + expect(bob.following?(sender)).to be false + end + + it 'sends a reject activity' do + expect(a_request(:post, bob.inbox_url)).to have_been_made.once + end + end end diff --git a/spec/services/remove_status_service_spec.rb b/spec/services/remove_status_service_spec.rb index a3bce7613..8b34bdb6b 100644 --- a/spec/services/remove_status_service_spec.rb +++ b/spec/services/remove_status_service_spec.rb @@ -6,14 +6,21 @@ RSpec.describe RemoveStatusService do let!(:alice) { Fabricate(:account) } let!(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://example.com/salmon') } let!(:jeff) { Fabricate(:account) } + let!(:hank) { Fabricate(:account, username: 'hank', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') } + let!(:bill) { Fabricate(:account, username: 'bill', protocol: :activitypub, domain: 'example2.com', inbox_url: 'http://example2.com/inbox') } before do stub_request(:post, 'http://example.com/push').to_return(status: 200, body: '', headers: {}) stub_request(:post, 'http://example.com/salmon').to_return(status: 200, body: '', headers: {}) + stub_request(:post, 'http://example.com/inbox').to_return(status: 200) + stub_request(:post, 'http://example2.com/inbox').to_return(status: 200) Fabricate(:subscription, account: alice, callback_url: 'http://example.com/push', confirmed: true, expires_at: 30.days.from_now) jeff.follow!(alice) + hank.follow!(alice) + @status = PostStatusService.new.call(alice, 'Hello @bob@example.com') + Fabricate(:status, account: bill, reblog: @status, uri: 'hoge') subject.call(@status) end @@ -31,10 +38,18 @@ RSpec.describe RemoveStatusService do }).to have_been_made end + it 'sends delete activity to followers' do + expect(a_request(:post, 'http://example.com/inbox')).to have_been_made.twice + end + it 'sends Salmon slap to previously mentioned users' do expect(a_request(:post, "http://example.com/salmon").with { |req| xml = OStatus2::Salmon.new.unpack(req.body) xml.match(TagManager::VERBS[:delete]) }).to have_been_made.once end + + it 'sends delete activity to rebloggers' do + expect(a_request(:post, 'http://example2.com/inbox')).to have_been_made + end end diff --git a/spec/services/resolve_remote_account_service_spec.rb b/spec/services/resolve_remote_account_service_spec.rb index c3b902b34..d0eab2310 100644 --- a/spec/services/resolve_remote_account_service_spec.rb +++ b/spec/services/resolve_remote_account_service_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' RSpec.describe ResolveRemoteAccountService do - subject { ResolveRemoteAccountService.new } + subject { described_class.new } before do stub_request(:get, "https://quitter.no/.well-known/host-meta").to_return(request_fixture('.host-meta.txt')) @@ -29,29 +29,6 @@ RSpec.describe ResolveRemoteAccountService do expect(subject.call('catsrgr8@example.com')).to be_nil end - it 'returns an already existing remote account' do - old_account = Fabricate(:account, username: 'gargron', domain: 'quitter.no') - returned_account = subject.call('gargron@quitter.no') - - expect(old_account.id).to eq returned_account.id - end - - it 'returns a new remote account' do - account = subject.call('gargron@quitter.no') - - expect(account.username).to eq 'gargron' - expect(account.domain).to eq 'quitter.no' - expect(account.remote_url).to eq 'https://quitter.no/api/statuses/user_timeline/7477.atom' - end - - it 'follows a legitimate account redirection' do - account = subject.call('gargron@redirected.com') - - expect(account.username).to eq 'gargron' - expect(account.domain).to eq 'quitter.no' - expect(account.remote_url).to eq 'https://quitter.no/api/statuses/user_timeline/7477.atom' - end - it 'prevents hijacking existing accounts' do account = subject.call('hacker1@redirected.com') expect(account.salmon_url).to_not eq 'https://hacker.com/main/salmon/user/7477' @@ -61,12 +38,41 @@ RSpec.describe ResolveRemoteAccountService do expect(subject.call('hacker2@redirected.com')).to be_nil end - it 'returns a new remote account' do - account = subject.call('foo@localdomain.com') + context 'with an OStatus account' do + it 'returns an already existing remote account' do + old_account = Fabricate(:account, username: 'gargron', domain: 'quitter.no') + returned_account = subject.call('gargron@quitter.no') + + expect(old_account.id).to eq returned_account.id + end + + it 'returns a new remote account' do + account = subject.call('gargron@quitter.no') + + expect(account.username).to eq 'gargron' + expect(account.domain).to eq 'quitter.no' + expect(account.remote_url).to eq 'https://quitter.no/api/statuses/user_timeline/7477.atom' + end + + it 'follows a legitimate account redirection' do + account = subject.call('gargron@redirected.com') + + expect(account.username).to eq 'gargron' + expect(account.domain).to eq 'quitter.no' + expect(account.remote_url).to eq 'https://quitter.no/api/statuses/user_timeline/7477.atom' + end + + it 'returns a new remote account' do + account = subject.call('foo@localdomain.com') + + expect(account.username).to eq 'foo' + expect(account.domain).to eq 'localdomain.com' + expect(account.remote_url).to eq 'https://webdomain.com/users/foo.atom' + end + end - expect(account.username).to eq 'foo' - expect(account.domain).to eq 'localdomain.com' - expect(account.remote_url).to eq 'https://webdomain.com/users/foo.atom' + context 'with an ActivityPub account' do + pending end it 'processes one remote account at a time using locks' do @@ -78,7 +84,7 @@ RSpec.describe ResolveRemoteAccountService do Thread.new do true while wait_for_start begin - return_values << ResolveRemoteAccountService.new.call('foo@localdomain.com') + return_values << described_class.new.call('foo@localdomain.com') rescue ActiveRecord::RecordNotUnique fail_occurred = true end diff --git a/spec/services/unblock_service_spec.rb b/spec/services/unblock_service_spec.rb index 1b9ae1239..def4981e7 100644 --- a/spec/services/unblock_service_spec.rb +++ b/spec/services/unblock_service_spec.rb @@ -18,7 +18,7 @@ RSpec.describe UnblockService do end end - describe 'remote' do + describe 'remote OStatus' do let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com')).account } before do @@ -28,7 +28,7 @@ RSpec.describe UnblockService do end it 'destroys the blocking relation' do - expect(sender.following?(bob)).to be false + expect(sender.blocking?(bob)).to be false end it 'sends an unblock salmon slap' do @@ -38,4 +38,22 @@ RSpec.describe UnblockService do }).to have_been_made.once end end + + describe 'remote ActivityPub' do + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox')).account } + + before do + sender.block!(bob) + stub_request(:post, 'http://example.com/inbox').to_return(status: 200) + subject.call(sender, bob) + end + + it 'destroys the blocking relation' do + expect(sender.blocking?(bob)).to be false + end + + it 'sends an unblock activity' do + expect(a_request(:post, 'http://example.com/inbox')).to have_been_made.once + end + end end diff --git a/spec/services/unfollow_service_spec.rb b/spec/services/unfollow_service_spec.rb index 8ec2148a1..29040431e 100644 --- a/spec/services/unfollow_service_spec.rb +++ b/spec/services/unfollow_service_spec.rb @@ -18,8 +18,8 @@ RSpec.describe UnfollowService do end end - describe 'remote' do - let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com')).account } + describe 'remote OStatus' do + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', protocol: :ostatus, domain: 'example.com', salmon_url: 'http://salmon.example.com')).account } before do sender.follow!(bob) @@ -38,4 +38,22 @@ RSpec.describe UnfollowService do }).to have_been_made.once end end + + describe 'remote ActivityPub' do + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox')).account } + + before do + sender.follow!(bob) + stub_request(:post, 'http://example.com/inbox').to_return(status: 200) + subject.call(sender, bob) + end + + it 'destroys the following relation' do + expect(sender.following?(bob)).to be false + end + + it 'sends an unfollow activity' do + expect(a_request(:post, 'http://example.com/inbox')).to have_been_made.once + end + end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 2bc462121..eecaec4ac 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,11 +1,15 @@ require 'simplecov' +GC.disable + SimpleCov.start 'rails' do add_group 'Services', 'app/services' add_group 'Presenters', 'app/presenters' add_group 'Validators', 'app/validators' end +gc_counter = -1 + RSpec.configure do |config| config.expect_with :rspec do |expectations| expectations.include_chain_clauses_in_custom_matcher_descriptions = true @@ -22,8 +26,21 @@ RSpec.configure do |config| end config.after :suite do + gc_counter = 0 FileUtils.rm_rf(Dir["#{Rails.root}/spec/test_files/"]) end + + config.after :each do + gc_counter += 1 + + if gc_counter > 19 + GC.enable + GC.start + GC.disable + + gc_counter = 0 + end + end end def body_as_json diff --git a/spec/views/about/show.html.haml_spec.rb b/spec/views/about/show.html.haml_spec.rb index d460adfe5..95a8a6323 100644 --- a/spec/views/about/show.html.haml_spec.rb +++ b/spec/views/about/show.html.haml_spec.rb @@ -15,6 +15,7 @@ describe 'about/show.html.haml', without_verify_partial_doubles: true do site_title: 'something', site_description: 'something', version_number: '1.0', + source_url: 'https://github.com/tootsuite/mastodon', open_registrations: false, closed_registrations_message: 'yes', commit_hash: commit_hash) diff --git a/spec/workers/activitypub/delivery_worker_spec.rb b/spec/workers/activitypub/delivery_worker_spec.rb new file mode 100644 index 000000000..351be185c --- /dev/null +++ b/spec/workers/activitypub/delivery_worker_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe ActivityPub::DeliveryWorker do + subject { described_class.new } + + let(:sender) { Fabricate(:account) } + let(:payload) { 'test' } + + describe 'perform' do + it 'performs a request' do + stub_request(:post, 'https://example.com/api').to_return(status: 200) + subject.perform(payload, sender.id, 'https://example.com/api') + expect(a_request(:post, 'https://example.com/api')).to have_been_made.once + end + + it 'raises when request fails' do + stub_request(:post, 'https://example.com/api').to_return(status: 500) + expect { subject.perform(payload, sender.id, 'https://example.com/api') }.to raise_error Mastodon::UnexpectedResponseError + end + end +end diff --git a/spec/workers/activitypub/distribution_worker_spec.rb b/spec/workers/activitypub/distribution_worker_spec.rb new file mode 100644 index 000000000..368ca025a --- /dev/null +++ b/spec/workers/activitypub/distribution_worker_spec.rb @@ -0,0 +1,48 @@ +require 'rails_helper' + +describe ActivityPub::DistributionWorker do + subject { described_class.new } + + let(:status) { Fabricate(:status) } + let(:follower) { Fabricate(:account, protocol: :activitypub, inbox_url: 'http://example.com') } + + describe '#perform' do + before do + allow(ActivityPub::DeliveryWorker).to receive(:push_bulk) + follower.follow!(status.account) + end + + context 'with public status' do + before do + status.update(visibility: :public) + end + + it 'delivers to followers' do + subject.perform(status.id) + expect(ActivityPub::DeliveryWorker).to have_received(:push_bulk).with(['http://example.com']) + end + end + + context 'with private status' do + before do + status.update(visibility: :private) + end + + it 'delivers to followers' do + subject.perform(status.id) + expect(ActivityPub::DeliveryWorker).to have_received(:push_bulk).with(['http://example.com']) + end + end + + context 'with direct status' do + before do + status.update(visibility: :direct) + end + + it 'does nothing' do + subject.perform(status.id) + expect(ActivityPub::DeliveryWorker).to_not have_received(:push_bulk) + end + end + end +end diff --git a/spec/workers/activitypub/processing_worker_spec.rb b/spec/workers/activitypub/processing_worker_spec.rb new file mode 100644 index 000000000..b42c0bdbc --- /dev/null +++ b/spec/workers/activitypub/processing_worker_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +describe ActivityPub::ProcessingWorker do + subject { described_class.new } + + let(:account) { Fabricate(:account) } + + describe '#perform' do + it 'delegates to ActivityPub::ProcessCollectionService' do + allow(ActivityPub::ProcessCollectionService).to receive(:new).and_return(double(:service, call: nil)) + subject.perform(account.id, '') + expect(ActivityPub::ProcessCollectionService).to have_received(:new) + end + end +end diff --git a/spec/workers/activitypub/update_distribution_worker_spec.rb b/spec/workers/activitypub/update_distribution_worker_spec.rb new file mode 100644 index 000000000..688a424d5 --- /dev/null +++ b/spec/workers/activitypub/update_distribution_worker_spec.rb @@ -0,0 +1,20 @@ +require 'rails_helper' + +describe ActivityPub::UpdateDistributionWorker do + subject { described_class.new } + + let(:account) { Fabricate(:account) } + let(:follower) { Fabricate(:account, protocol: :activitypub, inbox_url: 'http://example.com') } + + describe '#perform' do + before do + allow(ActivityPub::DeliveryWorker).to receive(:push_bulk) + follower.follow!(account) + end + + it 'delivers to followers' do + subject.perform(account.id) + expect(ActivityPub::DeliveryWorker).to have_received(:push_bulk).with(['http://example.com']) + end + end +end diff --git a/spec/workers/pubsubhubbub/distribution_worker_spec.rb b/spec/workers/pubsubhubbub/distribution_worker_spec.rb index 89191c084..5c22e7fa8 100644 --- a/spec/workers/pubsubhubbub/distribution_worker_spec.rb +++ b/spec/workers/pubsubhubbub/distribution_worker_spec.rb @@ -22,24 +22,62 @@ describe Pubsubhubbub::DistributionWorker do end end - describe 'with private status' do - let(:status) { Fabricate(:status, account: alice, text: 'Hello', visibility: :private) } + context 'when OStatus privacy is used' do + around do |example| + before_val = Rails.configuration.x.use_ostatus_privacy + Rails.configuration.x.use_ostatus_privacy = true + example.run + Rails.configuration.x.use_ostatus_privacy = before_val + end - it 'delivers payload only to subscriptions with followers' do - allow(Pubsubhubbub::DeliveryWorker).to receive(:push_bulk) - subject.perform(status.stream_entry.id) - expect(Pubsubhubbub::DeliveryWorker).to have_received(:push_bulk).with([subscription_with_follower]) - expect(Pubsubhubbub::DeliveryWorker).to_not have_received(:push_bulk).with([anonymous_subscription]) + describe 'with private status' do + let(:status) { Fabricate(:status, account: alice, text: 'Hello', visibility: :private) } + + it 'delivers payload only to subscriptions with followers' do + allow(Pubsubhubbub::DeliveryWorker).to receive(:push_bulk) + subject.perform(status.stream_entry.id) + expect(Pubsubhubbub::DeliveryWorker).to have_received(:push_bulk).with([subscription_with_follower]) + expect(Pubsubhubbub::DeliveryWorker).to_not have_received(:push_bulk).with([anonymous_subscription]) + end + end + + describe 'with direct status' do + let(:status) { Fabricate(:status, account: alice, text: 'Hello', visibility: :direct) } + + it 'does not deliver payload' do + allow(Pubsubhubbub::DeliveryWorker).to receive(:push_bulk) + subject.perform(status.stream_entry.id) + expect(Pubsubhubbub::DeliveryWorker).to_not have_received(:push_bulk) + end end end - describe 'with direct status' do - let(:status) { Fabricate(:status, account: alice, text: 'Hello', visibility: :direct) } + context 'when OStatus privacy is not used' do + around do |example| + before_val = Rails.configuration.x.use_ostatus_privacy + Rails.configuration.x.use_ostatus_privacy = false + example.run + Rails.configuration.x.use_ostatus_privacy = before_val + end - it 'does not deliver payload' do - allow(Pubsubhubbub::DeliveryWorker).to receive(:push_bulk) - subject.perform(status.stream_entry.id) - expect(Pubsubhubbub::DeliveryWorker).to_not have_received(:push_bulk) + describe 'with private status' do + let(:status) { Fabricate(:status, account: alice, text: 'Hello', visibility: :private) } + + it 'does not deliver anything' do + allow(Pubsubhubbub::DeliveryWorker).to receive(:push_bulk) + subject.perform(status.stream_entry.id) + expect(Pubsubhubbub::DeliveryWorker).to_not have_received(:push_bulk) + end + end + + describe 'with direct status' do + let(:status) { Fabricate(:status, account: alice, text: 'Hello', visibility: :direct) } + + it 'does not deliver payload' do + allow(Pubsubhubbub::DeliveryWorker).to receive(:push_bulk) + subject.perform(status.stream_entry.id) + expect(Pubsubhubbub::DeliveryWorker).to_not have_received(:push_bulk) + end end end end diff --git a/streaming/index.js b/streaming/index.js index c7e0de96c..3e80c8b30 100644 --- a/streaming/index.js +++ b/streaming/index.js @@ -403,11 +403,11 @@ const startWorker = (workerId) => { }); app.get('/api/v1/streaming/hashtag', (req, res) => { - streamFrom(`timeline:hashtag:${req.query.tag}`, req, streamToHttp(req, res), streamHttpEnd(req), true); + streamFrom(`timeline:hashtag:${req.query.tag.toLowerCase()}`, req, streamToHttp(req, res), streamHttpEnd(req), true); }); app.get('/api/v1/streaming/hashtag/local', (req, res) => { - streamFrom(`timeline:hashtag:${req.query.tag}:local`, req, streamToHttp(req, res), streamHttpEnd(req), true); + streamFrom(`timeline:hashtag:${req.query.tag.toLowerCase()}:local`, req, streamToHttp(req, res), streamHttpEnd(req), true); }); const wss = new WebSocket.Server({ server, verifyClient: wsVerifyClient }); @@ -438,10 +438,10 @@ const startWorker = (workerId) => { streamFrom('timeline:public:local', req, streamToWs(req, ws), streamWsEnd(req, ws), true); break; case 'hashtag': - streamFrom(`timeline:hashtag:${location.query.tag}`, req, streamToWs(req, ws), streamWsEnd(req, ws), true); + streamFrom(`timeline:hashtag:${location.query.tag.toLowerCase()}`, req, streamToWs(req, ws), streamWsEnd(req, ws), true); break; case 'hashtag:local': - streamFrom(`timeline:hashtag:${location.query.tag}:local`, req, streamToWs(req, ws), streamWsEnd(req, ws), true); + streamFrom(`timeline:hashtag:${location.query.tag.toLowerCase()}:local`, req, streamToWs(req, ws), streamWsEnd(req, ws), true); break; default: ws.close(); diff --git a/yarn.lock b/yarn.lock index cfb0f5175..c1c27a615 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3130,23 +3130,17 @@ intl-messageformat-parser@^1.2.0: version "1.3.0" resolved "https://registry.yarnpkg.com/intl-messageformat-parser/-/intl-messageformat-parser-1.3.0.tgz#c5d26ffb894c7d9c2b9fa444c67f417ab2594268" -intl-messageformat@1.3.0, intl-messageformat@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/intl-messageformat/-/intl-messageformat-1.3.0.tgz#f7d926aded7a3ab19b2dc601efd54e99a4bd4eae" - dependencies: - intl-messageformat-parser "1.2.0" - intl-messageformat@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/intl-messageformat/-/intl-messageformat-2.0.0.tgz#3d56982583425aee23b76c8b985fb9b0aae5be3c" dependencies: intl-messageformat-parser "1.2.0" -intl-relativeformat@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/intl-relativeformat/-/intl-relativeformat-1.3.0.tgz#893dc7076fccd380cf091a2300c380fa57ace45b" +intl-messageformat@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/intl-messageformat/-/intl-messageformat-2.1.0.tgz#1c51da76f02a3f7b360654cdc51bbc4d3fa6c72c" dependencies: - intl-messageformat "1.3.0" + intl-messageformat-parser "1.2.0" intl-relativeformat@^2.0.0: version "2.0.0" @@ -5312,13 +5306,13 @@ react-intl-translations-manager@^5.0.0: json-stable-stringify "^1.0.1" mkdirp "^0.5.1" -react-intl@^2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/react-intl/-/react-intl-2.3.0.tgz#e1df6af5667fdf01cbe4aab20e137251e2ae5142" +react-intl@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/react-intl/-/react-intl-2.4.0.tgz#66c14dc9df9a73b2fbbfbd6021726e80a613eb15" dependencies: intl-format-cache "^2.0.5" - intl-messageformat "^1.3.0" - intl-relativeformat "^1.3.0" + intl-messageformat "^2.1.0" + intl-relativeformat "^2.0.0" invariant "^2.1.1" react-motion@^0.5.0: |